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)
- figure out how to make integration tests work again

refs #1091
  • Loading branch information
evocateur committed Aug 2, 2018
1 parent 6237856 commit 876b38d
Show file tree
Hide file tree
Showing 19 changed files with 278 additions and 37 deletions.
3 changes: 3 additions & 0 deletions commands/publish/__tests__/publish-canary.test.js
Expand Up @@ -5,6 +5,9 @@ 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.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");

const fs = require("fs-extra");
const path = require("path");
Expand Down
4 changes: 4 additions & 0 deletions commands/publish/__tests__/publish-command.test.js
Expand Up @@ -2,6 +2,10 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required.js");
jest.mock("../lib/prompt-one-time-password.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");
// FIXME: better mock for version command
jest.mock("../../version/lib/git-push");
jest.mock("../../version/lib/is-anything-committed");
Expand Down
3 changes: 3 additions & 0 deletions commands/publish/__tests__/publish-licenses.test.js
@@ -1,6 +1,9 @@
"use strict";

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-two-factor-auth-required.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");
jest.mock("../lib/create-temp-licenses", () => jest.fn(() => Promise.resolve()));
jest.mock("../lib/remove-temp-licenses", () => jest.fn(() => Promise.resolve()));
// FIXME: better mock for version command
Expand Down
15 changes: 9 additions & 6 deletions commands/publish/__tests__/publish-lifecycle-scripts.test.js
Expand Up @@ -2,6 +2,9 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");
// FIXME: better mock for version command
jest.mock("../../version/lib/git-push");
jest.mock("../../version/lib/is-anything-committed");
Expand All @@ -22,14 +25,17 @@ describe("lifecycle scripts", () => {

await lernaPublish(cwd)();

expect(runLifecycle).toHaveBeenCalledTimes(12);

["prepare", "prepublishOnly", "postpublish"].forEach(script => {
// "lifecycle" is the root manifest name
expect(runLifecycle).toHaveBeenCalledWith(expect.objectContaining({ name: "lifecycle" }), script);
expect(runLifecycle).toHaveBeenCalledWith(expect.objectContaining({ name: "package-1" }), script);
});

// all leaf package lifecycles are called by npm pack
expect(runLifecycle).not.toHaveBeenCalledWith(
expect.objectContaining({ name: "package-1" }),
expect.stringMatching(/(prepare|prepublishOnly|postpublish)/)
);

// package-2 lacks version lifecycle scripts
expect(runLifecycle).not.toHaveBeenCalledWith(
expect.objectContaining({ name: "package-2" }),
Expand All @@ -47,9 +53,6 @@ describe("lifecycle scripts", () => {
// publish-specific
["lifecycle", "prepare"],
["lifecycle", "prepublishOnly"],
["package-1", "prepare"],
["package-1", "prepublishOnly"],
["package-1", "postpublish"],
["lifecycle", "postpublish"],
]);
});
Expand Down
Expand Up @@ -5,6 +5,9 @@ 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.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");
// FIXME: better mock for version command
jest.mock("../../version/lib/git-push");
jest.mock("../../version/lib/is-anything-committed");
Expand Down
3 changes: 3 additions & 0 deletions commands/publish/__tests__/publish-tagging.test.js
Expand Up @@ -2,6 +2,9 @@

// local modules _must_ be explicitly mocked
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/get-two-factor-auth-required.js");
jest.mock("../lib/verify-npm-package-access.js");
jest.mock("../lib/verify-npm-registry.js");
// FIXME: better mock for version command
jest.mock("../../version/lib/git-push");
jest.mock("../../version/lib/is-anything-committed");
Expand Down
87 changes: 63 additions & 24 deletions commands/publish/index.js
Expand Up @@ -25,6 +25,10 @@ const getTaggedPackages = require("./lib/get-tagged-packages");
const getPackagesWithoutLicense = require("./lib/get-packages-without-license");
const gitCheckout = require("./lib/git-checkout");
const removeTempLicenses = require("./lib/remove-temp-licenses");
const verifyNpmRegistry = require("./lib/verify-npm-registry");
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 @@ -89,7 +93,9 @@ class PublishCommand extends Command {
)
: [this.packagesToPublish];

return Promise.resolve().then(() => this.prepareLicenseActions());
return Promise.resolve()
.then(() => this.prepareRegistryActions())
.then(() => this.prepareLicenseActions());
});
}

Expand Down Expand Up @@ -232,6 +238,16 @@ class PublishCommand extends Command {
});
}

prepareRegistryActions() {
return Promise.resolve()
.then(() => verifyNpmRegistry(this.project.rootPath, this.npmConfig))
.then(() => verifyNpmPackageAccess(this.packagesToPublish, this.project.rootPath, this.npmConfig))
.then(() => getTwoFactorAuthRequired(this.project.rootPath, this.npmConfig))
.then(isRequired => {
this.twoFactorAuthRequired = isRequired;
});
}

updateCanaryVersions() {
const publishableUpdates = this.updates.filter(node => !node.pkg.private);

Expand Down Expand Up @@ -328,44 +344,67 @@ class PublishCommand extends Command {
});
}

requestOneTimePassword() {
return Promise.resolve()
.then(() => promptOneTimePassword())
.then(otp => {
this.npmConfig.otp = otp;
});
}

