Merge pull request #8311 from strapi/cli/reset-admin-pwd

Reset admin password CLI
This commit is contained in:
Alexandre BODIN 2020-10-14 14:53:10 +02:00 committed by GitHub
commit 8b212d1e87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 375 additions and 72 deletions

View File

@ -32,7 +32,9 @@ options: [--no-run|--use-npm|--debug|--quickstart|--dbclient=<dbclient> --dbhost
- **&#60;dbssl&#62;** and **&#60;dbauth&#62;** are available only for `mongo` and are optional.
- **--dbforce** Allows you to overwrite content if the provided database is not empty. Only available for `postgres`, `mysql`, and is optional.
## strapi develop|dev
## strapi develop
**Alias**: `dev`
Start a Strapi application with autoReload enabled.
@ -89,7 +91,9 @@ options: [--no-optimization]
- **strapi build --no-optimization**<br/>
Builds the administration panel without minimizing the assets. The build duration is faster.
## strapi configuration:dump|config:dump
## strapi configuration:dump
**Alias**: `config:dump`
Dumps configurations to a file or stdout to help you migrate to production.
@ -120,7 +124,9 @@ In case of doubt, you should avoid committing the dump file into a versioning sy
:::
## strapi configuration:restore|config:restore
## strapi configuration:restore
**Alias**: `config:restore`
Restores a configuration dump into your application.
@ -151,6 +157,27 @@ When running the restore command, you can choose from three different strategies
- **merge**: Will create missing keys and merge existing keys with their new value.
- **keep**: Will create missing keys and keep existing keys as is.
## strapi admin:reset-user-password
**Alias** `admin:reset-password`
Reset an admin user's password.
You can pass the email and new password as options or set them interactivly if you call the command without passing the options.
**Example**
```bash
strapi admin:reset-user-password --email=chef@strapi.io --password=Gourmet1234
```
**Options**
| Option | Type | Description |
| -------------- | ------ | ------------------------- |
| -e, --email | string | The user email |
| -p, --password | string | New password for the user |
| -h, --help | | display help for command |
## strapi generate:api
Scaffold a complete API with its configurations, controller, model and service.

View File

@ -20,7 +20,7 @@
"index.js"
],
"dependencies": {
"commander": "^2.20.0",
"commander": "6.1.0",
"strapi-generate-new": "3.2.3"
},
"scripts": {

View File

@ -744,4 +744,86 @@ describe('User', () => {
);
});
});
describe('resetPasswordByEmail', () => {
test('Throws on missing user', async () => {
const email = 'email@email.fr';
const password = 'invalidpass';
const findOne = jest.fn(() => {
return null;
});
global.strapi = {
query() {
return {
findOne,
};
},
};
await expect(userService.resetPasswordByEmail(email, password)).rejects.toEqual(
new Error(`User not found for email: ${email}`)
);
expect(findOne).toHaveBeenCalledWith({ email }, undefined);
});
test.each(['abc', 'Abcd', 'Abcdefgh', 'Abcd123'])(
'Throws on invalid password',
async password => {
const email = 'email@email.fr';
const findOne = jest.fn(() => ({ id: 1 }));
global.strapi = {
query() {
return {
findOne,
};
},
};
await expect(userService.resetPasswordByEmail(email, password)).rejects.toEqual(
new Error(
'Invalid password. Expected a minimum of 8 characters with at least one number and one uppercase letter'
)
);
expect(findOne).toHaveBeenCalledWith({ email }, undefined);
}
);
});
test('Call the update function with the expected params', async () => {
const email = 'email@email.fr';
const password = 'Testing1234';
const hash = 'hash';
const userId = 1;
const findOne = jest.fn(() => ({ id: userId }));
const update = jest.fn();
const hashPassword = jest.fn(() => hash);
global.strapi = {
query() {
return {
findOne,
update,
};
},
admin: {
services: {
auth: {
hashPassword,
},
},
},
};
await userService.resetPasswordByEmail(email, password);
expect(findOne).toHaveBeenCalledWith({ email }, undefined);
expect(update).toHaveBeenCalledWith({ id: userId }, { password: hash });
expect(hashPassword).toHaveBeenCalledWith(password);
});
});

View File

