diff --git a/server/api/@types/auth.ts b/server/api/@types/auth.ts index 326d120..283b2d0 100644 --- a/server/api/@types/auth.ts +++ b/server/api/@types/auth.ts @@ -138,6 +138,24 @@ export type ChangePasswordTarget = TargetBody< Record >; +export type ForgotPasswordTarget = TargetBody< + { + ClientId: MaybeId['userPoolClient']; + Username: string; + }, + { CodeDeliveryDetails: CodeDeliveryDetails } +>; + +export type ConfirmForgotPasswordTarget = TargetBody< + { + ClientId: MaybeId['userPoolClient']; + ConfirmationCode: string; + Password: string; + Username: string; + }, + Record +>; + export type AmzTargets = { 'AWSCognitoIdentityProviderService.SignUp': SignUpTarget; 'AWSCognitoIdentityProviderService.ConfirmSignUp': ConfirmSignUpTarget; @@ -151,4 +169,6 @@ export type AmzTargets = { 'AWSCognitoIdentityProviderService.AdminDeleteUser': AdminDeleteUserTarget; 'AWSCognitoIdentityProviderService.AdminInitiateAuth': AdminInitiateAuthTarget; 'AWSCognitoIdentityProviderService.ChangePassword': ChangePasswordTarget; + 'AWSCognitoIdentityProviderService.ForgotPassword': ForgotPasswordTarget; + 'AWSCognitoIdentityProviderService.ConfirmForgotPassword': ConfirmForgotPasswordTarget; }; diff --git a/server/api/controller.ts b/server/api/controller.ts index 27dc4e0..a84f03a 100644 --- a/server/api/controller.ts +++ b/server/api/controller.ts @@ -117,6 +117,22 @@ const targets: { }), useCase: authUseCase.changePassword, }, + 'AWSCognitoIdentityProviderService.ForgotPassword': { + validator: z.object({ + ClientId: brandedId.userPoolClient.maybe, + Username: z.string(), + }), + useCase: authUseCase.forgotPassword, + }, + 'AWSCognitoIdentityProviderService.ConfirmForgotPassword': { + validator: z.object({ + ClientId: brandedId.userPoolClient.maybe, + ConfirmationCode: z.string(), + Password: z.string(), + Username: z.string(), + }), + useCase: authUseCase.confirmForgotPassword, + }, }; const main = (target: T, body: AmzTargets[T]['reqBody']) => { diff --git a/server/domain/user/model/userMethod.ts b/server/domain/user/model/userMethod.ts index 682c40e..161fd99 100644 --- a/server/domain/user/model/userMethod.ts +++ b/server/domain/user/model/userMethod.ts @@ -166,4 +166,31 @@ export const userMethod = { challenge: undefined, }; }, + forgotPassword: (user: UserEntity): UserEntity => { + const confirmationCode = genConfirmationCode(); + + return { ...user, confirmationCode }; + }, + confirmForgotPassword: (params: { + user: UserEntity; + confirmationCode: string; + password: string; + }): UserEntity => { + const { user, confirmationCode } = params; + cognitoAssert( + user.confirmationCode === confirmationCode, + 'Invalid verification code provided, please try again.', + ); + validatePass(params.password); + + return { + ...user, + ...genCredentials({ + poolId: user.userPoolId, + username: user.name, + password: params.password, + }), + confirmationCode: '', + }; + }, }; diff --git a/server/domain/user/useCase/authUseCase.ts b/server/domain/user/useCase/authUseCase.ts index 1aaadd2..e5e1ed4 100644 --- a/server/domain/user/useCase/authUseCase.ts +++ b/server/domain/user/useCase/authUseCase.ts @@ -1,6 +1,9 @@ +/* eslint-disable max-lines */ import type { ChangePasswordTarget, + ConfirmForgotPasswordTarget, ConfirmSignUpTarget, + ForgotPasswordTarget, GetUserTarget, ListUserPoolsTarget, RefreshTokenAuthTarget, @@ -172,6 +175,37 @@ export const authUseCase = { await userCommand.save(tx, userMethod.changePassword({ user, req })); + return {}; + }), + forgotPassword: ( + req: ForgotPasswordTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + const poolClient = await userPoolQuery.findClientById(tx, req.ClientId); + const user = await userQuery.findByName(tx, req.Username); + assert(poolClient.userPoolId === user.userPoolId); + + const forgotUser = userMethod.forgotPassword(user); + await userCommand.save(tx, forgotUser); + await sendConfirmationCode(forgotUser); + + return { CodeDeliveryDetails: genCodeDeliveryDetails(forgotUser) }; + }), + confirmForgotPassword: ( + req: ConfirmForgotPasswordTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + const user = await userQuery.findByName(tx, req.Username); + + await userCommand.save( + tx, + userMethod.confirmForgotPassword({ + user, + confirmationCode: req.ConfirmationCode, + password: req.Password, + }), + ); + return {}; }), }; diff --git a/server/tests/api/forgotPassword.test.ts b/server/tests/api/forgotPassword.test.ts new file mode 100644 index 0000000..1c4e384 --- /dev/null +++ b/server/tests/api/forgotPassword.test.ts @@ -0,0 +1,72 @@ +import assert from 'assert'; +import { InbucketAPIClient } from 'inbucket-js-client'; +import { DEFAULT_USER_POOL_CLIENT_ID } from 'service/envValues'; +import { ulid } from 'ulid'; +import { expect, test } from 'vitest'; +import { deleteUser, noCookieClient, testUserName } from './apiClient'; + +test('ForgotPassword', async () => { + const email = `${ulid()}@example.com`; + + await noCookieClient.post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.SignUp' }, + body: { + Username: testUserName, + Password: 'Test-client-password2', + UserAttributes: [{ Name: 'email', Value: email }], + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + }, + }); + + await noCookieClient.post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.ResendConfirmationCode' }, + body: { ClientId: DEFAULT_USER_POOL_CLIENT_ID, Username: testUserName }, + }); + + assert(process.env.INBUCKET_URL); + + const inbucketClient = new InbucketAPIClient(process.env.INBUCKET_URL); + const inbox = await inbucketClient.mailbox(email); + const message = await inbucketClient.message(email, inbox[0].id); + const res1 = await noCookieClient.post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.ConfirmSignUp' }, + body: { + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + ConfirmationCode: message.body.text.trim().split(' ').at(-1) ?? '', + Username: testUserName, + }, + }); + + expect(res1.status).toBe(200); + + await inbucketClient.deleteMessage(email, inbox[0].id); + await inbucketClient.deleteMessage(email, inbox[1].id); + + const res2 = await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.ForgotPassword' }, + body: { + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + Username: testUserName, + }, + }); + assert('CodeDeliveryDetails' in res2); + + const inbox2 = await inbucketClient.mailbox(email); + const message2 = await inbucketClient.message(email, inbox2[0].id); + + const res3 = await noCookieClient.post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.ConfirmForgotPassword' }, + body: { + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + ConfirmationCode: message2.body.text.trim().split(' ').at(-1) ?? '', + Password: 'Test-client-password2', + Username: testUserName, + }, + }); + + await inbucketClient.deleteMessage(email, inbox2[0].id); + + expect(res3.status).toBe(200); + + await deleteUser(); +});