npmPublish() {
const tracker = this.logger.newItem("npmPublish");
// if we skip temp tags we should tag with the proper value immediately
const distTag = this.options.tempTag ? "lerna-temp" : this.getDistTag();
const tracker = this.logger.newItem("npmPublish");

// two batched loops are run, pack _then_ publish
tracker.addWork(this.packagesToPublish.length * 2);

let chain = Promise.resolve();

chain = chain.then(() => createTempLicenses(this.project.licensePath, this.packagesToBeLicensed));

chain = chain.then(() => this.runPrepublishScripts(this.project.manifest));
chain = chain.then(() =>
pMap(this.updates, ({ pkg }) => {
if (this.options.requireScripts) {
this.execScript(pkg, "prepublish");
}

return this.runPrepublishScripts(pkg);
})
);
if (this.options.requireScripts) {
// track completion of prepublish.js on _all_ updates
// and postpublish.js _only_ on public packages
tracker.addWork(this.updates.length + this.packagesToPublish.length);

tracker.addWork(this.packagesToPublish.length);
chain = chain.then(() =>
pMap(this.updates, ({ pkg }) => {
this.execScript(pkg, "prepublish");
tracker.completeWork(1);
})
);
}

const mapPackage = pkg => {
tracker.verbose("publishing", pkg.name);
chain = chain.then(() =>
runParallelBatches(this.batchedPackages, this.concurrency, pkg =>
npmPublish.npmPack(pkg).then(tgzFile => {
pkg.tgzFile = tgzFile;
tracker.completeWork(1);
})
)
);

return npmPublish(pkg, distTag, this.npmConfig).then(() => {
tracker.info("published", pkg.name);
tracker.completeWork(1);
if (this.twoFactorAuthRequired) {
chain = chain.then(() => this.requestOneTimePassword());
}

if (this.options.requireScripts) {
this.execScript(pkg, "postpublish");
}
chain = chain.then(() =>
runParallelBatches(this.batchedPackages, this.concurrency, pkg =>
npmPublish(pkg, distTag, this.npmConfig).then(() => {
tracker.info("published", pkg.name);
tracker.completeWork(1);

return this.runPackageLifecycle(pkg, "postpublish");
});
};
if (this.options.requireScripts) {
this.execScript(pkg, "postpublish");
tracker.completeWork(1);
}
})
)
);

chain = chain.then(() => runParallelBatches(this.batchedPackages, this.concurrency, mapPackage));
chain = chain.then(() => this.runPackageLifecycle(this.project.manifest, "postpublish"));
chain = chain.then(() => removeTempLicenses(this.packagesToBeLicensed));

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"));
4 changes: 4 additions & 0 deletions commands/publish/lib/__mocks__/verify-npm-package-access.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());
4 changes: 4 additions & 0 deletions commands/publish/lib/__mocks__/verify-npm-registry.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());
40 changes: 40 additions & 0 deletions commands/publish/lib/get-two-factor-auth-required.js
@@ -0,0 +1,40 @@
"use strict";

const log = require("npmlog");
const childProcess = require("@lerna/child-process");
const getExecOpts = require("@lerna/get-npm-exec-opts");
const ValidationError = require("@lerna/validation-error");

module.exports = getTwoFactorAuthRequired;

function getTwoFactorAuthRequired(location, { registry }) {
log.silly("getTwoFactorAuthRequired");

const args = [
"profile",
"get",
// next parameter is _not_ a typo...
"two-factor auth",
// immediate feedback from request errors, not excruciatingly slow retries
// @see https://docs.npmjs.com/misc/config#fetch-retries
"--fetch-retries=0",
// including http requests makes raw logging easier to debug for end users
"--loglevel=http",
];
const opts = getExecOpts({ location }, registry);

// we do not need special log handling
delete opts.pkg;

return childProcess.exec("npm", args, opts).then(
result => result.stdout === "auth-and-writes",
({ stderr }) => {
// Log the error cleanly to stderr, it already has npmlog decorations
log.pause();
console.error(stderr); // eslint-disable-line no-console
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",
});
}
71 changes: 71 additions & 0 deletions commands/publish/lib/verify-npm-package-access.js
@@ -0,0 +1,71 @@
"use strict";

const log = require("npmlog");
const childProcess = require("@lerna/child-process");
const getExecOpts = require("@lerna/get-npm-exec-opts");
const ValidationError = require("@lerna/validation-error");

module.exports = verifyNpmPackageAccess;

function verifyNpmPackageAccess(packages, location, { registry }) {
log.silly("verifyNpmPackageAccess");

const args = [
"access",
"ls-packages",
// immediate feedback from request errors, not excruciatingly slow retries
// @see https://docs.npmjs.com/misc/config#fetch-retries
"--fetch-retries=0",
// including http requests makes raw logging easier to debug for end users
"--loglevel=http",
];
const opts = getExecOpts({ location }, registry);

// we do not need special log handling
delete opts.pkg;

return childProcess.exec("npm", args, opts).then(
result => {
const permission = JSON.parse(result.stdout);

for (const pkg of packages) {
if (permission[pkg.name] !== "read-write") {
throw new ValidationError(
"EACCESS",
"You do not have write permission required to publish %j",
pkg.name
);
}
}
},
// only catch npm error, not validation error above
({ stderr }) => {
// pass if registry does not support ls-packages endpoint
if (/E500/.test(stderr) && /ECONNREFUSED/.test(stderr)) {
// most likely a private registry (npm Enterprise, verdaccio, etc)
log.warn(
"EREGISTRY",
"Registry %j does not support `npm access ls-packages`, skipping permission checks...",
registry
);

// don't log redundant errors
return;
}

if (/ENEEDAUTH/.test(stderr)) {
throw new ValidationError(
"ENEEDAUTH",
"You must be logged in to publish packages. Use `npm login` and try again."
);
}

// Log the error cleanly to stderr, it already has npmlog decorations
log.pause();
console.error(stderr); // eslint-disable-line no-console
log.resume();

throw new ValidationError("EWHOAMI", "Authentication error. Use `npm whoami` to troubleshoot.");
}
);
}

0 comments on commit 876b38d

Please sign in to comment.