Merge pull request #12890 from WalkingPizza/feature/user-permissions-change-password

Allow users to change own password
This commit is contained in:
Alexandre BODIN 2022-08-05 21:57:48 +02:00 committed by GitHub
commit 6c68176efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 293 additions and 17 deletions

View File

@ -185,6 +185,41 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/auth/change-password:
post:
tags:
- Users-Permissions - Auth
summary: Update user's own password
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
password:
required: true
type: string
currentPassword:
required: true
type: string
passwordConfirmation:
required: true
type: string
responses:
200:
description: Returns a jwt token and user info
content:
application/json:
schema:
$ref: '#/components/schemas/Users-Permissions-UserRegistration'
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/email-confirmation:
get:
tags:

View File

@ -18,6 +18,7 @@ const {
validateForgotPasswordBody,
validateResetPasswordBody,
validateEmailConfirmationBody,
validateChangePasswordBody,
} = require('./validation/auth');
const { getAbsoluteAdminUrl, getAbsoluteServerUrl, sanitize } = utils;
@ -90,18 +91,36 @@ module.exports = {
user: await sanitizeUser(user, ctx),
});
}
},
// Connect the user with the third-party provider.
try {
const user = await getService('providers').connect(provider, ctx.query);
return ctx.send({
jwt: getService('jwt').issue({ id: user.id }),
user: await sanitizeUser(user, ctx),
});
} catch (error) {
throw new ApplicationError(error.message);
async changePassword(ctx) {
if (!ctx.state.user) {
throw new ApplicationError('You must be authenticated to reset your password');
}
const { currentPassword, password } = await validateChangePasswordBody(ctx.request.body);
const user = await strapi.entityService.findOne(
'plugin::users-permissions.user',
ctx.state.user.id
);
const validPassword = await getService('user').validatePassword(currentPassword, user.password);
if (!validPassword) {
throw new ValidationError('The provided current password is invalid');
}
if (currentPassword === password) {
throw new ValidationError('Your new password must be different than your current password');
}
await getService('user').edit(user.id, { password });
ctx.send({
jwt: getService('jwt').issue({ id: user.id }),
user: await sanitizeUser(user, ctx),
});
},
async resetPassword(ctx) {

View File

@ -44,6 +44,17 @@ const resetPasswordSchema = yup
})
.noUnknown();
const changePasswordSchema = yup
.object({
password: yup.string().required(),
passwordConfirmation: yup
.string()
.required()
.oneOf([yup.ref('password')], 'Passwords do not match'),
currentPassword: yup.string().required(),
})
.noUnknown();
module.exports = {
validateCallbackBody: validateYupSchema(callbackSchema),
validateRegisterBody: validateYupSchema(registerSchema),
@ -51,4 +62,5 @@ module.exports = {
validateEmailConfirmationBody: validateYupSchema(validateEmailConfirmationSchema),
validateForgotPasswordBody: validateYupSchema(forgotPasswordSchema),
validateResetPasswordBody: validateYupSchema(resetPasswordSchema),
validateChangePasswordBody: validateYupSchema(changePasswordSchema),
};

View File

@ -0,0 +1,41 @@
'use strict';
const { toPlainObject } = require('lodash/fp');
const { checkBadRequest } = require('../../utils');
module.exports = ({ nexus, strapi }) => {
const { nonNull } = nexus;
return {
type: 'UsersPermissionsLoginPayload',
args: {
currentPassword: nonNull('String'),
password: nonNull('String'),
passwordConfirmation: nonNull('String'),
},
description: 'Change user password. Confirm with the current password.',
async resolve(parent, args, context) {
const { koaContext } = context;
koaContext.request.body = toPlainObject(args);
await strapi
.plugin('users-permissions')
.controller('auth')
.changePassword(koaContext);
const output = koaContext.body;
checkBadRequest(output);
return {
user: output.user || output,
jwt: output.jwt,
};
},
};
};

View File

@ -25,6 +25,7 @@ module.exports = context => {
register: require('./auth/register'),
forgotPassword: require('./auth/forgot-password'),
resetPassword: require('./auth/reset-password'),
changePassword: require('./auth/change-password'),
emailConfirmation: require('./auth/email-confirmation'),
};

View File

@ -23,6 +23,11 @@ module.exports = ({ strapi }) => {
'Mutation.forgotPassword': { auth: false },
'Mutation.resetPassword': { auth: false },
'Mutation.emailConfirmation': { auth: false },
'Mutation.changePassword': {
auth: {
scope: 'plugin::users-permissions.auth.changePassword',
},
},
// Scoped auth for replaced CRUD operations
// Role

View File

@ -70,4 +70,13 @@ module.exports = [
prefix: '',
},
},
{
method: 'POST',
path: '/auth/change-password',
handler: 'auth.changePassword',
config: {
middlewares: ['plugin::users-permissions.rateLimit'],
prefix: '',
},
},
];

