0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

Merge pull request #2411 from logto-io/charles-log-4072-change-password-session-api

feat(core): add change password session api
This commit is contained in:
Charles Zhao 2022-11-15 17:14:40 +08:00 committed by GitHub
commit 610794b69e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 86 additions and 6 deletions

View file

@ -197,7 +197,7 @@ describe('session -> forgotPasswordRoutes', () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 400);
expect(response).toHaveProperty('status', 422);
expect(updateUserById).toBeCalledTimes(0);
});
it('should redirect when there was no old password', async () => {

View file

@ -46,9 +46,8 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
assertThat(
!oldPasswordEncrypted ||
(oldPasswordEncrypted && !(await argon2Verify({ password, hash: oldPasswordEncrypted }))),
new RequestError({ code: 'user.same_password', status: 400 })
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
new RequestError({ code: 'user.same_password', status: 422 })
);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);

View file

@ -3,7 +3,7 @@ import { SignUpIdentifier } from '@logto/schemas';
import { getUnixTime } from 'date-fns';
import { Provider } from 'oidc-provider';
import { mockUser, mockUserResponse } from '@/__mocks__';
import { mockPasswordEncrypted, mockUser, mockUserResponse } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import profileRoutes, { profileRoute } from './profile';
@ -18,6 +18,15 @@ const mockUpdateUserById = jest.fn(
...data,
})
);
const encryptUserPassword = jest.fn(async (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}));
const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted);
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
result: { login: { accountId: 'id', ts: getUnixTime(new Date()) - 60 } },
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
@ -27,6 +36,11 @@ jest.mock('oidc-provider', () => ({
})),
}));
jest.mock('@/lib/user', () => ({
...jest.requireActual('@/lib/user'),
encryptUserPassword: async (password: string) => encryptUserPassword(password),
}));
jest.mock('@/queries/user', () => ({
...jest.requireActual('@/queries/user'),
findUserById: async () => mockFindUserById(),
@ -48,6 +62,10 @@ jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()),
}));
jest.mock('hash-wasm', () => ({
argon2Verify: async (password: string) => mockArgon2Verify(password),
}));
describe('session -> profileRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: profileRoutes,
@ -90,4 +108,37 @@ describe('session -> profileRoutes', () => {
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/profile/password', () => {
it('should update password with the new value', async () => {
const response = await sessionRequest
.post(`${profileRoute}/password`)
.send({ password: mockPasswordEncrypted });
expect(mockUpdateUserById).toBeCalledWith(
'id',
expect.objectContaining({
passwordEncrypted: 'a1b2c3_user1',
passwordEncryptionMethod: 'Argon2i',
})
);
expect(response.statusCode).toEqual(204);
});
it('should throw if new password is identical to old password', async () => {
jest.clearAllMocks();
encryptUserPassword.mockImplementationOnce(async (password: string) => ({
passwordEncrypted: password,
passwordEncryptionMethod: 'Argon2i',
}));
mockArgon2Verify.mockResolvedValueOnce(true);
const response = await sessionRequest
.post(`${profileRoute}/password`)
.send({ password: 'password' });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
});
});
});

View file

@ -1,11 +1,13 @@
import { usernameRegEx } from '@logto/core-kit';
import { passwordRegEx, usernameRegEx } from '@logto/core-kit';
import { userInfoSelectFields } from '@logto/schemas';
import { argon2Verify } from 'hash-wasm';
import pick from 'lodash.pick';
import type { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { checkSessionHealth } from '@/lib/session';
import { encryptUserPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findUserById, updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
@ -52,4 +54,32 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
return next();
}
);
router.post(
`${profileRoute}/password`,
koaGuard({
body: object({ password: string().regex(passwordRegEx) }),
}),
async (ctx, next) => {
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
assertThat(userId, new RequestError('auth.unauthorized'));
const { password } = ctx.guard.body;
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
assertThat(
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
new RequestError({ code: 'user.same_password', status: 422 })
);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
ctx.status = 204;
return next();
}
);
}