0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core): add POST /session/forgot-password/reset (#1972)

This commit is contained in:
Darcy Ye 2022-09-22 11:30:45 +08:00 committed by GitHub
parent 7cc2f4d142
commit acdc86c856
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 261 additions and 11 deletions

View file

@ -1,4 +1,4 @@
import { User, userInfoSelectFields } from '@logto/schemas';
import { userInfoSelectFields, User, UsersPasswordEncryptionMethod } from '@logto/schemas';
import pick from 'lodash.pick';
export const mockUser: User = {
@ -21,6 +21,25 @@ export const mockUser: User = {
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
export const mockPasswordEncrypted = 'a1b2c3';
export const mockUserWithPassword: User = {
id: 'id',
username: 'username',
primaryEmail: 'foo@logto.io',
primaryPhone: '111111',
roleNames: ['admin'],
passwordEncrypted: mockPasswordEncrypted,
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
name: null,
avatar: null,
identities: {
connector1: { userId: 'connector1', details: {} },
},
customData: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
};
export const mockUserList: User[] = [
{
id: '1',

View file

@ -1,16 +1,34 @@
import { User } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { mockPasswordEncrypted, mockUserWithPassword } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import { forgotPasswordVerificationTimeout } from './consts';
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password';
const encryptUserPassword = jest.fn(async (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}));
const findUserById = jest.fn(async (): Promise<User> => mockUserWithPassword);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/lib/user', () => ({
...jest.requireActual('@/lib/user'),
encryptUserPassword: async (password: string) => encryptUserPassword(password),
}));
jest.mock('@/queries/user', () => ({
...jest.requireActual('@/queries/user'),
hasUserWithPhone: async (phone: string) => phone === '13000000000',
findUserByPhone: async () => ({ id: 'id' }),
hasUserWithEmail: async (email: string) => email === 'a@a.com',
findUserByEmail: async () => ({ id: 'id' }),
findUserById: async () => findUserById(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
}));
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
@ -24,6 +42,11 @@ jest.mock('@/lib/passcode', () => ({
},
}));
const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted);
jest.mock('hash-wasm', () => ({
argon2Verify: async (password: string) => mockArgon2Verify(password),
}));
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
@ -69,6 +92,8 @@ describe('session -> forgotPasswordRoutes', () => {
describe('POST /session/forgot-password/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1234' });
@ -80,12 +105,14 @@ describe('session -> forgotPasswordRoutes', () => {
expect.objectContaining({
login: { accountId: 'id' },
forgotPassword: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresAt: expect.any(dayjs),
expiresAt: dayjs(fakeTime)
.add(forgotPasswordVerificationTimeout, 'second')
.toISOString(),
},
}),
expect.anything()
);
jest.useRealTimers();
});
it('throw error if phone number does not exist', async () => {
const response = await sessionRequest
@ -118,6 +145,8 @@ describe('session -> forgotPasswordRoutes', () => {
describe('POST /session/forgot-password/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1234' });
@ -129,12 +158,14 @@ describe('session -> forgotPasswordRoutes', () => {
expect.objectContaining({
login: { accountId: 'id' },
forgotPassword: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresAt: expect.any(dayjs),
expiresAt: dayjs(fakeTime)
.add(forgotPasswordVerificationTimeout, 'second')
.toISOString(),
},
}),
expect.anything()
);
jest.useRealTimers();
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
@ -149,4 +180,118 @@ describe('session -> forgotPasswordRoutes', () => {
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/forgot-password/reset', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
passwordEncrypted: 'a1b2c3_user1',
passwordEncryptionMethod: 'Argon2i',
})
);
expect(response.statusCode).toEqual(204);
});
it('should throw when `accountId` is missing', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 404);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when `forgotPassword.expiresAt` is not string', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: 0 },
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 404);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when `expiresAt` is not a valid date string', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: 'invalid date string' },
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 401);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when verification expires', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: dayjs().subtract(1, 'day').toISOString() },
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 401);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when new password is the same as old one', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
},
});
mockArgon2Verify.mockResolvedValueOnce(true);
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 400);
expect(updateUserById).toBeCalledTimes(0);
});
it('should redirect when there was no old password', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'id' },
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
},
});
findUserById.mockResolvedValueOnce({
...mockUserWithPassword,
passwordEncrypted: null,
passwordEncryptionMethod: null,
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
passwordEncrypted: 'a1b2c3_user1',
passwordEncryptionMethod: 'Argon2i',
})
);
expect(response.statusCode).toEqual(204);
});
});
});

View file

@ -1,18 +1,22 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { emailRegEx, passwordRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { argon2Verify } from 'hash-wasm';
import { Provider } from 'oidc-provider';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults } from '@/lib/session';
import { encryptUserPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
findUserByEmail,
findUserById,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
updateUserById,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
@ -22,6 +26,10 @@ import { getRoutePrefix } from './utils';
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
const forgotPasswordVerificationGuard = z.object({
forgotPassword: z.object({ expiresAt: z.string() }),
});
export default function forgotPasswordRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
@ -65,7 +73,7 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
await assignInteractionResults(ctx, provider, {
login: { accountId: id },
forgotPassword: {
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'),
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
},
});
@ -110,11 +118,56 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
await assignInteractionResults(ctx, provider, {
login: { accountId: id },
forgotPassword: {
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'),
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
},
});
return next();
}
);
router.post(
`${forgotPasswordRoute}/reset`,
koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const { password } = ctx.guard.body;
const forgotPasswordVerificationResult = forgotPasswordVerificationGuard.safeParse(result);
assertThat(
result?.login?.accountId && forgotPasswordVerificationResult.success,
new RequestError({ code: 'session.forgot_password_session_not_found', status: 404 })
);
const {
login: { accountId: id },
} = result;
const {
forgotPassword: { expiresAt },
} = forgotPasswordVerificationResult.data;
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.forgot_password_verification_expired', status: 401 })
);
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(id);
assertThat(
!oldPasswordEncrypted ||
(oldPasswordEncrypted && !(await argon2Verify({ password, hash: oldPasswordEncrypted }))),
new RequestError({ code: 'user.same_password', status: 400 })
);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const type = 'ForgotPasswordReset';
ctx.log(type, { userId: id });
await updateUserById(id, { passwordEncrypted, passwordEncryptionMethod });
ctx.status = 204;
return next();
}
);
}

