From c4fa2d7ce1095612b8bf131206d46cd94d89e47d Mon Sep 17 00:00:00 2001 From: caru-ini Date: Tue, 2 Jul 2024 01:33:55 +0900 Subject: [PATCH 1/4] feat: add types --- server/api/@types/auth.ts | 9 +++++++++ server/api/controller.ts | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/server/api/@types/auth.ts b/server/api/@types/auth.ts index b720171..33dfb20 100644 --- a/server/api/@types/auth.ts +++ b/server/api/@types/auth.ts @@ -117,6 +117,14 @@ export type ResendConfirmationCodeTarget = TargetBody< export type ListUserPoolsTarget = TargetBody; export type AdminDeleteUserTarget = TargetBody>; +export type ChangePasswordTarget = TargetBody< + { + AccessToken: string; + PreviousPassword: string; + ProposedPassword: string; + }, + Record +>; export type AmzTargets = { 'AWSCognitoIdentityProviderService.SignUp': SignUpTarget; @@ -128,4 +136,5 @@ export type AmzTargets = { 'AWSCognitoIdentityProviderService.ResendConfirmationCode': ResendConfirmationCodeTarget; 'AWSCognitoIdentityProviderService.ListUserPools': ListUserPoolsTarget; 'AWSCognitoIdentityProviderService.AdminDeleteUser': AdminDeleteUserTarget; + 'AWSCognitoIdentityProviderService.ChangePassword': ChangePasswordTarget; }; diff --git a/server/api/controller.ts b/server/api/controller.ts index 7af9685..356ad02 100644 --- a/server/api/controller.ts +++ b/server/api/controller.ts @@ -82,6 +82,14 @@ const targets: { validator: z.object({ UserPoolId: brandedId.userPool.maybe, Username: z.string() }), useCase: adminUseCase.deleteUser, }, + 'AWSCognitoIdentityProviderService.ChangePassword': { + validator: z.object({ + AccessToken: z.string(), + PreviousPassword: z.string(), + ProposedPassword: z.string(), + }), + useCase: authUseCase.changePassword, + }, }; const main = (target: T, body: AmzTargets[T]['reqBody']) => { From 7b7f77764b91278ee17e907d5e16ff8e7f5875f5 Mon Sep 17 00:00:00 2001 From: caru-ini Date: Tue, 2 Jul 2024 01:34:29 +0900 Subject: [PATCH 2/4] feat: allow updating salt and verifier --- server/domain/user/repository/userCommand.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/domain/user/repository/userCommand.ts b/server/domain/user/repository/userCommand.ts index 4ce09bf..cb6cfdf 100644 --- a/server/domain/user/repository/userCommand.ts +++ b/server/domain/user/repository/userCommand.ts @@ -10,6 +10,8 @@ export const userCommand = { email: user.email, name: user.name, verified: user.verified, + salt: user.salt, + verifier: user.verifier, refreshToken: user.refreshToken, confirmationCode: user.confirmationCode, secretBlock: user.challenge?.secretBlock, From 03e54404c842c6cb7e25f0e45c35ca2911164f97 Mon Sep 17 00:00:00 2001 From: caru-ini Date: Tue, 2 Jul 2024 01:35:30 +0900 Subject: [PATCH 3/4] feat: add changePassword useCase --- server/domain/user/model/userMethod.ts | 7 +++++++ server/domain/user/useCase/authUseCase.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/server/domain/user/model/userMethod.ts b/server/domain/user/model/userMethod.ts index e6dcf75..945fd10 100644 --- a/server/domain/user/model/userMethod.ts +++ b/server/domain/user/model/userMethod.ts @@ -143,4 +143,11 @@ export const userMethod = { return brandedId.deletableUser.entity.parse(params.user.id); }, + changePassword: (params: { user: UserEntity; salt: string; verifier: string }): UserEntity => ({ + ...params.user, + verifier: params.verifier, + salt: params.salt, + refreshToken: ulid(), + challenge: undefined, + }), }; diff --git a/server/domain/user/useCase/authUseCase.ts b/server/domain/user/useCase/authUseCase.ts index 7b9e213..1dee95e 100644 --- a/server/domain/user/useCase/authUseCase.ts +++ b/server/domain/user/useCase/authUseCase.ts @@ -1,4 +1,5 @@ import type { + ChangePasswordTarget, ConfirmSignUpTarget, GetUserTarget, ListUserPoolsTarget, @@ -170,4 +171,19 @@ export const authUseCase = { const pools = await userPoolQuery.listAll(tx, req.MaxResults); return { UserPools: pools.map((p) => ({ Id: p.id })) }; }), + changePassword: ( + req: ChangePasswordTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + const decoded = jwtDecode(req.AccessToken); + const user = await userQuery.findById(tx, decoded.sub); + const { salt, verifier } = genCredentials({ + poolId: user.userPoolId, + username: user.name, + password: req.ProposedPassword, + }); + + await userCommand.save(tx, userMethod.changePassword({ user, salt, verifier })); + return {}; + }), }; From 46a50aeb438f3d57432d5b8246da80ea99371c4b Mon Sep 17 00:00:00 2001 From: caru-ini Date: Tue, 2 Jul 2024 01:35:41 +0900 Subject: [PATCH 4/4] refactor: implement test --- server/tests/api/changePassword.test.ts | 110 ++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 server/tests/api/changePassword.test.ts diff --git a/server/tests/api/changePassword.test.ts b/server/tests/api/changePassword.test.ts new file mode 100644 index 0000000..00ac54f --- /dev/null +++ b/server/tests/api/changePassword.test.ts @@ -0,0 +1,110 @@ +import assert from 'assert'; +import crypto from 'crypto'; +import { calcClientSignature } from 'domain/user/service/srp/calcClientSignature'; +import { N, g } from 'domain/user/service/srp/constants'; +import { fromBuffer, toBuffer } from 'domain/user/service/srp/util'; +import { DEFAULT_USER_POOL_CLIENT_ID } from 'service/envValues'; +import { test } from 'vitest'; +import { + createUserClient, + deleteUser, + noCookieClient, + testPassword, + testUserName, +} from './apiClient'; + +test('changePassword', async () => { + await createUserClient(); + const a = crypto.randomBytes(32); + const A = toBuffer(g.modPow(fromBuffer(a), N)); + const res1 = await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.InitiateAuth' }, + body: { + AuthFlow: 'USER_SRP_AUTH', + AuthParameters: { USERNAME: testUserName, SRP_A: A.toString('hex') }, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + }, + }); + + assert('ChallengeParameters' in res1); + const secretBlock1 = res1.ChallengeParameters.SECRET_BLOCK; + const signature1 = calcClientSignature({ + secretBlock: secretBlock1, + username: testUserName, + password: testPassword, + salt: res1.ChallengeParameters.SALT, + timestamp: 'Thu Jan 01 00:00:00 UTC 1970', + A: A.toString('hex'), + a: fromBuffer(a), + B: res1.ChallengeParameters.SRP_B, + }); + + const res2 = await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.RespondToAuthChallenge' }, + body: { + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeResponses: { + PASSWORD_CLAIM_SECRET_BLOCK: secretBlock1, + PASSWORD_CLAIM_SIGNATURE: signature1, + TIMESTAMP: 'Thu Jan 01 00:00:00 UTC 1970', + USERNAME: testUserName, + }, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + }, + }); + + assert('AuthenticationResult' in res2); + assert('RefreshToken' in res2.AuthenticationResult); + + const newPassword = 'Test-client-password2'; + + await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.ChangePassword' }, + body: { + AccessToken: res2.AuthenticationResult.AccessToken, + PreviousPassword: testPassword, + ProposedPassword: newPassword, + }, + }); + + const res3 = await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.InitiateAuth' }, + body: { + AuthFlow: 'USER_SRP_AUTH', + AuthParameters: { USERNAME: testUserName, SRP_A: A.toString('hex') }, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + }, + }); + + assert('ChallengeParameters' in res3); + const secretBlock2 = res3.ChallengeParameters.SECRET_BLOCK; + const signature2 = calcClientSignature({ + secretBlock: secretBlock2, + username: testUserName, + password: newPassword, + salt: res3.ChallengeParameters.SALT, + timestamp: 'Thu Jan 01 00:00:00 UTC 1970', + A: A.toString('hex'), + a: fromBuffer(a), + B: res3.ChallengeParameters.SRP_B, + }); + + const res4 = await noCookieClient.$post({ + headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.RespondToAuthChallenge' }, + body: { + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeResponses: { + PASSWORD_CLAIM_SECRET_BLOCK: secretBlock2, + PASSWORD_CLAIM_SIGNATURE: signature2, + TIMESTAMP: 'Thu Jan 01 00:00:00 UTC 1970', + USERNAME: testUserName, + }, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + }, + }); + + assert('AuthenticationResult' in res4); + assert('RefreshToken' in res4.AuthenticationResult); + + await deleteUser(); +});