Skip to content

Commit

Permalink
feat(publish): Support 2FA during publish (WIP)
Browse files Browse the repository at this point in the history
TODO: unit tests (direct + high-level)

refs #1091
  • Loading branch information
evocateur committed Jan 1, 2019
1 parent 00a372e commit eed401a
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 0 deletions.
1 change: 1 addition & 0 deletions commands/publish/__tests__/publish-canary.test.js
Expand Up @@ -5,6 +5,7 @@ jest.unmock("@lerna/collect-updates");

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");

Expand Down
2 changes: 2 additions & 0 deletions commands/publish/__tests__/publish-command.test.js
Expand Up @@ -2,6 +2,8 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/prompt-one-time-password");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
jest.mock("../lib/get-unpublished-packages");
Expand Down
1 change: 1 addition & 0 deletions commands/publish/__tests__/publish-licenses.test.js
@@ -1,6 +1,7 @@
"use strict";

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
jest.mock("../lib/create-temp-licenses", () => jest.fn(() => Promise.resolve()));
Expand Down
Expand Up @@ -2,6 +2,7 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
// FIXME: better mock for version command
Expand Down
Expand Up @@ -5,6 +5,7 @@ jest.unmock("@lerna/collect-updates");

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
// FIXME: better mock for version command
Expand Down
1 change: 1 addition & 0 deletions commands/publish/__tests__/publish-tagging.test.js
Expand Up @@ -2,6 +2,7 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
// FIXME: better mock for version command
Expand Down
24 changes: 24 additions & 0 deletions commands/publish/index.js
Expand Up @@ -37,6 +37,8 @@ const getPackagesWithoutLicense = require("./lib/get-packages-without-license");
const gitCheckout = require("./lib/git-checkout");
const removeTempLicenses = require("./lib/remove-temp-licenses");
const verifyNpmPackageAccess = require("./lib/verify-npm-package-access");
const getTwoFactorAuthRequired = require("./lib/get-two-factor-auth-required");
const promptOneTimePassword = require("./lib/prompt-one-time-password");

module.exports = factory;

Expand Down Expand Up @@ -408,6 +410,10 @@ class PublishCommand extends Command {
// if no username was retrieved, don't bother validating
if (this.conf.get("username") && this.verifyAccess) {
chain = chain.then(() => verifyNpmPackageAccess(this.packagesToPublish, this.conf.snapshot));
chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
chain = chain.then(isRequired => {
this.twoFactorAuthRequired = isRequired;
});
}

return chain;
Expand Down Expand Up @@ -509,6 +515,14 @@ class PublishCommand extends Command {
});
}

requestOneTimePassword() {
return Promise.resolve()
.then(() => promptOneTimePassword())
.then(otp => {
this.conf.set("otp", otp);
});
}

packUpdated() {
const tracker = this.logger.newItem("npm pack");

Expand Down Expand Up @@ -566,6 +580,10 @@ class PublishCommand extends Command {

let chain = Promise.resolve();

if (this.twoFactorAuthRequired) {
chain = chain.then(() => this.requestOneTimePassword());
}

const opts = this.conf.snapshot;
const mapper = pPipe(
[
Expand Down Expand Up @@ -600,6 +618,12 @@ class PublishCommand extends Command {

let chain = Promise.resolve();

// there's no reasonable way to guess if the OTP has expired already,
// so we have to ask for it every time
if (this.twoFactorAuthRequired) {
chain = chain.then(() => this.requestOneTimePassword());
}

const opts = this.conf.snapshot;
const mapper = pkg => {
const spec = `${pkg.name}@${pkg.version}`;
Expand Down
@@ -0,0 +1,4 @@
"use strict";

// to mock user modules, you _must_ call `jest.mock('./path/to/module')`
module.exports = jest.fn(() => Promise.resolve(false));
4 changes: 4 additions & 0 deletions commands/publish/lib/__mocks__/prompt-one-time-password.js
@@ -0,0 +1,4 @@
"use strict";

// to mock user modules, you _must_ call `jest.mock('./path/to/module')`
module.exports = jest.fn(() => Promise.resolve("MOCK_OTP"));
52 changes: 52 additions & 0 deletions commands/publish/lib/get-two-factor-auth-required.js
@@ -0,0 +1,52 @@
"use strict";

const profile = require("libnpm/profile");
const ValidationError = require("@lerna/validation-error");
const FetchConfig = require("./fetch-config");

module.exports = getTwoFactorAuthRequired;

function getTwoFactorAuthRequired(_opts) {
const opts = FetchConfig(_opts, {
// don't wait forever for third-party failures to be dealt with
"fetch-retries": 0,
});

opts.log.silly("getTwoFactorAuthRequired", opts.registry);

return profile.get(opts).then(success, failure);

function success(result) {
opts.log.silly("2FA", result.tfa);

if (result.tfa.pending) {
// if 2FA is pending, it is disabled
return false;
}

return result.tfa.mode === "auth-and-writes";
}

function failure(err) {
// pass if registry does not support profile endpoint
if (err.code === "E500" || err.code === "E404") {
// most likely a private registry (npm Enterprise, verdaccio, etc)
opts.log.warn(
"EREGISTRY",
"Registry %j does not support `npm profile get`, skipping two-factor auth check...",
// registry
opts.registry
);

// don't log redundant errors
return false;
}

// Log the error cleanly to stderr
opts.log.pause();
console.error(err.message); // eslint-disable-line no-console
opts.log.resume();

throw new ValidationError("ETWOFACTOR", "Unable to obtain two-factor auth mode");
}
}
16 changes: 16 additions & 0 deletions commands/publish/lib/prompt-one-time-password.js
@@ -0,0 +1,16 @@
"use strict";

const PromptUtilities = require("@lerna/prompt");

module.exports = promptOneTimePassword;

function promptOneTimePassword() {
// Logic taken from npm internals: https://git.io/fNoMe
return PromptUtilities.input("Enter OTP", {
filter: otp => otp.replace(/\s+/g, ""),
validate: otp =>
(otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) ||
"Must be a valid one-time-password. " +
"See https://docs.npmjs.com/getting-started/using-two-factor-authentication",
});
}

0 comments on commit eed401a

Please sign in to comment.