diff --git a/commands/publish/__tests__/publish-canary.test.js b/commands/publish/__tests__/publish-canary.test.js index 3c3c253d2ad..196a035ce1f 100644 --- a/commands/publish/__tests__/publish-canary.test.js +++ b/commands/publish/__tests__/publish-canary.test.js @@ -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"); diff --git a/commands/publish/__tests__/publish-command.test.js b/commands/publish/__tests__/publish-command.test.js index 8bfa5c80035..8e4db4a7334 100644 --- a/commands/publish/__tests__/publish-command.test.js +++ b/commands/publish/__tests__/publish-command.test.js @@ -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"); diff --git a/commands/publish/__tests__/publish-licenses.test.js b/commands/publish/__tests__/publish-licenses.test.js index e0acc01c56d..5bcd55cdfcf 100644 --- a/commands/publish/__tests__/publish-licenses.test.js +++ b/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())); diff --git a/commands/publish/__tests__/publish-lifecycle-scripts.test.js b/commands/publish/__tests__/publish-lifecycle-scripts.test.js index f17c04550b8..7235a90c0ba 100644 --- a/commands/publish/__tests__/publish-lifecycle-scripts.test.js +++ b/commands/publish/__tests__/publish-lifecycle-scripts.test.js @@ -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 diff --git a/commands/publish/__tests__/publish-relative-file-specifiers.test.js b/commands/publish/__tests__/publish-relative-file-specifiers.test.js index e08405ef642..7adfeec91af 100644 --- a/commands/publish/__tests__/publish-relative-file-specifiers.test.js +++ b/commands/publish/__tests__/publish-relative-file-specifiers.test.js @@ -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 diff --git a/commands/publish/__tests__/publish-tagging.test.js b/commands/publish/__tests__/publish-tagging.test.js index 515db1c0d8d..0e71b25674b 100644 --- a/commands/publish/__tests__/publish-tagging.test.js +++ b/commands/publish/__tests__/publish-tagging.test.js @@ -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 diff --git a/commands/publish/index.js b/commands/publish/index.js index 49307c020fd..d85d2c42cee 100644 --- a/commands/publish/index.js +++ b/commands/publish/index.js @@ -36,6 +36,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; @@ -416,6 +418,13 @@ class PublishCommand extends Command { return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot); } }); + + // read profile metadata to determine if account-level 2FA is enabled + chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot)); + chain = chain.then(isRequired => { + // notably, this still doesn't handle package-level 2FA requirements + this.twoFactorAuthRequired = isRequired; + }); } return chain; @@ -517,6 +526,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"); @@ -577,6 +594,10 @@ class PublishCommand extends Command { let chain = Promise.resolve(); + if (this.twoFactorAuthRequired) { + chain = chain.then(() => this.requestOneTimePassword()); + } + const opts = Object.assign(this.conf.snapshot, { // distTag defaults to "latest" OR whatever is in pkg.publishConfig.tag // if we skip temp tags we should tag with the proper value immediately @@ -616,6 +637,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 getDistTag = publishConfig => { if (opts.tag === "latest" && publishConfig && publishConfig.tag) { diff --git a/commands/publish/lib/__mocks__/get-two-factor-auth-required.js b/commands/publish/lib/__mocks__/get-two-factor-auth-required.js new file mode 100644 index 00000000000..459b43ddf05 --- /dev/null +++ b/commands/publish/lib/__mocks__/get-two-factor-auth-required.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(false)); diff --git a/commands/publish/lib/__mocks__/prompt-one-time-password.js b/commands/publish/lib/__mocks__/prompt-one-time-password.js new file mode 100644 index 00000000000..96be4d472d4 --- /dev/null +++ b/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")); diff --git a/commands/publish/lib/get-two-factor-auth-required.js b/commands/publish/lib/get-two-factor-auth-required.js new file mode 100644 index 00000000000..cd2276c45b4 --- /dev/null +++ b/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"); + } +} diff --git a/commands/publish/lib/prompt-one-time-password.js b/commands/publish/lib/prompt-one-time-password.js new file mode 100644 index 00000000000..3d6360440e1 --- /dev/null +++ b/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", + }); +}