@ -4,6 +4,7 @@ const _ = require('lodash');
const { stringIncludes } = require('strapi-utils');
const { createUser, hasSuperAdminRole } = require('../domain/user');
const { SUPER_ADMIN_CODE } = require('./constants');
const { password: passwordValidator } = require('../validation/common-validators');
const sanitizeUserRoles = role => _.pick(role, ['id', 'name', 'description', 'code']);
@ -43,7 +44,7 @@ const create = async attributes => {
/**
* Update a user in database
* @param params query params to find the user to update
* @param id query params to find the user to update
* @param attributes A partial user object
* @returns {Promise<user>}
*/
@ -89,6 +90,29 @@ const updateById = async (id, attributes) => {
return strapi.query('user', 'admin').update({ id }, attributes);
};
/**
* Reset a user password by email. (Used in admin:reset CLI)
* @param {string} email - user email
* @param {string} password - new password
*/
const resetPasswordByEmail = async (email, password) => {
const user = await findOne({ email });
if (!user) {
throw new Error(`User not found for email: ${email}`);
}
try {
await passwordValidator.validate(password);
} catch (error) {
throw new Error(
'Invalid password. Expected a minimum of 8 characters with at least one number and one uppercase letter'
);
}
await updateById(user.id, { password });
};
/**
* Check if a user is the last super admin
* @param {int|string} userId user's id to look for
@ -320,4 +344,5 @@ module.exports = {
assignARoleToAll,
displayWarningIfUsersDontHaveRole,
migrateUsers,
resetPasswordByEmail,
};

View File

@ -4,18 +4,11 @@
const _ = require('lodash');
const resolveCwd = require('resolve-cwd');
const { yellow } = require('chalk');
const program = require('commander');
const { Command } = require('commander');
const program = new Command();
const packageJSON = require('../package.json');
// Allow us to display `help()`, but omit the wildcard (`*`) command.
program.Command.prototype.usageMinusWildcard = program.usageMinusWildcard = () => {
program.commands = _.reject(program.commands, {
_name: '*',
});
program.help();
};
const checkCwdIsStrapiApp = name => {
let logErrorAndExit = () => {
console.log(
@ -61,37 +54,29 @@ const getLocalScript = name => (...args) => {
});
};
/**
* Normalize version argument
*
* `$ strapi -v`
* `$ strapi -V`
* `$ strapi --version`
* `$ strapi version`
*/
// Initial program setup
program
.storeOptionsAsProperties(false)
.passCommandToAction(false)
.allowUnknownOption(true);
program.allowUnknownOption(true);
// Expose version.
program.version(packageJSON.version, '-v, --version');
// Make `-v` option case-insensitive.
process.argv = _.map(process.argv, arg => {
return arg === '-V' ? '-v' : arg;
});
program.helpOption('-h, --help', 'Display help for command');
program.addHelpCommand('help [command]', 'Display help for command');
// `$ strapi version` (--version synonym)
program.option('-v, --version', 'Output the version number');
program
.command('version')
.description('output your version of Strapi')
.description('Output your version of Strapi')
.action(() => {
console.log(packageJSON.version);
process.stdout.write(packageJSON.version + '\n');
process.exit(0);
});
// `$ strapi console`
program
.command('console')
.description('open the Strapi framework console')
.description('Open the Strapi framework console')
.action(getLocalScript('console'));
// `$ strapi new`
@ -112,7 +97,7 @@ program
.option('--dbauth <dbauth>', 'Authentication Database')
.option('--dbfile <dbfile>', 'Database file path for sqlite')
.option('--dbforce', 'Overwrite database content if any')
.description('create a new application')
.description('Create a new application')
.action(require('../lib/commands/new'));
// `$ strapi start`
@ -125,8 +110,8 @@ program
program
.command('develop')
.alias('dev')
.option('--no-build', 'Disable build', false)
.option('--watch-admin', 'Enable watch', true)
.option('--no-build', 'Disable build')
.option('--watch-admin', 'Enable watch', false)
.option('--browser <name>', 'Open the browser', true)
.description('Start your Strapi application in development mode')
.action(getLocalScript('develop'));
@ -138,8 +123,8 @@ program
.option('-p, --plugin <api>', 'Name of the local plugin')
.option('-e, --extend <api>', 'Name of the plugin to extend')
.option('-c, --connection <connection>', 'The name of the connection to use')
.option('--draft-and-publish <value>', 'Enable draft/publish', false)
.description('generate a basic API')
.option('--draft-and-publish', 'Enable draft/publish', false)
.description('Generate a basic API')
.action((id, attributes, cliArguments) => {
cliArguments.attributes = attributes;
getLocalScript('generate')(id, cliArguments);
@ -151,7 +136,7 @@ program
.option('-a, --api <api>', 'API name to generate the files in')
.option('-p, --plugin <api>', 'Name of the local plugin')
.option('-e, --extend <api>', 'Name of the plugin to extend')
.description('generate a controller for an API')
.description('Generate a controller for an API')
.action(getLocalScript('generate'));
// `$ strapi generate:model`
@ -160,8 +145,8 @@ program
.option('-a, --api <api>', 'API name to generate a sub API')
.option('-p, --plugin <api>', 'plugin name')
.option('-c, --connection <connection>', 'The name of the connection to use')
.option('--draft-and-publish <value>', 'Enable draft/publish', false)
.description('generate a model for an API')
.option('--draft-and-publish', 'Enable draft/publish', false)
.description('Generate a model for an API')
.action((id, attributes, cliArguments) => {
cliArguments.attributes = attributes;
getLocalScript('generate')(id, cliArguments);
@ -172,7 +157,7 @@ program
.command('generate:policy <id>')
.option('-a, --api <api>', 'API name')
.option('-p, --plugin <api>', 'plugin name')
.description('generate a policy for an API')
.description('Generate a policy for an API')
.action(getLocalScript('generate'));
// `$ strapi generate:service`
@ -181,33 +166,33 @@ program
.option('-a, --api <api>', 'API name')
.option('-p, --plugin <api>', 'plugin name')
.option('-t, --tpl <template>', 'template name')
.description('generate a service for an API')
.description('Generate a service for an API')
.action(getLocalScript('generate'));
// `$ strapi generate:plugin`
program
.command('generate:plugin <id>')
.option('-n, --name <name>', 'Plugin name')
.description('generate a basic plugin')
.description('Generate a basic plugin')
.action(getLocalScript('generate'));
program
.command('build')
.option('--clean', 'Remove the build and .cache folders', false)
.option('--no-optimization', 'Build the Administration without assets optimization', false)
.option('--no-optimization', 'Build the Administration without assets optimization')
.description('Builds the strapi admin app')
.action(getLocalScript('build'));
// `$ strapi install`
program
.command('install [plugins...]')
.description('install a Strapi plugin')
.description('Install a Strapi plugin')
.action(getLocalScript('install'));
// `$ strapi uninstall`
program
.command('uninstall [plugins...]')
.description('uninstall a Strapi plugin')
.description('Uninstall a Strapi plugin')
.option('-d, --delete-files', 'Delete files', false)
.action(getLocalScript('uninstall'));
@ -221,38 +206,25 @@ program
program
.command('configuration:dump')
.alias('config:dump')
.description('Dump configurations of your application')
.option('-f, --file <file>', 'Output file, default output is stdout')
.action(getLocalScript('configurationDump'));
program
.command('configuration:restore')
.alias('config:restore')
.description('Restore configurations of your application')
.option('-f, --file <file>', 'Input file, default input is stdin')
.option('-s, --strategy <strategy>', 'Strategy name, one of: "replace", "merge", "keep"')
.action(getLocalScript('configurationRestore'));
/**
* Normalize help argument
*/
// `$ strapi help` (--help synonym)
// Admin
program
.command('help')
.description('output the help')
.action(program.usageMinusWildcard);
.command('admin:reset-user-password')
.alias('admin:reset-password')
.description("Reset an admin user's password")
.option('-e, --email <email>', 'The user email')
.option('-p, --password <password>', 'New password for the user')
.action(getLocalScript('admin-reset'));
// `$ strapi <unrecognized_cmd>`
// Mask the '*' in `help`.
program.command('*').action(program.usageMinusWildcard);
// Don't balk at unknown options.
/**
* `$ strapi`
*/
program.parse(process.argv);
const NO_COMMAND_SPECIFIED = program.args.length === 0;
if (NO_COMMAND_SPECIFIED) {
program.usageMinusWildcard();
}
program.parseAsync(process.argv);

View File

@ -0,0 +1,141 @@
'use strict';
const load = jest.fn(() => mock);
const resetPasswordByEmail = jest.fn();
const admin = {
services: {
user: {
resetPasswordByEmail,
},
},
};
const mock = {
load,
admin,
};
jest.mock('../../index', () => {
return jest.fn(() => mock);
});
const inquirer = require('inquirer');
const resetAdminPasswordCommand = require('../admin-reset');
describe('admin:reset-password command', () => {
beforeEach(() => {
load.mockClear();
resetPasswordByEmail.mockClear();
});
test('resetAdminPasswordCommand accepts direct input', async () => {
const email = 'email@email.fr';
const password = 'testPasword1234';
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await resetAdminPasswordCommand({ email, password });
expect(mockExit).toHaveBeenCalledWith(0);
expect(consoleLog).toHaveBeenCalled();
expect(load).toHaveBeenCalled();
expect(resetPasswordByEmail).toHaveBeenCalledWith(email, password);
mockExit.mockRestore();
consoleLog.mockRestore();
});
describe('Handles prompt input', () => {
test('Only prompt on TTY', async () => {
const tmpTTY = process.stdin.isTTY;
process.stdin.isTTY = false;
// throw so the code will stop executing
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
});
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
await resetAdminPasswordCommand().catch(err => {
expect(err).toEqual(new Error('exit'));
});
expect(consoleError).toBeCalledWith('Missing required options `email` or `password`');
expect(mockExit).toHaveBeenCalledWith(1);
expect(load).not.toHaveBeenCalled();
expect(resetPasswordByEmail).not.toHaveBeenCalled();
process.stdin.isTTY = tmpTTY;
mockExit.mockRestore();
consoleError.mockRestore();
});
test('Stops if not confirmed', async () => {
process.stdin.isTTY = true;
const email = 'email@email.fr';
const password = 'testPasword1234';
const mockInquiry = jest
.spyOn(inquirer, 'prompt')
.mockImplementationOnce(async () => ({ email, password, confirm: false }));
// throw so the code will stop executing
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
});
await resetAdminPasswordCommand().catch(err => {
expect(err).toEqual(new Error('exit'));
});
expect(mockInquiry).toHaveBeenLastCalledWith([
expect.objectContaining({
message: expect.any(String),
name: 'email',
type: 'input',
}),
expect.objectContaining({
message: expect.any(String),
name: 'password',
type: 'password',
}),
expect.objectContaining({
message: expect.any(String),
name: 'confirm',
type: 'confirm',
}),
]);
expect(mockExit).toHaveBeenCalledWith(0);
expect(load).not.toHaveBeenCalled();
expect(resetPasswordByEmail).not.toHaveBeenCalled();
mockExit.mockRestore();
mockInquiry.mockRestore();
});
test('Calls the reset method with user input', async () => {
const email = 'email@email.fr';
const password = 'testPasword1234';
const mockInquiry = jest
.spyOn(inquirer, 'prompt')
.mockImplementationOnce(async () => ({ email, password, confirm: true }));
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await resetAdminPasswordCommand();
expect(mockExit).toHaveBeenCalledWith(0);
expect(consoleLog).toHaveBeenCalled();
expect(load).toHaveBeenCalled();
expect(resetPasswordByEmail).toHaveBeenCalledWith(email, password);
mockInquiry.mockRestore();
mockExit.mockRestore();
consoleLog.mockRestore();
});
});
});

View File

@ -0,0 +1,51 @@
'use strict';
const _ = require('lodash');
const inquirer = require('inquirer');
const strapi = require('../index');
const promptQuestions = [
{ type: 'input', name: 'email', message: 'User email?' },
{ type: 'password', name: 'password', message: 'New password?' },
{
type: 'confirm',
name: 'confirm',
message: "Do you really want to reset this user's password?",
},
];
/**
* Reset user's password
* @param {Object} cmdOptions - command options
* @param {string} cmdOptions.email - user's email
* @param {string} cmdOptions.password - user's new password
*/
module.exports = async function(cmdOptions = {}) {
const { email, password } = cmdOptions;
if (_.isEmpty(email) && _.isEmpty(password) && process.stdin.isTTY) {
const inquiry = await inquirer.prompt(promptQuestions);
if (!inquiry.confirm) {
process.exit(0);
}
return changePassword(inquiry);
}
if (_.isEmpty(email) || _.isEmpty(password)) {
console.error('Missing required options `email` or `password`');
process.exit(1);
}
return changePassword({ email, password });
};
async function changePassword({ email, password }) {
const app = await strapi().load();
await app.admin.services.user.resetPasswordByEmail(email, password);
console.log(`Successfully reset user's password`);
process.exit(0);
}

View File

@ -19,7 +19,7 @@
"chokidar": "3.3.1",
"ci-info": "2.0.0",
"cli-table3": "^0.6.0",
"commander": "^2.20.0",
"commander": "6.1.0",
"cross-spawn": "^6.0.5",
"debug": "^4.1.1",
"delegates": "^1.0.0",

View File

@ -5651,6 +5651,11 @@ commander@2.3.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873"
integrity sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM=
commander@6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc"
integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==
commander@^2.19.0, commander@^2.20.0, commander@^2.20.3:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"