View File

@ -87,13 +87,6 @@ module.exports = ({ strapi }) => ({
async remove(params) {
return strapi.query('plugin::users-permissions.user').delete({ where: params });
},
isHashed(password) {
if (typeof password !== 'string' || !password) {
return false;
}
return password.split('$').length === 4;
},
validatePassword(password, hash) {
return bcrypt.compare(password, hash);

View File

@ -15,6 +15,7 @@ const DEFAULT_PERMISSIONS = [
{ action: 'plugin::users-permissions.auth.emailConfirmation', roleType: 'public' },
{ action: 'plugin::users-permissions.auth.sendEmailConfirmation', roleType: 'public' },
{ action: 'plugin::users-permissions.user.me', roleType: 'authenticated' },
{ action: 'plugin::users-permissions.auth.changePassword', roleType: 'authenticated' },
];
const transformRoutePrefixFor = pluginName => route => {

View File

@ -0,0 +1,140 @@
'use strict';
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createRequest } = require('../../../../../test/helpers/request');
const { createAuthenticatedUser } = require('../utils');
let strapi;
let rq;
const internals = {
user: {
username: 'test',
email: 'test@strapi.io',
password: 'Test1234',
confirmed: true,
provider: 'local',
},
newPassword: 'Test12345',
};
const data = {};
describe('Auth API', () => {
beforeAll(async () => {
strapi = await createStrapiInstance({ bypassAuth: false });
const { jwt, user } = await createAuthenticatedUser({ strapi, userInfo: internals.user });
data.user = user;
rq = createRequest({ strapi })
.setURLPrefix('/api/auth')
.setToken(jwt);
});
afterAll(async () => {
await strapi.destroy();
});
describe('Change Password', () => {
test('Fails on unauthenticated request', async () => {
const nonAuthRequest = createRequest({ strapi });
const res = await nonAuthRequest({
method: 'POST',
url: '/api/auth/change-password',
body: {},
});
expect(res.statusCode).toBe(403);
});
test('Fails on invalid confirmation password', async () => {
const res = await rq({
method: 'POST',
url: '/change-password',
body: {
password: 'newPassword',
passwordConfirmation: 'somethingElse',
currentPassword: internals.user.password,
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe('Passwords do not match');
});
test('Fails on invalid current password', async () => {
const res = await rq({
method: 'POST',
url: '/change-password',
body: {
password: 'newPassword',
passwordConfirmation: 'newPassword',
currentPassword: 'badPassword',
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe('The provided current password is invalid');
});
test('Fails when current and new password are the same', async () => {
const res = await rq({
method: 'POST',
url: '/change-password',
body: {
password: internals.user.password,
passwordConfirmation: internals.user.password,
currentPassword: internals.user.password,
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe(
'Your new password must be different than your current password'
);
});
test('Returns user info and jwt token on success', async () => {
const res = await rq({
method: 'POST',
url: '/change-password',
body: {
password: internals.newPassword,
passwordConfirmation: internals.newPassword,
currentPassword: internals.user.password,
},
});
expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({
jwt: expect.any(String),
user: {
id: data.user.id,
email: data.user.email,
username: data.user.username,
},
});
});
test('Can login with new password after success', async () => {
const rq = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rq({
method: 'POST',
url: '/local',
body: {
identifier: internals.user.email,
password: internals.newPassword,
},
});
expect(res.statusCode).toBe(200);
});
});
});

View File

@ -0,0 +1,20 @@
'use strict';
const createAuthenticatedUser = async ({ strapi, userInfo }) => {
const defaultRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'authenticated' } });
const user = await strapi.service('plugin::users-permissions.user').add({
role: defaultRole.id,
...userInfo,
});
const jwt = strapi.service('plugin::users-permissions.jwt').issue({ id: user.id });
return { user, jwt };
};
module.exports = {
createAuthenticatedUser,
};