View file

@ -42,6 +42,7 @@ const errors = {
identity_exists: 'The social account has been registered.',
invalid_role_names: 'role names ({{roleNames}}) are not valid',
cannot_delete_self: 'You cannot delete yourself.',
same_password: 'Your new password can not be the same as current password.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
@ -55,6 +56,10 @@ const errors = {
insufficient_info: 'Insufficient sign-in info.',
connector_id_mismatch: 'The connectorId is mismatched with session record.',
connector_session_not_found: 'Connector session not found. Please go back and sign in again.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.',
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.',
unauthorized: 'Please sign in first.',
unsupported_prompt_name: 'Unsupported prompt name.',
},

View file

@ -42,7 +42,8 @@ const errors = {
identity_not_exists: "Le compte social n'a pas encore été enregistré.",
identity_exists: 'Le compte social a été enregistré.',
invalid_role_names: 'les noms de rôles ({{roleNames}}) ne sont pas valides',
cannot_delete_self: 'You cannot delete yourself.',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
@ -60,6 +61,10 @@ const errors = {
connector_id_mismatch: "Le connectorId ne correspond pas à l'enregistrement de la session.",
connector_session_not_found:
"La session du connecteur n'a pas été trouvée. Veuillez revenir en arrière et vous connecter à nouveau.",
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: "Veuillez vous enregistrer d'abord.",
unsupported_prompt_name: "Nom d'invite non supporté.",
},

View file

@ -40,7 +40,8 @@ const errors = {
identity_not_exists: '소셜 계정이 아직 등록되지 않았어요.',
identity_exists: '소셜 계정이 이미 등록되있어요.',
invalid_role_names: '직책 명({{roleNames}})이 유효하지 않아요.',
cannot_delete_self: 'You cannot delete yourself.',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
@ -54,6 +55,10 @@ const errors = {
insufficient_info: '로그인 정보가 충분하지 않아요.',
connector_id_mismatch: '연동 ID가 세션 정보와 일치하지 않아요.',
connector_session_not_found: '연동 세션을 찾을 수 없어요. 다시 로그인해주세요.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: '로그인을 먼저 해주세요.',
unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.',
},

View file

@ -41,6 +41,7 @@ const errors = {
identity_exists: 'A conta social foi registada.',
invalid_role_names: '({{roleNames}}) não são válidos',
cannot_delete_self: 'Não se pode remover a si mesmo.',
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
@ -56,6 +57,10 @@ const errors = {
connector_id_mismatch: 'O connectorId não corresponde ao registado na sessão.',
connector_session_not_found:
'Sessão do conector não encontrada. Por favor, volte e faça login novamente.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Faça login primeiro.',
unsupported_prompt_name: 'Nome de prompt não suportado.',
},

View file

@ -41,7 +41,8 @@ const errors = {
identity_not_exists: 'Sosyal platform hesabı henüz kaydedilmedi.',
identity_exists: 'Sosyal platform hesabı kaydedildi.',
invalid_role_names: '({{roleNames}}) rol adları geçerli değil.',
cannot_delete_self: 'You cannot delete yourself.',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
@ -56,6 +57,10 @@ const errors = {
connector_id_mismatch: 'connectorId, oturum kaydı ile eşleşmiyor.',
connector_session_not_found:
'Bağlayıcı oturum bulunamadı. Lütfen geri dönüp tekrardan giriş yapınız.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Lütfen önce oturum açın.',
unsupported_prompt_name: 'Desteklenmeyen prompt adı.',
},

View file

@ -41,6 +41,7 @@ const errors = {
identity_exists: '该社交帐号已被注册',
invalid_role_names: '角色名称({{roleNames}})无效',
cannot_delete_self: '你无法删除自己',
same_password: '新设置的密码不可与当前密码相同',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}',
@ -54,6 +55,8 @@ const errors = {
insufficient_info: '登录信息缺失,请检查你的输入。',
connector_id_mismatch: '传入的连接器 ID 与 session 中保存的记录不一致',
connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。',
forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。',
forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。',
unauthorized: '请先登录',
unsupported_prompt_name: '不支持的 prompt name',
},

View file

@ -118,6 +118,10 @@ type ForgotPasswordEmailLogPayload = ArbitraryLogPayload & {
userId?: string;
};
type ForgotPasswordResetLogPayload = ArbitraryLogPayload & {
userId?: string;
};
export enum TokenType {
AccessToken = 'AccessToken',
RefreshToken = 'RefreshToken',
@ -157,6 +161,7 @@ export type LogPayloads = {
ForgotPasswordSms: ForgotPasswordSmsLogPayload;
ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload;
ForgotPasswordEmail: ForgotPasswordEmailLogPayload;
ForgotPasswordReset: ForgotPasswordResetLogPayload;
CodeExchangeToken: ExchangeTokenLogPayload;
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
RevokeToken: RevokeTokenLogPayload;