Skip to content

Commit b8a565e

Browse files
neonowyarcanis
authored andcommitted
feat(auth): Support two factor authentication for NPM accounts (#6555)
* feat(auth): Support two factor authentication for npm accounts Fix #4904 * Add basic tests * Rename OneTimePasswordRequiredError to OneTimePasswordError Cause it's also thrown when one-time password is invalid. * Remove misleading config parameter from getOneTimePassword * Don't reimplement setOtp in npm-registry.js tests * Update CHANGELOG.md
1 parent 5876b30 commit b8a565e

File tree

11 files changed

+135
-13
lines changed

11 files changed

+135
-13
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ Please add one entry in this file for each change in Yarn's behavior. Use the sa
3232

3333
[#5322](https://github.com/yarnpkg/yarn/pull/5322) - [**Karolis Narkevicius**](https://twitter.com/KidkArolis)
3434

35+
- Adds 2FA (Two Factor Authentication) support to publish & alike
36+
37+
[#6555](https://github.com/yarnpkg/yarn/pull/6555) - [**Krzysztof Zbudniewek**](https://github.com/neonowy)
38+
3539
- Fixes how the `files` property is interpreted to bring it in line with npm
3640

3741
[#6562](https://github.com/yarnpkg/yarn/pull/6562) - [**Bertrand Marron**](https://github.com/tusbar)

__tests__/registries/npm-registry.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ describe('request', () => {
8585
const npmRegistry = new NpmRegistry(testCwd, mockRegistries, mockRequestManager, mockReporter, true, []);
8686
npmRegistry.config = config;
8787
return {
88+
setOtp(otp: string) {
89+
npmRegistry.setOtp(otp);
90+
},
91+
8892
request(url: string, options: Object, packageName: string): Object {
8993
npmRegistry.request(url, options, packageName);
9094
const lastIndex = mockRequestManager.request.mock.calls.length - 1;
@@ -101,6 +105,17 @@ describe('request', () => {
101105
expect(requestParams.url).toBe(url);
102106
});
103107

108+
test('should add `npm-otp` header', () => {
109+
const url = 'https://registry.npmjs.org/yarn';
110+
const config = {};
111+
const registry = createRegistry(config);
112+
113+
registry.setOtp('123 456');
114+
115+
const requestParams = registry.request(url);
116+
expect(requestParams.headers['npm-otp']).toBe('123 456');
117+
});
118+
104119
const testCases = [
105120
{
106121
title: 'using npm as default registry and using private registry for scoped packages',

__tests__/util/request-manager.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* @flow */
22
/* eslint max-len: 0 */
33

4+
import {OneTimePasswordError} from '../../src/errors.js';
45
import {Reporter} from '../../src/reporters/index.js';
56
import Config from '../../src/config.js';
67
import * as fs from '../../src/util/fs.js';
@@ -209,6 +210,48 @@ for (const statusCode of [403, 442]) {
209210
});
210211
}
211212

213+
test('RequestManager.execute one time password error on npm request', async () => {
214+
jest.resetModules();
215+
jest.mock('request', factory => options => {
216+
options.callback(
217+
'',
218+
{statusCode: 401, headers: {'www-authenticate': 'otp'}},
219+
{error: 'You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.'},
220+
);
221+
return {
222+
on: () => {},
223+
};
224+
});
225+
226+
try {
227+
const config = await Config.create({});
228+
await config.requestManager.request({
229+
url: 'https://registry.npmjs.org/yarn',
230+
});
231+
} catch (err) {
232+
expect(err).toBeInstanceOf(OneTimePasswordError);
233+
}
234+
});
235+
236+
test('RequestManager.execute one time password error on npm login request', async () => {
237+
jest.resetModules();
238+
jest.mock('request', factory => options => {
239+
options.callback('', {statusCode: 401, headers: {'www-authenticate': 'otp'}}, {ok: false});
240+
return {
241+
on: () => {},
242+
};
243+
});
244+
245+
try {
246+
const config = await Config.create({});
247+
await config.requestManager.request({
248+
url: 'https://registry.npmjs.org/-/user/org.couchdb.user:user',
249+
});
250+
} catch (err) {
251+
expect(err).toBeInstanceOf(OneTimePasswordError);
252+
}
253+
});
254+
212255
// Cloudflare will occasionally return an html response with a 500 status code on some calls
213256
for (const statusCode of [408, 500, 542]) {
214257
test(`RequestManager.execute retries on ${statusCode} error`, async () => {

src/cli/commands/login.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ async function getCredentials(
3636
return {username, email};
3737
}
3838

39+
export function getOneTimePassword(reporter: Reporter): Promise<string> {
40+
return reporter.question(reporter.lang('npmOneTimePassword'));
41+
}
42+
3943
export async function getToken(
4044
config: Config,
4145
reporter: Reporter,
@@ -45,6 +49,10 @@ export async function getToken(
4549
): Promise<() => Promise<void>> {
4650
const auth = registry ? config.registries.npm.getAuthByRegistry(registry) : config.registries.npm.getAuth(name);
4751

52+
if (config.otp) {
53+
config.registries.npm.setOtp(config.otp);
54+
}
55+
4856
if (auth) {
4957
config.registries.npm.setToken(auth);
5058
return function revoke(): Promise<void> {

src/cli/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export async function main({
122122
);
123123
commander.option('--no-node-version-check', 'do not warn when using a potentially unsupported Node version');
124124
commander.option('--focus', 'Focus on a single workspace by installing remote copies of its sibling workspaces.');
125+
commander.option('--otp <otpcode>', 'one-time password for two factor authentication');
125126

126127
// if -v is the first command, then always exit after returning the version
127128
if (args[0] === '-v') {
@@ -527,6 +528,7 @@ export async function main({
527528
nonInteractive: commander.nonInteractive,
528529
updateChecksums: commander.updateChecksums,
529530
focus: commander.focus,
531+
otp: commander.otp,
530532
})
531533
.then(() => {
532534
// lockfile check must happen after config.init sets lockfileFolder

src/config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export type ConfigOptions = {
6868
updateChecksums?: boolean,
6969

7070
focus?: boolean,
71+
72+
otp?: string,
7173
};
7274

7375
type PackageMetadata = {
@@ -207,6 +209,8 @@ export default class Config {
207209

208210
autoAddIntegrity: boolean;
209211

212+
otp: ?string;
213+
210214
/**
211215
* Execute a promise produced by factory if it doesn't exist in our cache with
212216
* the associated key.
@@ -495,6 +499,8 @@ export default class Config {
495499

496500
this.focus = !!opts.focus;
497501
this.focusedWorkspaceName = '';
502+
503+
this.otp = opts.otp || '';
498504
}
499505

500506
/**

src/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@ export class ResponseError extends Error {
3333

3434
responseCode: number;
3535
}
36+
37+
export class OneTimePasswordError extends Error {}

src/registries/base-registry.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export default class BaseRegistry {
6565
//
6666
token: string;
6767

68+
//
69+
otp: string;
70+
6871
//
6972
cwd: string;
7073

@@ -81,6 +84,10 @@ export default class BaseRegistry {
8184
this.token = token;
8285
}
8386

87+
setOtp(otp: string) {
88+
this.otp = otp;
89+
}
90+
8491
getOption(key: string): mixed {
8592
return this.config[key];
8693
}

src/registries/npm-registry.js

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {addSuffix} from '../util/misc';
1515
import {getPosixPath, resolveWithHome} from '../util/path';
1616
import normalizeUrl from 'normalize-url';
1717
import {default as userHome, home} from '../util/user-home-dir';
18+
import {MessageError, OneTimePasswordError} from '../errors.js';
19+
import {getOneTimePassword} from '../cli/commands/login.js';
1820
import path from 'path';
1921
import url from 'url';
2022
import ini from 'ini';
@@ -133,7 +135,7 @@ export default class NpmRegistry extends Registry {
133135
return (requestToRegistryHost || requestToYarn) && (requestToRegistryPath || customHostSuffixInUse);
134136
}
135137

136-
request(pathname: string, opts?: RegistryRequestOptions = {}, packageName: ?string): Promise<*> {
138+
async request(pathname: string, opts?: RegistryRequestOptions = {}, packageName: ?string): Promise<*> {
137139
// packageName needs to be escaped when if it is passed
138140
const packageIdent = (packageName && NpmRegistry.escapeName(packageName)) || pathname;
139141
const registry = opts.registry || this.getRegistry(packageIdent);
@@ -161,17 +163,38 @@ export default class NpmRegistry extends Registry {
161163
}
162164
}
163165

164-
return this.requestManager.request({
165-
url: requestUrl,
166-
method: opts.method,
167-
body: opts.body,
168-
auth: opts.auth,
169-
headers,
170-
json: !opts.buffer,
171-
buffer: opts.buffer,
172-
process: opts.process,
173-
gzip: true,
174-
});
166+
if (this.otp) {
167+
headers['npm-otp'] = this.otp;
168+
}
169+
170+
try {
171+
return await this.requestManager.request({
172+
url: requestUrl,
173+
method: opts.method,
174+
body: opts.body,
175+
auth: opts.auth,
176+
headers,
177+
json: !opts.buffer,
178+
buffer: opts.buffer,
179+
process: opts.process,
180+
gzip: true,
181+
});
182+
} catch (error) {
183+
if (error instanceof OneTimePasswordError) {
184+
if (this.otp) {
185+
throw new MessageError(this.reporter.lang('incorrectOneTimePassword'));
186+
}
187+
188+
this.reporter.info(this.reporter.lang('twoFactorAuthenticationEnabled'));
189+
this.otp = await getOneTimePassword(this.reporter);
190+
191+
this.requestManager.clearCache();
192+
193+
return this.request(pathname, opts, packageName);
194+
} else {
195+
throw error;
196+
}
197+
}
175198
}
176199

177200
requestNeedsAuth(requestUrl: string): boolean {

src/reporters/lang/en.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ const messages = {
311311
npmUsername: 'npm username',
312312
npmPassword: 'npm password',
313313
npmEmail: 'npm email',
314+
npmOneTimePassword: 'npm one-time password',
314315

315316
loggingIn: 'Logging in',
316317
loggedIn: 'Logged in.',
@@ -322,6 +323,8 @@ const messages = {
322323

323324
loginAsPublic: 'Logging in as public',
324325
incorrectCredentials: 'Incorrect username or password.',
326+
incorrectOneTimePassword: 'Incorrect one-time password.',
327+
twoFactorAuthenticationEnabled: 'Two factor authentication enabled.',
325328
clearedCredentials: 'Cleared login credentials.',
326329

327330
publishFail: "Couldn't publish package: $0",

0 commit comments

Comments
 (0)