diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index e517b6068..6dc66d73d 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -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 () => { diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index 2e979d101..c724f02f3 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -46,9 +46,8 @@ export default function forgotPasswordRoutes( 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); diff --git a/packages/core/src/routes/session/profile.test.ts b/packages/core/src/routes/session/profile.test.ts index 01296baeb..0e4ab678d 100644 --- a/packages/core/src/routes/session/profile.test.ts +++ b/packages/core/src/routes/session/profile.test.ts @@ -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> = 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(); + }); + }); }); diff --git a/packages/core/src/routes/session/profile.ts b/packages/core/src/routes/session/profile.ts index 3467a2c77..50f3aef30 100644 --- a/packages/core/src/routes/session/profile.ts +++ b/packages/core/src/routes/session/profile.ts @@ -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(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(); + } + ); }