diff --git a/server/api/public/backdoor/controller.ts b/server/api/public/backdoor/controller.ts index bebe6cb..d121195 100644 --- a/server/api/public/backdoor/controller.ts +++ b/server/api/public/backdoor/controller.ts @@ -1,6 +1,7 @@ import type { UserEntity } from 'api/@types/user'; import { userMethod } from 'domain/user/model/userMethod'; import { userCommand } from 'domain/user/repository/userCommand'; +import { userQuery } from 'domain/user/repository/userQuery'; import { genCredentials } from 'domain/user/service/genCredentials'; import { genTokens } from 'domain/user/service/genTokens'; import { userPoolQuery } from 'domain/userPool/repository/userPoolQuery'; @@ -18,9 +19,11 @@ export default defineController(() => ({ username: body.username, password: body.password, }); + const idCount = await userQuery.countId(prismaClient, body.username); const user: UserEntity = { - ...userMethod.createUser({ + ...userMethod.createUser(idCount, { name: body.username, + password: body.password, email: body.email, salt, verifier, diff --git a/server/domain/user/model/userMethod.ts b/server/domain/user/model/userMethod.ts index bf19429..8f83951 100644 --- a/server/domain/user/model/userMethod.ts +++ b/server/domain/user/model/userMethod.ts @@ -14,29 +14,67 @@ import { calculateSignature } from 'domain/user/service/srp/calcSignature'; import { calculateSrpB } from 'domain/user/service/srp/calcSrpB'; import { getPoolName } from 'domain/user/service/srp/util'; import { brandedId } from 'service/brandedId'; +import { cognitoAssert } from 'service/cognitoAssert'; import { ulid } from 'ulid'; +import { z } from 'zod'; export const userMethod = { - createUser: (val: { - name: string; - email: string; - salt: string; - verifier: string; - userPoolId: EntityId['userPool']; - }): UserEntity => ({ - id: brandedId.user.entity.parse(val.name), - email: val.email, - name: val.name, - refreshToken: ulid(), - userPoolId: val.userPoolId, - verified: false, - confirmationCode: genConfirmationCode(), - salt: val.salt, - verifier: val.verifier, - createdTime: Date.now(), - }), + createUser: ( + idCount: number, + val: { + name: string; + password: string; + email: string; + salt: string; + verifier: string; + userPoolId: EntityId['userPool']; + }, + ): UserEntity => { + cognitoAssert(idCount === 0, 'User already exists'); + cognitoAssert( + /^[a-z][a-z\d_-]/.test(val.name), + "1 validation error detected: Value at 'username' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]+", + ); + cognitoAssert( + val.password.length >= 8, + 'Password did not conform with policy: Password not long enough', + ); + cognitoAssert( + /[a-z]/.test(val.password), + 'Password did not conform with policy: Password must have lowercase characters', + ); + cognitoAssert( + /[A-Z]/.test(val.password), + 'Password did not conform with policy: Password must have uppercase characters', + ); + cognitoAssert( + /[0-9]/.test(val.password), + 'Password did not conform with policy: Password must have numeric characters', + ); + cognitoAssert( + /[!-/:-@[-`{-~]/.test(val.password), + 'Password did not conform with policy: Password must have symbol characters', + ); + cognitoAssert(z.string().email().parse(val.email), 'Invalid email address format.'); + + return { + id: brandedId.user.entity.parse(val.name), + email: val.email, + name: val.name, + refreshToken: ulid(), + userPoolId: val.userPoolId, + verified: false, + confirmationCode: genConfirmationCode(), + salt: val.salt, + verifier: val.verifier, + createdTime: Date.now(), + }; + }, verifyUser: (user: UserEntity, confirmationCode: string): UserEntity => { - assert(user.confirmationCode === confirmationCode); + cognitoAssert( + user.confirmationCode === confirmationCode, + 'Invalid verification code provided, please try again.', + ); return { ...user, verified: true }; }, @@ -91,7 +129,7 @@ export const userMethod = { scramblingParameter, key: sessionKey, }); - assert(signature === params.clientSignature); + cognitoAssert(signature === params.clientSignature, 'Incorrect username or password.'); return genTokens({ privateKey: params.pool.privateKey, diff --git a/server/domain/user/repository/userQuery.ts b/server/domain/user/repository/userQuery.ts index a3faa1e..75c7bee 100644 --- a/server/domain/user/repository/userQuery.ts +++ b/server/domain/user/repository/userQuery.ts @@ -4,6 +4,8 @@ import type { UserEntity } from 'api/@types/user'; import { toUserEntity } from './toUserEntity'; export const userQuery = { + countId: (tx: Prisma.TransactionClient, id: string): Promise => + tx.user.count({ where: { id } }), findById: (tx: Prisma.TransactionClient, id: EntityId['user']): Promise => tx.user.findUniqueOrThrow({ where: { id } }).then(toUserEntity), findByName: (tx: Prisma.TransactionClient, name: string): Promise => diff --git a/server/domain/user/useCase/authUseCase.ts b/server/domain/user/useCase/authUseCase.ts index 515ea02..0646c66 100644 --- a/server/domain/user/useCase/authUseCase.ts +++ b/server/domain/user/useCase/authUseCase.ts @@ -15,6 +15,7 @@ import { genCredentials } from 'domain/user/service/genCredentials'; import { genTokens } from 'domain/user/service/genTokens'; import { userPoolQuery } from 'domain/userPool/repository/userPoolQuery'; import { jwtDecode } from 'jwt-decode'; +import { cognitoAssert } from 'service/cognitoAssert'; import { transaction } from 'service/prismaClient'; import { sendMail } from 'service/sendMail'; import type { AccessTokenJwt } from 'service/types'; @@ -28,8 +29,10 @@ export const authUseCase = { username: req.Username, password: req.Password, }); - const user = userMethod.createUser({ + const idCount = await userQuery.countId(tx, req.Username); + const user = userMethod.createUser(idCount, { name: req.Username, + password: req.Password, email: req.UserAttributes[0].Value, salt, verifier, @@ -63,8 +66,8 @@ export const authUseCase = { }), userSrpAuth: (req: UserSrpAuthTarget['reqBody']): Promise => transaction(async (tx) => { - const user = await userQuery.findByName(tx, req.AuthParameters.USERNAME); - assert(user.verified); + const user = await userQuery.findByName(tx, req.AuthParameters.USERNAME).catch(() => null); + cognitoAssert(user, 'Incorrect username or password.'); const { userWithChallenge, ChallengeParameters } = userMethod.createChallenge( user, @@ -115,6 +118,8 @@ export const authUseCase = { assert(pool.id === poolClient.userPoolId); assert(user.challenge?.secretBlock === req.ChallengeResponses.PASSWORD_CLAIM_SECRET_BLOCK); + cognitoAssert(user.verified, 'User is not confirmed.'); + const tokens = userMethod.srpAuth({ user, timestamp: req.ChallengeResponses.TIMESTAMP, diff --git a/server/service/cognitoAssert.ts b/server/service/cognitoAssert.ts new file mode 100644 index 0000000..86a1bc9 --- /dev/null +++ b/server/service/cognitoAssert.ts @@ -0,0 +1,26 @@ +import assert from 'assert'; + +export const COGNITO_ERRORS = { + 'Incorrect username or password.': 'NotAuthorizedException', + 'Invalid email address format.': 'InvalidParameterException', + "1 validation error detected: Value at 'username' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]+": + 'InvalidParameterException', + 'Password did not conform with policy: Password must have lowercase characters': + 'InvalidPasswordException', + 'Password did not conform with policy: Password must have uppercase characters': + 'InvalidPasswordException', + 'Password did not conform with policy: Password must have numeric characters': + 'InvalidPasswordException', + 'Password did not conform with policy: Password must have symbol characters': + 'InvalidPasswordException', + 'Password did not conform with policy: Password not long enough': 'InvalidPasswordException', + 'User already exists': 'UsernameExistsException', + 'Invalid verification code provided, please try again.': 'CodeMismatchException', + 'User is not confirmed.': 'UserNotConfirmedException', +}; + +export class CognitoError extends Error {} + +export function cognitoAssert(val: unknown, msg: keyof typeof COGNITO_ERRORS): asserts val { + assert(val, new CognitoError(msg)); +} diff --git a/server/service/returnStatus.ts b/server/service/returnStatus.ts index 6fdf7b7..b3c9135 100644 --- a/server/service/returnStatus.ts +++ b/server/service/returnStatus.ts @@ -1,3 +1,5 @@ +import { COGNITO_ERRORS, CognitoError } from './cognitoAssert'; + export const returnSuccess = (val: T): { status: 200; body: T } => ({ status: 200, body: val }); const logErr = (e: unknown): void => { @@ -12,7 +14,25 @@ export const returnGetError = (e: unknown): { status: 404 } => { return { status: 404 }; }; -export const returnPostError = (e: unknown): { status: 403; body: Record } => { +export const returnPostError = ( + e: unknown, +): + | { + status: 400; + headers: Record<'X-Amzn-Errormessage' | 'X-Amzn-Errortype', string>; + body: { message: string; __type: string }; + } + | { status: 403; body: Record } => { + if (e instanceof CognitoError) { + const type = COGNITO_ERRORS[e.message as keyof typeof COGNITO_ERRORS]; + + return { + status: 400, + headers: { 'X-Amzn-Errormessage': e.message, 'X-Amzn-Errortype': type }, + body: { message: e.message, __type: type }, + }; + } + logErr(e); return { status: 403, body: {} }; diff --git a/server/tests/api/apiClient.ts b/server/tests/api/apiClient.ts index a5e04aa..118a3d3 100644 --- a/server/tests/api/apiClient.ts +++ b/server/tests/api/apiClient.ts @@ -16,7 +16,7 @@ export const createUserClient = async (): Promise => { body: { username: 'test-client', email: `${ulid()}@example.com`, - password: 'test-client-password', + password: 'Test-client-password1', }, }); const agent = axios.create({ diff --git a/server/tests/api/signIn.test.ts b/server/tests/api/signIn.test.ts index 8718574..fe1d815 100644 --- a/server/tests/api/signIn.test.ts +++ b/server/tests/api/signIn.test.ts @@ -25,7 +25,7 @@ test('signIn', async () => { const signature = calcClientSignature({ secretBlock, username: 'test-client', - password: 'test-client-password', + password: 'Test-client-password1', salt: res1.ChallengeParameters.SALT, timestamp: 'Thu Jan 01 00:00:00 UTC 1970', A: A.toString('hex'),