From feb5b4fb0de5c6b1862128d90d222d5b5abf5c49 Mon Sep 17 00:00:00 2001 From: Jeff Man Date: Wed, 22 Jul 2026 23:52:09 +0100 Subject: [PATCH 1/3] feat(auth): add TOTP-based two-factor authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional 2FA on top of the existing JWT/password login, using otplib (RFC 6238 TOTP) and qrcode for the setup QR image. - New totpSecret/totpEnabled columns on User - GenerateTotpSecretUseCase / ConfirmTotpSetupUseCase / DisableTotpUseCase / GetTotpStatusUseCase back a setup-QR-confirm-disable flow in account settings (beginTotpSetup, confirmTotpSetup, disableTotp, totpEnabled) - login now returns { success, totpRequired } instead of a plain boolean — when totpRequired is true, no auth cookies are set and the client must call the new loginWithTotp(email, password, code) mutation to complete sign-in (LoginWithTotpUseCase) - New login.tsx two-step UI (credentials, then a 6-digit code) and a Two-factor authentication section in account.tsx (QR code + manual key, confirm, and disable-with-password) - Verified end-to-end against a live server: register → beginTotpSetup → confirmTotpSetup with a real generated code → login now requires TOTP → loginWithTotp completes sign-in --- apps/api/package.json | 3 + .../20260722223632_add_totp/migration.sql | 2 + apps/api/prisma/schema.prisma | 2 + .../auth/LoginWithTotpUseCase.test.ts | 76 ++++++ .../user/ConfirmTotpSetupUseCase.test.ts | 71 +++++ .../user/DisableTotpUseCase.test.ts | 51 ++++ .../user/GenerateTotpSecretUseCase.test.ts | 44 +++ .../user/GetTotpStatusUseCase.test.ts | 37 +++ .../api/src/__tests__/helpers/createTestDb.ts | 2 + apps/api/src/__tests__/helpers/mocks.ts | 2 + .../repositories/PrismaUserRepository.test.ts | 23 ++ .../resolvers/AuthResolver.test.ts | 99 +++++-- .../resolvers/UserResolver.test.ts | 84 ++++++ apps/api/src/constants.ts | 7 + apps/api/src/domain/user/User.ts | 2 + apps/api/src/http/container.ts | 17 ++ apps/api/src/http/schema/index.ts | 2 + .../http/schema/mutations/authMutations.ts | 22 +- .../http/schema/mutations/userMutations.ts | 55 ++++ .../src/http/schema/queries/userQueries.ts | 11 + .../src/http/schema/types/LoginResultType.ts | 14 + .../src/http/schema/types/TotpSetupType.ts | 11 + apps/api/src/infrastructure/auth/totp.ts | 10 + .../db/repositories/PrismaUserRepository.ts | 14 +- .../resolvers/AuthResolver.ts | 17 +- .../resolvers/UserResolver.ts | 27 ++ .../use-cases/auth/ILoginWithTotpUseCase.ts | 11 + .../use-cases/auth/LoginWithTotpUseCase.ts | 44 +++ .../src/use-cases/ports/IUserRepository.ts | 10 +- .../use-cases/user/ConfirmTotpSetupUseCase.ts | 42 +++ .../src/use-cases/user/DisableTotpUseCase.ts | 29 ++ .../user/GenerateTotpSecretUseCase.ts | 35 +++ .../use-cases/user/GetTotpStatusUseCase.ts | 18 ++ .../user/IConfirmTotpSetupUseCase.ts | 8 + .../src/use-cases/user/IDisableTotpUseCase.ts | 8 + .../user/IGenerateTotpSecretUseCase.ts | 9 + .../use-cases/user/IGetTotpStatusUseCase.ts | 3 + .../__tests__/components/AccountPage.test.tsx | 103 ++++++- .../__tests__/components/LoginPage.test.tsx | 121 ++++++--- .../web/src/routes/_authenticated/account.tsx | 193 ++++++++++++++ apps/web/src/routes/login.tsx | 104 +++++++- pnpm-lock.yaml | 251 ++++++++++++++++-- 42 files changed, 1594 insertions(+), 100 deletions(-) create mode 100644 apps/api/prisma/migrations/20260722223632_add_totp/migration.sql create mode 100644 apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts create mode 100644 apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts create mode 100644 apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts create mode 100644 apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts create mode 100644 apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts create mode 100644 apps/api/src/http/schema/types/LoginResultType.ts create mode 100644 apps/api/src/http/schema/types/TotpSetupType.ts create mode 100644 apps/api/src/infrastructure/auth/totp.ts create mode 100644 apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts create mode 100644 apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts create mode 100644 apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts create mode 100644 apps/api/src/use-cases/user/DisableTotpUseCase.ts create mode 100644 apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts create mode 100644 apps/api/src/use-cases/user/GetTotpStatusUseCase.ts create mode 100644 apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts create mode 100644 apps/api/src/use-cases/user/IDisableTotpUseCase.ts create mode 100644 apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts create mode 100644 apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts diff --git a/apps/api/package.json b/apps/api/package.json index be3abdca..59ff0bcc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -50,11 +50,14 @@ "graphql": "^16.10.0", "mercurius": "^16.0.0", "nanoid": "^5.0.9", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "zod": "^3.23.8" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.10", + "@types/qrcode": "^1.5.6", "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", diff --git a/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql b/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql new file mode 100644 index 00000000..4ac3744e --- /dev/null +++ b/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT; +ALTER TABLE "User" ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e6ee4ce4..ddc1d2fa 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -11,6 +11,8 @@ model User { id String @id email String @unique passwordHash String + totpSecret String? + totpEnabled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt applications JobApplication[] diff --git a/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts new file mode 100644 index 00000000..1d90f222 --- /dev/null +++ b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { LoginWithTotpUseCase } from '@/use-cases/auth/LoginWithTotpUseCase.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +describe('LoginWithTotpUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const input = { email: 'test@example.com', password: 'password123', code: '123456' }; + + it('throws UNAUTHORIZED when the user does not exist', async () => { + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); + + const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws UNAUTHORIZED when 2FA is not enabled for the user', async () => { + const user = makeUser({ totpEnabled: false, totpSecret: null }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws UNAUTHORIZED for an invalid code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ totpEnabled: true, totpSecret: secret }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ userRepository }) + .execute({ ...input, code: '000000' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('returns the user for valid credentials and a valid code', async () => { + const secret = createTotp().generateSecret(); + const validCode = await createTotp({ secret }).generate(); + const user = makeUser({ totpEnabled: true, totpSecret: secret }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const result = await new LoginWithTotpUseCase({ userRepository }).execute({ + ...input, + code: validCode, + }); + + expect(result).toEqual(user); + }); +}); diff --git a/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts new file mode 100644 index 00000000..015bcd14 --- /dev/null +++ b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConfirmTotpSetupUseCase } from '@/use-cases/user/ConfirmTotpSetupUseCase.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +describe('ConfirmTotpSetupUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new ConfirmTotpSetupUseCase({ userRepository }) + .execute({ userId: 'missing', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws CONFLICT when 2FA is already enabled', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const err = await new ConfirmTotpSetupUseCase({ userRepository }) + .execute({ userId: 'user-1', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + }); + + it('throws CONFLICT when no setup is in progress', async () => { + const user = makeUser({ totpEnabled: false, totpSecret: null }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const err = await new ConfirmTotpSetupUseCase({ userRepository }) + .execute({ userId: 'user-1', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + }); + + it('throws UNAUTHORIZED for an invalid code', async () => { + const user = makeUser({ + totpEnabled: false, + totpSecret: createTotp().generateSecret(), + }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const err = await new ConfirmTotpSetupUseCase({ userRepository }) + .execute({ userId: 'user-1', code: '000000' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('enables 2FA when given a valid code', async () => { + const secret = createTotp().generateSecret(); + const validCode = await createTotp({ secret }).generate(); + const user = makeUser({ id: 'user-1', totpEnabled: false, totpSecret: secret }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + await new ConfirmTotpSetupUseCase({ userRepository }).execute({ + userId: 'user-1', + code: validCode, + }); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpEnabled: true }); + }); +}); diff --git a/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts new file mode 100644 index 00000000..89ef4e57 --- /dev/null +++ b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { DisableTotpUseCase } from '@/use-cases/user/DisableTotpUseCase.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +describe('DisableTotpUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const input = { userId: 'user-1', password: 'secret123' }; + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new DisableTotpUseCase({ userRepository }).execute(input).catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new DisableTotpUseCase({ userRepository }).execute(input).catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('clears totpSecret and disables totpEnabled on correct password', async () => { + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + await new DisableTotpUseCase({ userRepository }).execute(input); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + totpEnabled: false, + totpSecret: null, + }); + }); +}); diff --git a/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts new file mode 100644 index 00000000..1dc58085 --- /dev/null +++ b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GenerateTotpSecretUseCase } from '@/use-cases/user/GenerateTotpSecretUseCase.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +describe('GenerateTotpSecretUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute('missing') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws CONFLICT when 2FA is already enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute('user-1') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('generates a secret, persists it (not yet enabled), and returns setup data', async () => { + const user = makeUser({ id: 'user-1', email: 'test@example.com', totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GenerateTotpSecretUseCase({ userRepository }).execute('user-1'); + + expect(result.secret).toMatch(/^[A-Z2-7]+$/); // base32 + expect(result.otpauthUrl).toMatch(/^otpauth:\/\/totp\//); + expect(result.otpauthUrl).toContain(encodeURIComponent(user.email)); + expect(result.qrCodeDataUrl).toMatch(/^data:image\/png;base64,/); + expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpSecret: result.secret }); + }); +}); diff --git a/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts b/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts new file mode 100644 index 00000000..715d649a --- /dev/null +++ b/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetTotpStatusUseCase } from '@/use-cases/user/GetTotpStatusUseCase.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +describe('GetTotpStatusUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new GetTotpStatusUseCase({ userRepository }) + .execute('missing') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('returns true when 2FA is enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GetTotpStatusUseCase({ userRepository }).execute('user-1'); + + expect(result).toBe(true); + }); + + it('returns false when 2FA is disabled', async () => { + const user = makeUser({ totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GetTotpStatusUseCase({ userRepository }).execute('user-1'); + + expect(result).toBe(false); + }); +}); diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index 3c6bb9b8..71120656 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -9,6 +9,8 @@ const SCHEMA_STATEMENTS = [ "id" TEXT PRIMARY KEY, "email" TEXT NOT NULL UNIQUE, "passwordHash" TEXT NOT NULL, + "totpSecret" TEXT, + "totpEnabled" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index 608f2647..f7a644bf 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -123,6 +123,8 @@ export const makeUser = (overrides?: Partial): User => ({ id: 'user-1', email: 'test@example.com', passwordHash: 'hashed-pw', + totpSecret: null, + totpEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts index 2377c1ca..a3b171f7 100644 --- a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts @@ -27,6 +27,12 @@ describe('PrismaUserRepository', () => { expect(user.createdAt).toBeInstanceOf(Date); expect(user.updatedAt).toBeInstanceOf(Date); }); + + it('defaults totpSecret to null and totpEnabled to false', async () => { + const user = await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + expect(user.totpSecret).toBeNull(); + expect(user.totpEnabled).toBe(false); + }); }); describe('findById', () => { @@ -100,6 +106,23 @@ describe('PrismaUserRepository', () => { expect(updated.createdAt).toBeInstanceOf(Date); expect(updated.updatedAt).toBeInstanceOf(Date); }); + + it('sets totpSecret and totpEnabled', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + const updated = await repo.update('u1', { totpSecret: 'ABCD1234', totpEnabled: true }); + + expect(updated.totpSecret).toBe('ABCD1234'); + expect(updated.totpEnabled).toBe(true); + }); + + it('clears totpSecret when given null', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + await repo.update('u1', { totpSecret: 'ABCD1234', totpEnabled: true }); + const updated = await repo.update('u1', { totpSecret: null, totpEnabled: false }); + + expect(updated.totpSecret).toBeNull(); + expect(updated.totpEnabled).toBe(false); + }); }); describe('delete', () => { diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts index 111da1a5..9cf89332 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts @@ -3,6 +3,7 @@ import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; import { makeUser } from '@/__tests__/helpers/mocks.js'; import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { ILoginWithTotpUseCase } from '@/use-cases/auth/ILoginWithTotpUseCase.js'; import type { ITokenService } from '@/use-cases/ports/ITokenService.js'; const makeRegisterUseCase = (overrides?: Partial): IRegisterUseCase => ({ @@ -15,12 +16,26 @@ const makeLoginUseCase = (overrides?: Partial): ILoginUseCase => ...overrides, }); +const makeLoginWithTotpUseCase = ( + overrides?: Partial, +): ILoginWithTotpUseCase => ({ + execute: vi.fn(), + ...overrides, +}); + const makeTokenService = (overrides?: Partial): ITokenService => ({ sign: vi.fn().mockReturnValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }), verifyRefresh: vi.fn(), ...overrides, }); +const baseDeps = () => ({ + registerUseCase: makeRegisterUseCase(), + loginUseCase: makeLoginUseCase(), + loginWithTotpUseCase: makeLoginWithTotpUseCase(), + tokenService: makeTokenService(), +}); + describe('AuthResolver', () => { beforeEach(() => { vi.clearAllMocks(); @@ -33,11 +48,7 @@ describe('AuthResolver', () => { }); const tokenService = makeTokenService(); - const resolver = new AuthResolver({ - registerUseCase, - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), registerUseCase, tokenService }); const result = await resolver.register('test@example.com', 'password123'); @@ -51,18 +62,12 @@ describe('AuthResolver', () => { }); describe('login', () => { - it('calls loginUseCase and returns a token pair', async () => { - const user = makeUser(); - const loginUseCase = makeLoginUseCase({ - execute: vi.fn().mockResolvedValue(user), - }); + it('signs tokens and returns totpRequired: false for a user without 2FA', async () => { + const user = makeUser({ totpEnabled: false }); + const loginUseCase = makeLoginUseCase({ execute: vi.fn().mockResolvedValue(user) }); const tokenService = makeTokenService(); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase, - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), loginUseCase, tokenService }); const result = await resolver.login('test@example.com', 'password123'); @@ -71,8 +76,60 @@ describe('AuthResolver', () => { password: 'password123', }); expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email); + expect(result).toEqual({ + totpRequired: false, + tokens: { accessToken: 'access-token', refreshToken: 'refresh-token' }, + }); + }); + + it('does not sign tokens and returns totpRequired: true for a user with 2FA enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const loginUseCase = makeLoginUseCase({ execute: vi.fn().mockResolvedValue(user) }); + const tokenService = makeTokenService(); + + const resolver = new AuthResolver({ ...baseDeps(), loginUseCase, tokenService }); + + const result = await resolver.login('test@example.com', 'password123'); + + expect(tokenService.sign).not.toHaveBeenCalled(); + expect(result).toEqual({ totpRequired: true, tokens: null }); + }); + }); + + describe('loginWithTotp', () => { + it('delegates to loginWithTotpUseCase and signs tokens', async () => { + const user = makeUser({ totpEnabled: true }); + const loginWithTotpUseCase = makeLoginWithTotpUseCase({ + execute: vi.fn().mockResolvedValue(user), + }); + const tokenService = makeTokenService(); + + const resolver = new AuthResolver({ ...baseDeps(), loginWithTotpUseCase, tokenService }); + + const result = await resolver.loginWithTotp('test@example.com', 'password123', '123456'); + + expect(loginWithTotpUseCase.execute).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + code: '123456', + }); + expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' }); }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid verification code'), { + code: 'UNAUTHORIZED', + }); + const loginWithTotpUseCase = makeLoginWithTotpUseCase({ + execute: vi.fn().mockRejectedValue(err), + }); + const resolver = new AuthResolver({ ...baseDeps(), loginWithTotpUseCase }); + + await expect( + resolver.loginWithTotp('test@example.com', 'password123', 'bad-code'), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); }); describe('refreshToken', () => { @@ -81,11 +138,7 @@ describe('AuthResolver', () => { verifyRefresh: vi.fn().mockReturnValue({ sub: 'user-1', email: 'test@example.com' }), }); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), tokenService }); const result = resolver.refreshToken('valid-refresh-token'); @@ -101,11 +154,7 @@ describe('AuthResolver', () => { }), }); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), tokenService }); const err = (() => { try { diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts index c1427611..890320c2 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts @@ -4,6 +4,10 @@ import type { IUpdateEmailUseCase } from '@/use-cases/user/IUpdateEmailUseCase.j import type { IUpdatePasswordUseCase } from '@/use-cases/user/IUpdatePasswordUseCase.js'; import type { IDeleteAccountUseCase } from '@/use-cases/user/IDeleteAccountUseCase.js'; import type { IExportUserDataUseCase } from '@/use-cases/user/IExportUserDataUseCase.js'; +import type { IGenerateTotpSecretUseCase } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; +import type { IConfirmTotpSetupUseCase } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; +import type { IDisableTotpUseCase } from '@/use-cases/user/IDisableTotpUseCase.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; const stub = (methods: Partial): T => methods as T; @@ -16,6 +20,12 @@ const makeDeps = (overrides?: object) => ({ execute: vi.fn().mockResolvedValue(undefined), }), exportUserDataUseCase: stub({ execute: vi.fn() }), + generateTotpSecretUseCase: stub({ execute: vi.fn() }), + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockResolvedValue(undefined), + }), + disableTotpUseCase: stub({ execute: vi.fn().mockResolvedValue(undefined) }), + getTotpStatusUseCase: stub({ execute: vi.fn() }), ...overrides, }); @@ -100,4 +110,78 @@ describe('UserResolver', () => { expect(result).toEqual(exportData); }); }); + + describe('beginTotpSetup', () => { + it('delegates to generateTotpSecretUseCase and returns the result', async () => { + const setup = { + secret: 'ABCD1234', + otpauthUrl: 'otpauth://totp/...', + qrCodeDataUrl: 'data:image/png;base64,...', + }; + const deps = makeDeps({ + generateTotpSecretUseCase: stub({ + execute: vi.fn().mockResolvedValue(setup), + }), + }); + + const result = await new UserResolver(deps).beginTotpSetup('user-1'); + + expect(deps.generateTotpSecretUseCase.execute).toHaveBeenCalledWith('user-1'); + expect(result).toEqual(setup); + }); + }); + + describe('confirmTotpSetup', () => { + it('delegates to confirmTotpSetupUseCase with the correct arguments', async () => { + const deps = makeDeps(); + + await new UserResolver(deps).confirmTotpSetup('user-1', '123456'); + + expect(deps.confirmTotpSetupUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + code: '123456', + }); + }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid verification code'), { code: 'UNAUTHORIZED' }); + const deps = makeDeps({ + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockRejectedValue(err), + }), + }); + + await expect( + new UserResolver(deps).confirmTotpSetup('user-1', 'bad-code'), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('disableTotp', () => { + it('delegates to disableTotpUseCase with the correct arguments', async () => { + const deps = makeDeps(); + + await new UserResolver(deps).disableTotp('user-1', 'secret'); + + expect(deps.disableTotpUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + password: 'secret', + }); + }); + }); + + describe('getTotpStatus', () => { + it('delegates to getTotpStatusUseCase and returns the result', async () => { + const deps = makeDeps({ + getTotpStatusUseCase: stub({ + execute: vi.fn().mockResolvedValue(true), + }), + }); + + const result = await new UserResolver(deps).getTotpStatus('user-1'); + + expect(deps.getTotpStatusUseCase.execute).toHaveBeenCalledWith('user-1'); + expect(result).toBe(true); + }); + }); }); diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index de3ea730..a115d1c6 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -88,6 +88,13 @@ export const API_TOKEN_SCOPE = { READ: 'read', } as const; +/** TOTP (RFC 6238) two-factor authentication settings. */ +export const TOTP_CONFIG = { + ISSUER: 'Job Finder', + /** Accept codes from the adjacent time step to absorb minor clock drift. */ + EPOCH_TOLERANCE_S: 30, +} as const; + /** HTTP Authorization header. */ export const AUTH_HEADER = { BEARER_PREFIX: 'Bearer ', diff --git a/apps/api/src/domain/user/User.ts b/apps/api/src/domain/user/User.ts index e0d40921..b50221ff 100644 --- a/apps/api/src/domain/user/User.ts +++ b/apps/api/src/domain/user/User.ts @@ -2,6 +2,8 @@ export interface User { id: string; email: string; passwordHash: string; + totpSecret: string | null; + totpEnabled: boolean; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index 36b69134..a99b1ad5 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -39,6 +39,7 @@ import { McpController } from '@/interface-adapters/mcp/McpController.js'; import { RegisterUseCase } from '@/use-cases/auth/RegisterUseCase.js'; import { LoginUseCase } from '@/use-cases/auth/LoginUseCase.js'; +import { LoginWithTotpUseCase } from '@/use-cases/auth/LoginWithTotpUseCase.js'; import { CreateApplicationUseCase } from '@/use-cases/jobs/CreateApplicationUseCase.js'; import { GetApplicationsUseCase } from '@/use-cases/jobs/GetApplicationsUseCase.js'; import { GetApplicationUseCase } from '@/use-cases/jobs/GetApplicationUseCase.js'; @@ -56,6 +57,10 @@ import { UpdateEmailUseCase } from '@/use-cases/user/UpdateEmailUseCase.js'; import { UpdatePasswordUseCase } from '@/use-cases/user/UpdatePasswordUseCase.js'; import { DeleteAccountUseCase } from '@/use-cases/user/DeleteAccountUseCase.js'; import { ExportUserDataUseCase } from '@/use-cases/user/ExportUserDataUseCase.js'; +import { GenerateTotpSecretUseCase } from '@/use-cases/user/GenerateTotpSecretUseCase.js'; +import { ConfirmTotpSetupUseCase } from '@/use-cases/user/ConfirmTotpSetupUseCase.js'; +import { DisableTotpUseCase } from '@/use-cases/user/DisableTotpUseCase.js'; +import { GetTotpStatusUseCase } from '@/use-cases/user/GetTotpStatusUseCase.js'; import { CreateInterviewRoundUseCase } from '@/use-cases/interviewRounds/CreateInterviewRoundUseCase.js'; import { GetInterviewRoundsUseCase } from '@/use-cases/interviewRounds/GetInterviewRoundsUseCase.js'; import { UpdateInterviewRoundUseCase } from '@/use-cases/interviewRounds/UpdateInterviewRoundUseCase.js'; @@ -132,6 +137,7 @@ declare module '@fastify/awilix' { registerUseCase: RegisterUseCase; loginUseCase: LoginUseCase; + loginWithTotpUseCase: LoginWithTotpUseCase; createApplicationUseCase: CreateApplicationUseCase; getApplicationsUseCase: GetApplicationsUseCase; getApplicationUseCase: GetApplicationUseCase; @@ -149,6 +155,10 @@ declare module '@fastify/awilix' { updatePasswordUseCase: UpdatePasswordUseCase; deleteAccountUseCase: DeleteAccountUseCase; exportUserDataUseCase: ExportUserDataUseCase; + generateTotpSecretUseCase: GenerateTotpSecretUseCase; + confirmTotpSetupUseCase: ConfirmTotpSetupUseCase; + disableTotpUseCase: DisableTotpUseCase; + getTotpStatusUseCase: GetTotpStatusUseCase; createInterviewRoundUseCase: CreateInterviewRoundUseCase; getInterviewRoundsUseCase: GetInterviewRoundsUseCase; updateInterviewRoundUseCase: UpdateInterviewRoundUseCase; @@ -243,6 +253,7 @@ export function buildContainer(fastify: FastifyInstance): void { // Use Cases registerUseCase: asClass(RegisterUseCase, { lifetime: Lifetime.TRANSIENT }), loginUseCase: asClass(LoginUseCase, { lifetime: Lifetime.TRANSIENT }), + loginWithTotpUseCase: asClass(LoginWithTotpUseCase, { lifetime: Lifetime.TRANSIENT }), createApplicationUseCase: asClass(CreateApplicationUseCase, { lifetime: Lifetime.TRANSIENT, }), @@ -266,6 +277,12 @@ export function buildContainer(fastify: FastifyInstance): void { updatePasswordUseCase: asClass(UpdatePasswordUseCase, { lifetime: Lifetime.TRANSIENT }), deleteAccountUseCase: asClass(DeleteAccountUseCase, { lifetime: Lifetime.TRANSIENT }), exportUserDataUseCase: asClass(ExportUserDataUseCase, { lifetime: Lifetime.TRANSIENT }), + generateTotpSecretUseCase: asClass(GenerateTotpSecretUseCase, { + lifetime: Lifetime.TRANSIENT, + }), + confirmTotpSetupUseCase: asClass(ConfirmTotpSetupUseCase, { lifetime: Lifetime.TRANSIENT }), + disableTotpUseCase: asClass(DisableTotpUseCase, { lifetime: Lifetime.TRANSIENT }), + getTotpStatusUseCase: asClass(GetTotpStatusUseCase, { lifetime: Lifetime.TRANSIENT }), createInterviewRoundUseCase: asClass(CreateInterviewRoundUseCase, { lifetime: Lifetime.TRANSIENT, }), diff --git a/apps/api/src/http/schema/index.ts b/apps/api/src/http/schema/index.ts index ba6bdd4b..3cca3db7 100644 --- a/apps/api/src/http/schema/index.ts +++ b/apps/api/src/http/schema/index.ts @@ -17,6 +17,8 @@ import './types/inputs/DocumentInputs.js'; import './types/ContactType.js'; import './types/ParsedJobDescriptionType.js'; import './types/HealthScoreType.js'; +import './types/LoginResultType.js'; +import './types/TotpSetupType.js'; // Queries import './queries/applicationQueries.js'; diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index fca102a9..ad59fd64 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -1,6 +1,7 @@ import { GraphQLError } from 'graphql'; import { builder } from '@/http/schema/builder.js'; import { setAuthCookies, clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import { LoginResultRef } from '@/http/schema/types/LoginResultType.js'; import { ERROR_CODES } from '@/constants.js'; builder.mutationField('register', (t) => @@ -19,14 +20,33 @@ builder.mutationField('register', (t) => ); builder.mutationField('login', (t) => + t.field({ + type: LoginResultRef, + args: { + email: t.arg.string({ required: true }), + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + const { authResolver } = ctx.diScope.cradle; + const result = await authResolver.login(args.email, args.password); + if (result.tokens) { + setAuthCookies(ctx.reply, result.tokens.accessToken, result.tokens.refreshToken); + } + return { success: !result.totpRequired, totpRequired: result.totpRequired }; + }, + }), +); + +builder.mutationField('loginWithTotp', (t) => t.boolean({ args: { email: t.arg.string({ required: true }), password: t.arg.string({ required: true }), + code: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const { authResolver } = ctx.diScope.cradle; - const tokens = await authResolver.login(args.email, args.password); + const tokens = await authResolver.loginWithTotp(args.email, args.password, args.code); setAuthCookies(ctx.reply, tokens.accessToken, tokens.refreshToken); return true; }, diff --git a/apps/api/src/http/schema/mutations/userMutations.ts b/apps/api/src/http/schema/mutations/userMutations.ts index a3ae27ea..aed9c8e3 100644 --- a/apps/api/src/http/schema/mutations/userMutations.ts +++ b/apps/api/src/http/schema/mutations/userMutations.ts @@ -1,6 +1,7 @@ import { GraphQLError } from 'graphql'; import { builder } from '@/http/schema/builder.js'; import { clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import { TotpSetupRef } from '@/http/schema/types/TotpSetupType.js'; import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -63,3 +64,57 @@ builder.mutationField('deleteAccount', (t) => }, }), ); + +builder.mutationField('beginTotpSetup', (t) => + t.field({ + type: TotpSetupRef, + resolve: async (_root, _args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + return await userResolver.beginTotpSetup(ctx.user.sub); + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('confirmTotpSetup', (t) => + t.boolean({ + args: { + code: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + await userResolver.confirmTotpSetup(ctx.user.sub, args.code); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('disableTotp', (t) => + t.boolean({ + args: { + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + await userResolver.disableTotp(ctx.user.sub, args.password); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); diff --git a/apps/api/src/http/schema/queries/userQueries.ts b/apps/api/src/http/schema/queries/userQueries.ts index a3001809..50524ee7 100644 --- a/apps/api/src/http/schema/queries/userQueries.ts +++ b/apps/api/src/http/schema/queries/userQueries.ts @@ -13,3 +13,14 @@ builder.queryField('exportUserData', (t) => }, }), ); + +builder.queryField('totpEnabled', (t) => + t.boolean({ + resolve: async (_root, _args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + return userResolver.getTotpStatus(ctx.user.sub); + }, + }), +); diff --git a/apps/api/src/http/schema/types/LoginResultType.ts b/apps/api/src/http/schema/types/LoginResultType.ts new file mode 100644 index 00000000..be1d5e11 --- /dev/null +++ b/apps/api/src/http/schema/types/LoginResultType.ts @@ -0,0 +1,14 @@ +import { builder } from '@/http/schema/builder.js'; + +export interface LoginResultDTO { + success: boolean; + totpRequired: boolean; +} + +export const LoginResultRef = builder.objectRef('LoginResult'); +LoginResultRef.implement({ + fields: (t) => ({ + success: t.exposeBoolean('success'), + totpRequired: t.exposeBoolean('totpRequired'), + }), +}); diff --git a/apps/api/src/http/schema/types/TotpSetupType.ts b/apps/api/src/http/schema/types/TotpSetupType.ts new file mode 100644 index 00000000..38543832 --- /dev/null +++ b/apps/api/src/http/schema/types/TotpSetupType.ts @@ -0,0 +1,11 @@ +import { builder } from '@/http/schema/builder.js'; +import type { TotpSetup } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; + +export const TotpSetupRef = builder.objectRef('TotpSetup'); +TotpSetupRef.implement({ + fields: (t) => ({ + secret: t.exposeString('secret'), + otpauthUrl: t.exposeString('otpauthUrl'), + qrCodeDataUrl: t.exposeString('qrCodeDataUrl'), + }), +}); diff --git a/apps/api/src/infrastructure/auth/totp.ts b/apps/api/src/infrastructure/auth/totp.ts new file mode 100644 index 00000000..0c1d5775 --- /dev/null +++ b/apps/api/src/infrastructure/auth/totp.ts @@ -0,0 +1,10 @@ +import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib'; +import { TOTP_CONFIG } from '@/constants.js'; + +const crypto = new NobleCryptoPlugin(); +const base32 = new ScureBase32Plugin(); + +/** Shared TOTP instance factory — `secret`/`label` are supplied per call site. */ +export function createTotp(options: { secret?: string; label?: string } = {}): TOTP { + return new TOTP({ crypto, base32, issuer: TOTP_CONFIG.ISSUER, ...options }); +} diff --git a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts index 752546c9..275dda5d 100644 --- a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts +++ b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts @@ -34,7 +34,15 @@ export class PrismaUserRepository implements IUserRepository { return this.toEntity(row); } - async update(id: string, data: { email?: string; passwordHash?: string }): Promise { + async update( + id: string, + data: { + email?: string; + passwordHash?: string; + totpSecret?: string | null; + totpEnabled?: boolean; + }, + ): Promise { const row = await this.db.user.update({ where: { id }, data }); return this.toEntity(row); } @@ -47,6 +55,8 @@ export class PrismaUserRepository implements IUserRepository { id: string; email: string; passwordHash: string; + totpSecret: string | null; + totpEnabled: boolean; createdAt: Date; updatedAt: Date; }): User { @@ -54,6 +64,8 @@ export class PrismaUserRepository implements IUserRepository { id: row.id, email: row.email, passwordHash: row.passwordHash, + totpSecret: row.totpSecret, + totpEnabled: row.totpEnabled, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index 252fd856..95c5a659 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -1,13 +1,20 @@ import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { ILoginWithTotpUseCase } from '@/use-cases/auth/ILoginWithTotpUseCase.js'; import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js'; interface Deps { registerUseCase: IRegisterUseCase; loginUseCase: ILoginUseCase; + loginWithTotpUseCase: ILoginWithTotpUseCase; tokenService: ITokenService; } +export interface LoginResult { + totpRequired: boolean; + tokens: TokenPair | null; +} + export class AuthResolver { constructor(private readonly deps: Deps) {} @@ -16,8 +23,16 @@ export class AuthResolver { return this.deps.tokenService.sign(userId, email); } - async login(email: string, password: string): Promise { + async login(email: string, password: string): Promise { const user = await this.deps.loginUseCase.execute({ email, password }); + if (user.totpEnabled) { + return { totpRequired: true, tokens: null }; + } + return { totpRequired: false, tokens: this.deps.tokenService.sign(user.id, user.email) }; + } + + async loginWithTotp(email: string, password: string, code: string): Promise { + const user = await this.deps.loginWithTotpUseCase.execute({ email, password, code }); return this.deps.tokenService.sign(user.id, user.email); } diff --git a/apps/api/src/interface-adapters/resolvers/UserResolver.ts b/apps/api/src/interface-adapters/resolvers/UserResolver.ts index 39e30bbf..91093197 100644 --- a/apps/api/src/interface-adapters/resolvers/UserResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/UserResolver.ts @@ -5,12 +5,23 @@ import type { IExportUserDataUseCase, ExportUserDataOutput, } from '@/use-cases/user/IExportUserDataUseCase.js'; +import type { + IGenerateTotpSecretUseCase, + TotpSetup, +} from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; +import type { IConfirmTotpSetupUseCase } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; +import type { IDisableTotpUseCase } from '@/use-cases/user/IDisableTotpUseCase.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; interface Deps { updateEmailUseCase: IUpdateEmailUseCase; updatePasswordUseCase: IUpdatePasswordUseCase; deleteAccountUseCase: IDeleteAccountUseCase; exportUserDataUseCase: IExportUserDataUseCase; + generateTotpSecretUseCase: IGenerateTotpSecretUseCase; + confirmTotpSetupUseCase: IConfirmTotpSetupUseCase; + disableTotpUseCase: IDisableTotpUseCase; + getTotpStatusUseCase: IGetTotpStatusUseCase; } export class UserResolver { @@ -35,4 +46,20 @@ export class UserResolver { async exportUserData(userId: string): Promise { return this.deps.exportUserDataUseCase.execute(userId); } + + async beginTotpSetup(userId: string): Promise { + return this.deps.generateTotpSecretUseCase.execute(userId); + } + + async confirmTotpSetup(userId: string, code: string): Promise { + await this.deps.confirmTotpSetupUseCase.execute({ userId, code }); + } + + async disableTotp(userId: string, password: string): Promise { + await this.deps.disableTotpUseCase.execute({ userId, password }); + } + + async getTotpStatus(userId: string): Promise { + return this.deps.getTotpStatusUseCase.execute(userId); + } } diff --git a/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts new file mode 100644 index 00000000..2853c990 --- /dev/null +++ b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts @@ -0,0 +1,11 @@ +import type { User } from '@/domain/user/User.js'; + +export interface LoginWithTotpInput { + email: string; + password: string; + code: string; +} + +export interface ILoginWithTotpUseCase { + execute(input: LoginWithTotpInput): Promise; +} diff --git a/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts new file mode 100644 index 00000000..68bc1209 --- /dev/null +++ b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts @@ -0,0 +1,44 @@ +import bcrypt from 'bcryptjs'; +import type { User } from '@/domain/user/User.js'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { ERROR_CODES, TOTP_CONFIG } from '@/constants.js'; +import type { + ILoginWithTotpUseCase, + LoginWithTotpInput, +} from '@/use-cases/auth/ILoginWithTotpUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class LoginWithTotpUseCase implements ILoginWithTotpUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: LoginWithTotpInput): Promise { + const user = await this.deps.userRepository.findByEmail(input.email); + if (!user) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + const validPassword = await bcrypt.compare(input.password, user.passwordHash); + if (!validPassword) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + if (!user.totpEnabled || !user.totpSecret) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + const result = await createTotp({ secret: user.totpSecret }).verify(input.code, { + epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, + }); + if (!result.valid) { + throw Object.assign(new Error('Invalid verification code'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + return user; + } +} diff --git a/apps/api/src/use-cases/ports/IUserRepository.ts b/apps/api/src/use-cases/ports/IUserRepository.ts index 4f1232bc..b1d16239 100644 --- a/apps/api/src/use-cases/ports/IUserRepository.ts +++ b/apps/api/src/use-cases/ports/IUserRepository.ts @@ -5,6 +5,14 @@ export interface IUserRepository { findByEmail(email: string): Promise; findAll(): Promise; create(data: { id: string; email: string; passwordHash: string }): Promise; - update(id: string, data: { email?: string; passwordHash?: string }): Promise; + update( + id: string, + data: { + email?: string; + passwordHash?: string; + totpSecret?: string | null; + totpEnabled?: boolean; + }, + ): Promise; delete(id: string): Promise; } diff --git a/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts new file mode 100644 index 00000000..6c80d2af --- /dev/null +++ b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts @@ -0,0 +1,42 @@ +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { ERROR_CODES, TOTP_CONFIG } from '@/constants.js'; +import type { + IConfirmTotpSetupUseCase, + ConfirmTotpSetupInput, +} from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class ConfirmTotpSetupUseCase implements IConfirmTotpSetupUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: ConfirmTotpSetupInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + if (user.totpEnabled) { + throw Object.assign(new Error('Two-factor authentication is already enabled'), { + code: ERROR_CODES.CONFLICT, + }); + } + if (!user.totpSecret) { + throw Object.assign(new Error('No two-factor setup in progress'), { + code: ERROR_CODES.CONFLICT, + }); + } + + const result = await createTotp({ secret: user.totpSecret }).verify(input.code, { + epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, + }); + if (!result.valid) { + throw Object.assign(new Error('Invalid verification code'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + await this.deps.userRepository.update(input.userId, { totpEnabled: true }); + } +} diff --git a/apps/api/src/use-cases/user/DisableTotpUseCase.ts b/apps/api/src/use-cases/user/DisableTotpUseCase.ts new file mode 100644 index 00000000..6efed22e --- /dev/null +++ b/apps/api/src/use-cases/user/DisableTotpUseCase.ts @@ -0,0 +1,29 @@ +import bcrypt from 'bcryptjs'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { + IDisableTotpUseCase, + DisableTotpInput, +} from '@/use-cases/user/IDisableTotpUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class DisableTotpUseCase implements IDisableTotpUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: DisableTotpInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + const valid = await bcrypt.compare(input.password, user.passwordHash); + if (!valid) + throw Object.assign(new Error('Invalid password'), { code: ERROR_CODES.UNAUTHORIZED }); + + await this.deps.userRepository.update(input.userId, { + totpEnabled: false, + totpSecret: null, + }); + } +} diff --git a/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts new file mode 100644 index 00000000..d5bc5061 --- /dev/null +++ b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts @@ -0,0 +1,35 @@ +import QRCode from 'qrcode'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { + IGenerateTotpSecretUseCase, + TotpSetup, +} from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class GenerateTotpSecretUseCase implements IGenerateTotpSecretUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string): Promise { + const user = await this.deps.userRepository.findById(userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + if (user.totpEnabled) { + throw Object.assign(new Error('Two-factor authentication is already enabled'), { + code: ERROR_CODES.CONFLICT, + }); + } + + const secret = createTotp().generateSecret(); + const otpauthUrl = createTotp({ secret, label: user.email }).toURI(); + const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl); + + await this.deps.userRepository.update(userId, { totpSecret: secret }); + + return { secret, otpauthUrl, qrCodeDataUrl }; + } +} diff --git a/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts b/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts new file mode 100644 index 00000000..fb223b78 --- /dev/null +++ b/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts @@ -0,0 +1,18 @@ +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class GetTotpStatusUseCase implements IGetTotpStatusUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string): Promise { + const user = await this.deps.userRepository.findById(userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + return user.totpEnabled; + } +} diff --git a/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts new file mode 100644 index 00000000..b550a27e --- /dev/null +++ b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts @@ -0,0 +1,8 @@ +export interface ConfirmTotpSetupInput { + userId: string; + code: string; +} + +export interface IConfirmTotpSetupUseCase { + execute(input: ConfirmTotpSetupInput): Promise; +} diff --git a/apps/api/src/use-cases/user/IDisableTotpUseCase.ts b/apps/api/src/use-cases/user/IDisableTotpUseCase.ts new file mode 100644 index 00000000..9409d3e0 --- /dev/null +++ b/apps/api/src/use-cases/user/IDisableTotpUseCase.ts @@ -0,0 +1,8 @@ +export interface DisableTotpInput { + userId: string; + password: string; +} + +export interface IDisableTotpUseCase { + execute(input: DisableTotpInput): Promise; +} diff --git a/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts new file mode 100644 index 00000000..4dbcdc23 --- /dev/null +++ b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts @@ -0,0 +1,9 @@ +export interface TotpSetup { + secret: string; + otpauthUrl: string; + qrCodeDataUrl: string; +} + +export interface IGenerateTotpSecretUseCase { + execute(userId: string): Promise; +} diff --git a/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts b/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts new file mode 100644 index 00000000..45c2a310 --- /dev/null +++ b/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts @@ -0,0 +1,3 @@ +export interface IGetTotpStatusUseCase { + execute(userId: string): Promise; +} diff --git a/apps/web/src/__tests__/components/AccountPage.test.tsx b/apps/web/src/__tests__/components/AccountPage.test.tsx index ebe7c4a4..9546ce41 100644 --- a/apps/web/src/__tests__/components/AccountPage.test.tsx +++ b/apps/web/src/__tests__/components/AccountPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const { mockNavigate, mockGqlRequest, mockClearAuthIndicator } = vi.hoisted(() => ({ mockNavigate: vi.fn(), @@ -37,9 +38,16 @@ vi.mock('#/lib/queryClient', () => ({ import { AccountPage } from '#/routes/_authenticated/account'; +const makeClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + describe('AccountPage', () => { beforeEach(() => { vi.clearAllMocks(); + mockGqlRequest.mockResolvedValue({ totpEnabled: false }); }); afterEach(() => { @@ -47,18 +55,20 @@ describe('AccountPage', () => { }); it('renders all sections', () => { - render(); + render(, { wrapper: Wrapper }); expect(screen.getByText('Account settings')).toBeInTheDocument(); expect(screen.getByText('Email address')).toBeInTheDocument(); expect(screen.getByText('Password')).toBeInTheDocument(); + expect(screen.getByText('Two-factor authentication')).toBeInTheDocument(); expect(screen.getByText('Export your data')).toBeInTheDocument(); expect(screen.getByText('Danger zone')).toBeInTheDocument(); }); describe('email update form', () => { it('calls updateEmail mutation with current password and new email', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockResolvedValue({ updateEmail: true }); - render(); const updateEmailBtn = screen.getByRole('button', { name: /update email/i }); const form = updateEmailBtn.closest('form')!; @@ -78,10 +88,11 @@ describe('AccountPage', () => { }); it('shows error message on email update failure', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockRejectedValue({ response: { errors: [{ message: 'Email already in use' }] }, }); - render(); const updateEmailBtn = screen.getByRole('button', { name: /update email/i }); const form = updateEmailBtn.closest('form')!; @@ -100,7 +111,7 @@ describe('AccountPage', () => { describe('password update form', () => { it('shows validation error when new passwords do not match', async () => { - render(); + render(, { wrapper: Wrapper }); const updatePasswordBtn = screen.getByRole('button', { name: /update password/i }); const form = updatePasswordBtn.closest('form')!; @@ -114,12 +125,16 @@ describe('AccountPage', () => { await waitFor(() => { expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); }); - expect(mockGqlRequest).not.toHaveBeenCalled(); + expect(mockGqlRequest).not.toHaveBeenCalledWith( + expect.stringContaining('UpdatePassword'), + expect.anything(), + ); }); it('calls updatePassword mutation with matching passwords', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockResolvedValue({ updatePassword: true }); - render(); const updatePasswordBtn = screen.getByRole('button', { name: /update password/i }); const form = updatePasswordBtn.closest('form')!; @@ -139,8 +154,9 @@ describe('AccountPage', () => { }); it('shows generic error message when password update fails', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockRejectedValue(new Error('network error')); - render(); const updatePasswordBtn = screen.getByRole('button', { name: /update password/i }); const form = updatePasswordBtn.closest('form')!; @@ -157,14 +173,79 @@ describe('AccountPage', () => { }); }); + describe('two-factor authentication', () => { + it('shows an "Enable 2FA" button when disabled', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => { + expect(screen.getByRole('button', { name: /enable 2fa/i })).toBeInTheDocument(); + }); + }); + + it('starts setup, shows the QR code and secret, and confirms with a code', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + + const setupData = { + beginTotpSetup: { + secret: 'ABCD1234', + otpauthUrl: 'otpauth://totp/Job%20Finder:test@example.com?secret=ABCD1234', + qrCodeDataUrl: 'data:image/png;base64,abc123', + }, + }; + mockGqlRequest.mockResolvedValueOnce(setupData); + fireEvent.click(screen.getByRole('button', { name: /enable 2fa/i })); + + await waitFor(() => { + expect(screen.getByAltText('Two-factor authentication QR code')).toHaveAttribute( + 'src', + setupData.beginTotpSetup.qrCodeDataUrl, + ); + }); + expect(screen.getByText('ABCD1234')).toBeInTheDocument(); + + mockGqlRequest.mockResolvedValueOnce({ confirmTotpSetup: true }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: /^confirm$/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('ConfirmTotpSetup'), { + code: '654321', + }); + }); + }); + + it('shows a disable form when 2FA is already enabled', async () => { + mockGqlRequest.mockResolvedValue({ totpEnabled: true }); + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(screen.getByText('Two-factor authentication is enabled.')).toBeInTheDocument(); + }); + + mockGqlRequest.mockResolvedValueOnce({ disableTotp: true }); + const disableBtn = screen.getByRole('button', { name: /disable 2fa/i }); + const form = disableBtn.closest('form')!; + const passwordInput = form.querySelector('input[type="password"]')!; + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + fireEvent.click(disableBtn); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('DisableTotp'), { + password: 'mypassword', + }); + }); + }); + }); + describe('data export', () => { it('calls exportUserData and triggers download on success', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockResolvedValue({ exportUserData: '{"applications":[]}' }); const mockRevokeObjectURL = vi.fn(); vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(mockRevokeObjectURL); - render(); fireEvent.click(screen.getByRole('button', { name: /download export/i })); await waitFor(() => { @@ -178,8 +259,9 @@ describe('AccountPage', () => { describe('account deletion', () => { it('calls deleteAccount mutation, clears auth, and navigates to /login', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockResolvedValue({ deleteAccount: true }); - render(); const deleteBtn = screen.getByRole('button', { name: /delete my account/i }); const form = deleteBtn.closest('form')!; @@ -198,10 +280,11 @@ describe('AccountPage', () => { }); it('shows error message when deletion fails', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockRejectedValue({ response: { errors: [{ message: 'Invalid password' }] }, }); - render(); const deleteBtn = screen.getByRole('button', { name: /delete my account/i }); const form = deleteBtn.closest('form')!; diff --git a/apps/web/src/__tests__/components/LoginPage.test.tsx b/apps/web/src/__tests__/components/LoginPage.test.tsx index 91a50a36..7442a95a 100644 --- a/apps/web/src/__tests__/components/LoginPage.test.tsx +++ b/apps/web/src/__tests__/components/LoginPage.test.tsx @@ -31,6 +31,15 @@ vi.mock('#/lib/queryClient', () => ({ import { LoginPage } from '#/routes/login'; +const noTotpResponse = { login: { success: true, totpRequired: false } }; +const totpRequiredResponse = { login: { success: false, totpRequired: true } }; + +async function fillCredentials(email: string, password: string) { + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: email } }); + fireEvent.change(screen.getByPlaceholderText('••••••••'), { target: { value: password } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); +} + describe('LoginPage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -60,13 +69,7 @@ describe('LoginPage', () => { it('shows validation error when password is too short', async () => { render(); - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'short' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'short'); await waitFor(() => { expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument(); @@ -75,16 +78,9 @@ describe('LoginPage', () => { }); it('calls gqlClient.request with email and password on valid submit', async () => { - mockGqlRequest.mockResolvedValue({}); + mockGqlRequest.mockResolvedValue(noTotpResponse); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('Login'), { @@ -94,17 +90,10 @@ describe('LoginPage', () => { }); }); - it('navigates to /dashboard after successful login', async () => { - mockGqlRequest.mockResolvedValue({}); + it('navigates to /dashboard after successful login without 2FA', async () => { + mockGqlRequest.mockResolvedValue(noTotpResponse); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ to: '/dashboard' }); @@ -116,14 +105,7 @@ describe('LoginPage', () => { response: { errors: [{ message: 'Invalid credentials' }] }, }); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); @@ -134,17 +116,74 @@ describe('LoginPage', () => { it('shows generic error message when API error has no message', async () => { mockGqlRequest.mockRejectedValue(new Error('Network error')); render(); + await fillCredentials('test@example.com', 'password123'); - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, + await waitFor(() => { + expect(screen.getByText('Login failed. Please try again.')).toBeInTheDocument(); }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, + }); + + describe('two-factor authentication step', () => { + it('shows the code entry step when the login response requires 2FA', async () => { + mockGqlRequest.mockResolvedValue(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + + await waitFor(() => { + expect(screen.getByText('Two-factor authentication')).toBeInTheDocument(); + }); + expect(mockNavigate).not.toHaveBeenCalled(); }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); - await waitFor(() => { - expect(screen.getByText('Login failed. Please try again.')).toBeInTheDocument(); + it('calls loginWithTotp with the original credentials and the code, then navigates', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + mockGqlRequest.mockResolvedValueOnce({ loginWithTotp: true }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: /verify/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('LoginWithTotp'), { + email: 'test@example.com', + password: 'password123', + code: '654321', + }); + }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ to: '/dashboard' }); + }); + }); + + it('shows an error message when the code is invalid', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + mockGqlRequest.mockRejectedValueOnce({ + response: { errors: [{ message: 'Invalid verification code' }] }, + }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '000000' } }); + fireEvent.click(screen.getByRole('button', { name: /verify/i })); + + await waitFor(() => { + expect(screen.getByText('Invalid verification code')).toBeInTheDocument(); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('returns to the credentials form when "Back to sign in" is clicked', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + fireEvent.click(screen.getByRole('button', { name: /back to sign in/i })); + + expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument(); }); }); }); diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx index 6722bca6..5fb87268 100644 --- a/apps/web/src/routes/_authenticated/account.tsx +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { gqlClient } from '#/graphql/client'; @@ -36,6 +38,34 @@ const EXPORT_USER_DATA = ` } `; +const TOTP_ENABLED_QUERY = ` + query TotpEnabled { + totpEnabled + } +`; + +const BEGIN_TOTP_SETUP = ` + mutation BeginTotpSetup { + beginTotpSetup { + secret + otpauthUrl + qrCodeDataUrl + } + } +`; + +const CONFIRM_TOTP_SETUP = ` + mutation ConfirmTotpSetup($code: String!) { + confirmTotpSetup(code: $code) + } +`; + +const DISABLE_TOTP = ` + mutation DisableTotp($password: String!) { + disableTotp(password: $password) + } +`; + // ── Schemas ──────────────────────────────────────────────────────────────── const emailSchema = z.object({ @@ -43,6 +73,14 @@ const emailSchema = z.object({ newEmail: z.string().email('Invalid email'), }); +const totpConfirmSchema = z.object({ + code: z.string().min(6, 'Enter the 6-digit code').max(6, 'Enter the 6-digit code'), +}); + +const totpDisableSchema = z.object({ + password: z.string().min(1, 'Required'), +}); + const passwordSchema = z .object({ currentPassword: z.string().min(1, 'Required'), @@ -61,6 +99,10 @@ const deleteSchema = z.object({ type EmailForm = z.infer; type PasswordForm = z.infer; type DeleteForm = z.infer; +type TotpConfirmForm = z.infer; +type TotpDisableForm = z.infer; + +type TotpSetup = { secret: string; otpauthUrl: string; qrCodeDataUrl: string }; // ── Input styles ─────────────────────────────────────────────────────────── @@ -73,6 +115,53 @@ const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb- export function AccountPage() { const navigate = useNavigate(); + const qc = useQueryClient(); + + // Two-factor authentication + const { data: totpData } = useQuery({ + queryKey: ['totpEnabled'], + queryFn: () => gqlClient.request<{ totpEnabled: boolean }>(TOTP_ENABLED_QUERY), + }); + const totpEnabled = totpData?.totpEnabled ?? false; + const [totpSetup, setTotpSetup] = useState(null); + const [totpSetupError, setTotpSetupError] = useState(null); + + const onBeginTotpSetup = async () => { + setTotpSetupError(null); + try { + const res = await gqlClient.request<{ beginTotpSetup: TotpSetup }>(BEGIN_TOTP_SETUP); + setTotpSetup(res.beginTotpSetup); + } catch (err) { + setTotpSetupError(extractGqlError(err) ?? 'Failed to start two-factor setup.'); + } + }; + + const totpConfirmForm = useForm({ resolver: zodResolver(totpConfirmSchema) }); + const onConfirmTotpSetup = async (data: TotpConfirmForm) => { + try { + await gqlClient.request(CONFIRM_TOTP_SETUP, data); + setTotpSetup(null); + totpConfirmForm.reset(); + await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); + } catch (err) { + totpConfirmForm.setError('root', { + message: extractGqlError(err) ?? 'Invalid code. Please try again.', + }); + } + }; + + const totpDisableForm = useForm({ resolver: zodResolver(totpDisableSchema) }); + const onDisableTotp = async (data: TotpDisableForm) => { + try { + await gqlClient.request(DISABLE_TOTP, data); + totpDisableForm.reset(); + await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); + } catch (err) { + totpDisableForm.setError('root', { + message: extractGqlError(err) ?? 'Failed to disable two-factor authentication.', + }); + } + }; // Email form const emailForm = useForm({ resolver: zodResolver(emailSchema) }); @@ -264,6 +353,110 @@ export function AccountPage() {
+ {/* ── Two-factor authentication ── */} +
+
+

+ Two-factor authentication +

+

+ Require a code from an authenticator app in addition to your password. +

+
+ + {totpEnabled ? ( +
+

Two-factor authentication is enabled.

+
+ + + {totpDisableForm.formState.errors.password && ( +

+ {totpDisableForm.formState.errors.password.message} +

+ )} +
+ {totpDisableForm.formState.errors.root && ( +

+ {totpDisableForm.formState.errors.root.message} +

+ )} + +
+ ) : totpSetup ? ( +
+

+ Scan this QR code with your authenticator app, or enter the key manually. +

+ Two-factor authentication QR code +

+ {totpSetup.secret} +

+
+
+ + + {totpConfirmForm.formState.errors.code && ( +

+ {totpConfirmForm.formState.errors.code.message} +

+ )} +
+ {totpConfirmForm.formState.errors.root && ( +

+ {totpConfirmForm.formState.errors.root.message} +

+ )} + +
+
+ ) : ( +
+ {totpSetupError && ( +

+ {totpSetupError} +

+ )} + +
+ )} +
+ +
+ {/* ── Export ── */}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 5c4771dc..f8fdd2aa 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -12,9 +13,23 @@ const schema = z.object({ }); type FormValues = z.infer; +const totpSchema = z.object({ + code: z.string().min(6, 'Enter the 6-digit code').max(6, 'Enter the 6-digit code'), +}); +type TotpFormValues = z.infer; + const LOGIN_MUTATION = ` mutation Login($email: String!, $password: String!) { - login(email: $email, password: $password) + login(email: $email, password: $password) { + success + totpRequired + } + } +`; + +const LOGIN_WITH_TOTP_MUTATION = ` + mutation LoginWithTotp($email: String!, $password: String!, $code: String!) { + loginWithTotp(email: $email, password: $password, code: $code) } `; @@ -28,6 +43,8 @@ export const Route = createFileRoute('/login')({ export function LoginPage() { const navigate = useNavigate(); + const [pendingCredentials, setPendingCredentials] = useState(null); + const { register, handleSubmit, @@ -39,7 +56,13 @@ export function LoginPage() { const onSubmit = async (data: FormValues) => { try { - await gqlClient.request(LOGIN_MUTATION, data); + const res = await gqlClient.request<{ + login: { success: boolean; totpRequired: boolean }; + }>(LOGIN_MUTATION, data); + if (res.login.totpRequired) { + setPendingCredentials(data); + return; + } await queryClient.resetQueries(); await navigate({ to: '/dashboard' }); } catch (err: unknown) { @@ -48,6 +71,10 @@ export function LoginPage() { } }; + if (pendingCredentials) { + return setPendingCredentials(null)} />; + } + return (
@@ -109,6 +136,79 @@ export function LoginPage() { ); } +function TotpStep({ credentials, onBack }: { credentials: FormValues; onBack: () => void }) { + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + resolver: zodResolver(totpSchema), + }); + + const onSubmit = async (data: TotpFormValues) => { + try { + await gqlClient.request(LOGIN_WITH_TOTP_MUTATION, { ...credentials, code: data.code }); + await queryClient.resetQueries(); + await navigate({ to: '/dashboard' }); + } catch (err: unknown) { + const msg = extractGqlError(err) ?? 'Invalid code. Please try again.'; + setError('root', { message: msg }); + } + }; + + return ( +
+
+
+

+ Two-factor authentication +

+

+ Enter the 6-digit code from your authenticator app. +

+
+ +
+
+ + + {errors.code &&

{errors.code.message}

} +
+ + {errors.root && ( +

+ {errors.root.message} +

+ )} + + +
+ + +
+
+ ); +} + function extractGqlError(err: unknown): string | null { if (typeof err === 'object' && err !== null && 'response' in err) { const r = (err as { response?: { errors?: Array<{ message?: string }> } }).response; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c850628..a1de5dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,12 @@ importers: nanoid: specifier: ^5.0.9 version: 5.1.16 + otplib: + specifier: ^13.4.1 + version: 13.4.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 zod: specifier: ^3.23.8 version: 3.25.76 @@ -114,6 +120,9 @@ importers: '@types/node': specifier: ^22.10.10 version: 22.20.1 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -137,7 +146,7 @@ importers: version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.5 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) apps/cli: dependencies: @@ -363,7 +372,7 @@ importers: version: 7.1.1(eslint@9.39.5(jiti@2.7.0)) jsdom: specifier: ^28.1.0 - version: 28.1.0 + version: 28.1.0(@noble/hashes@2.2.0) typescript: specifier: ^6.0.2 version: 6.0.3 @@ -375,7 +384,7 @@ importers: version: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.5 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -1584,6 +1593,10 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2261,6 +2274,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@otplib/core@13.4.1': + resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==} + + '@otplib/hotp@13.4.1': + resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==} + + '@otplib/plugin-base32-scure@13.4.1': + resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==} + + '@otplib/plugin-crypto-noble@13.4.1': + resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==} + + '@otplib/totp@13.4.1': + resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==} + + '@otplib/uri@13.4.1': + resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==} + '@oxc-parser/binding-android-arm-eabi@0.120.0': resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2700,6 +2731,9 @@ packages: cpu: [x64] os: [win32] + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + '@smithy/core@3.29.4': resolution: {integrity: sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==} engines: {node: '>=18.0.0'} @@ -3272,6 +3306,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3660,6 +3697,10 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001805: resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} @@ -3728,6 +3769,9 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3918,6 +3962,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -3976,6 +4024,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4301,6 +4352,10 @@ packages: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4990,6 +5045,10 @@ packages: enquirer: optional: true + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5260,6 +5319,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + otplib@13.4.1: + resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -5268,10 +5330,18 @@ packages: resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} engines: {node: ^20.19.0 || >=22.12.0} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5280,6 +5350,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5392,6 +5466,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5474,6 +5552,11 @@ packages: resolution: {integrity: sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==} engines: {node: '>= 16'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5596,6 +5679,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -5715,6 +5801,9 @@ packages: resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} engines: {node: '>=10'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -6318,6 +6407,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -6382,6 +6474,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6397,6 +6492,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6405,6 +6504,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -7063,7 +7166,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 '@fastify/accept-negotiator@2.0.1': {} @@ -7898,6 +8003,8 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8856,6 +8963,33 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@otplib/core@13.4.1': {} + + '@otplib/hotp@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@otplib/uri': 13.4.1 + + '@otplib/plugin-base32-scure@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@scure/base': 2.2.0 + + '@otplib/plugin-crypto-noble@13.4.1': + dependencies: + '@noble/hashes': 2.2.0 + '@otplib/core': 13.4.1 + + '@otplib/totp@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@otplib/hotp': 13.4.1 + '@otplib/uri': 13.4.1 + + '@otplib/uri@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@oxc-parser/binding-android-arm-eabi@0.120.0': optional: true @@ -9148,6 +9282,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@scure/base@2.2.0': {} + '@smithy/core@3.29.4': dependencies: '@smithy/types': 4.16.1 @@ -9842,6 +9978,10 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.1 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -10059,7 +10199,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -10396,6 +10536,8 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001805: {} capital-case@1.0.4: @@ -10480,6 +10622,12 @@ snapshots: cli-width@3.0.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10617,10 +10765,10 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -10652,6 +10800,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} decimal.js@10.6.0: {} @@ -10694,6 +10844,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -11195,6 +11347,11 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -11461,9 +11618,9 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -11767,16 +11924,16 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@28.1.0: + jsdom@28.1.0(@noble/hashes@2.2.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 '@bramus/specificity': 2.4.2 - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) cssstyle: 6.2.0 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -11788,7 +11945,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -11940,6 +12097,10 @@ snapshots: through: 2.3.8 wrap-ansi: 7.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -12208,6 +12369,15 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + otplib@13.4.1: + dependencies: + '@otplib/core': 13.4.1 + '@otplib/hotp': 13.4.1 + '@otplib/plugin-base32-scure': 13.4.1 + '@otplib/plugin-crypto-noble': 13.4.1 + '@otplib/totp': 13.4.1 + '@otplib/uri': 13.4.1 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -12242,10 +12412,18 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -12254,6 +12432,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} param-case@3.0.4: @@ -12377,6 +12557,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: @@ -12453,6 +12635,12 @@ snapshots: qlobber@8.0.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -12580,6 +12768,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-main-filename@2.0.0: {} + reselect@5.2.0: {} resolve-from@4.0.0: {} @@ -12729,6 +12919,8 @@ snapshots: seroval@1.5.5: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -13229,7 +13421,7 @@ snapshots: optionalDependencies: vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -13255,7 +13447,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.1 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 28.1.0 + jsdom: 28.1.0(@noble/hashes@2.2.0) transitivePeerDependencies: - msw @@ -13279,9 +13471,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -13323,6 +13515,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -13385,6 +13579,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13393,10 +13589,29 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.3: dependencies: cliui: 8.0.1 From c8997bff8977e485f0356cab2f9d8a652af3202b Mon Sep 17 00:00:00 2001 From: Jeff Man Date: Thu, 23 Jul 2026 14:33:17 +0100 Subject: [PATCH 2/3] style: fix prettier formatting after merge --- apps/api/src/http/schema/mutations/authMutations.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index 0402f6ed..fc0ab016 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -42,7 +42,11 @@ builder.mutationField('login', (t) => }, resolve: async (_root, args, ctx) => { const { authResolver } = ctx.diScope.cradle; - const result = await authResolver.login(args.email, args.password, deviceInfoFrom(ctx.request)); + const result = await authResolver.login( + args.email, + args.password, + deviceInfoFrom(ctx.request), + ); if (result.tokens) { setAuthCookies(ctx.reply, result.tokens.accessToken, result.tokens.refreshToken); } From 3cff722be86e7476dfbfc73e89af4039577179f2 Mon Sep 17 00:00:00 2001 From: Jeff Man Date: Thu, 23 Jul 2026 14:59:14 +0100 Subject: [PATCH 3/3] fix(auth): harden TOTP 2FA (JEF-27, JEF-28, JEF-29) - Encrypt TOTP secrets at rest with AES-256-GCM (scrypt-derived key) instead of storing them in plaintext; require password re-authentication before starting 2FA setup, matching the existing disable-2FA re-auth pattern. - Rate-limit TOTP code verification during login by email and IP to blunt brute-forcing of the 6-digit code. - Issue 10 single-use backup codes on successful 2FA enrollment (shown once), accept them as a login fallback when the TOTP code is wrong, and delete all backup codes when 2FA is disabled. --- apps/api/.env.example | 3 + .../migration.sql | 10 ++ apps/api/prisma/schema.prisma | 12 ++ .../auth/LoginWithTotpUseCase.test.ts | 149 ++++++++++++++++-- .../user/ConfirmTotpSetupUseCase.test.ts | 66 ++++++-- .../user/DisableTotpUseCase.test.ts | 23 ++- .../user/GenerateTotpSecretUseCase.test.ts | 41 ++++- .../api/src/__tests__/helpers/createTestDb.ts | 9 ++ apps/api/src/__tests__/helpers/mocks.ts | 27 ++++ .../__tests__/http/errors/AppError.test.ts | 16 ++ .../__tests__/http/errors/formatError.test.ts | 13 ++ .../auth/totpSecretCrypto.test.ts | 63 ++++++++ .../PrismaTotpBackupCodeRepository.test.ts | 85 ++++++++++ .../rateLimit/RateLimiter.test.ts | 49 ++++++ .../resolvers/AuthResolver.test.ts | 1 + .../resolvers/UserResolver.test.ts | 19 ++- apps/api/src/constants.ts | 18 +++ .../domain/totpBackupCode/TotpBackupCode.ts | 7 + apps/api/src/http/container.ts | 16 +- apps/api/src/http/errors/AppError.ts | 8 + apps/api/src/http/errors/formatError.ts | 1 + .../http/schema/mutations/userMutations.ts | 14 +- .../http/schema/types/ConfirmTotpSetupType.ts | 10 ++ .../infrastructure/auth/totpSecretCrypto.ts | 39 +++++ .../PrismaTotpBackupCodeRepository.ts | 55 +++++++ .../infrastructure/rateLimit/RateLimiter.ts | 36 +++++ .../resolvers/AuthResolver.ts | 7 +- .../resolvers/UserResolver.ts | 13 +- .../use-cases/auth/ILoginWithTotpUseCase.ts | 1 + .../use-cases/auth/LoginWithTotpUseCase.ts | 44 +++++- apps/api/src/use-cases/ports/IRateLimiter.ts | 4 + .../ports/ITotpBackupCodeRepository.ts | 8 + .../use-cases/user/ConfirmTotpSetupUseCase.ts | 28 +++- .../src/use-cases/user/DisableTotpUseCase.ts | 3 + .../user/GenerateTotpSecretUseCase.ts | 16 +- .../user/IConfirmTotpSetupUseCase.ts | 6 +- .../user/IGenerateTotpSecretUseCase.ts | 7 +- .../__tests__/components/AccountPage.test.tsx | 17 +- .../web/src/routes/_authenticated/account.tsx | 85 +++++++--- apps/web/src/routes/login.tsx | 8 +- 40 files changed, 951 insertions(+), 86 deletions(-) create mode 100644 apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql create mode 100644 apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts create mode 100644 apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts create mode 100644 apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts create mode 100644 apps/api/src/domain/totpBackupCode/TotpBackupCode.ts create mode 100644 apps/api/src/http/schema/types/ConfirmTotpSetupType.ts create mode 100644 apps/api/src/infrastructure/auth/totpSecretCrypto.ts create mode 100644 apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts create mode 100644 apps/api/src/infrastructure/rateLimit/RateLimiter.ts create mode 100644 apps/api/src/use-cases/ports/IRateLimiter.ts create mode 100644 apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 64c0a551..397b45bd 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,6 +9,9 @@ DATABASE_AUTH_TOKEN= JWT_SECRET=change-me-in-production JWT_REFRESH_SECRET=change-me-refresh-in-production +# Passphrase used to derive the key that encrypts TOTP secrets at rest +TOTP_ENCRYPTION_KEY=change-me-in-production + # Storage: 'local' for dev, 'gcs' for production STORAGE_PROVIDER=local diff --git a/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql b/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql new file mode 100644 index 00000000..438ecdc2 --- /dev/null +++ b/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE "TotpBackupCode" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL UNIQUE, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TotpBackupCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "TotpBackupCode_userId_idx" ON "TotpBackupCode"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6807d741..25786219 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -26,6 +26,18 @@ model User { emailVerificationTokens EmailVerificationToken[] sessions Session[] loginEvents LoginEvent[] + totpBackupCodes TotpBackupCode[] +} + +model TotpBackupCode { + id String @id + userId String + codeHash String @unique + usedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) } model LoginEvent { diff --git a/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts index 1d90f222..79f2e91a 100644 --- a/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts +++ b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'crypto'; import bcrypt from 'bcryptjs'; import { LoginWithTotpUseCase } from '@/use-cases/auth/LoginWithTotpUseCase.js'; import { createTotp } from '@/infrastructure/auth/totp.js'; -import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, + makeTotpBackupCode, + makeRateLimiter, +} from '@/__tests__/helpers/mocks.js'; vi.mock('bcryptjs', () => ({ default: { @@ -11,27 +18,53 @@ vi.mock('bcryptjs', () => ({ }, })); +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); + describe('LoginWithTotpUseCase', () => { beforeEach(() => { vi.clearAllMocks(); }); - const input = { email: 'test@example.com', password: 'password123', code: '123456' }; + const input = { + email: 'test@example.com', + password: 'password123', + code: '123456', + ipAddress: '127.0.0.1', + }; it('throws UNAUTHORIZED when the user does not exist', async () => { const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); - - const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); }); it('throws UNAUTHORIZED when the password is wrong', async () => { - const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const user = makeUser({ totpEnabled: true, totpSecret: 'encrypted:ABCD1234' }); const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); vi.mocked(bcrypt.compare).mockResolvedValue(false as never); - const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); }); @@ -39,20 +72,53 @@ describe('LoginWithTotpUseCase', () => { it('throws UNAUTHORIZED when 2FA is not enabled for the user', async () => { const user = makeUser({ totpEnabled: false, totpSecret: null }); const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - const err = await new LoginWithTotpUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); }); - it('throws UNAUTHORIZED for an invalid code', async () => { + it('throws RATE_LIMITED when too many verification attempts have been made', async () => { const secret = createTotp().generateSecret(); - const user = makeUser({ totpEnabled: true, totpSecret: secret }); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter({ consume: vi.fn().mockReturnValue(false) }); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - const err = await new LoginWithTotpUseCase({ userRepository }) + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('RATE_LIMITED'); + }); + + it('throws UNAUTHORIZED for an invalid code with no matching backup code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) .execute({ ...input, code: '000000' }) .catch((e) => e); @@ -62,15 +128,74 @@ describe('LoginWithTotpUseCase', () => { it('returns the user for valid credentials and a valid code', async () => { const secret = createTotp().generateSecret(); const validCode = await createTotp({ secret }).generate(); - const user = makeUser({ totpEnabled: true, totpSecret: secret }); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - const result = await new LoginWithTotpUseCase({ userRepository }).execute({ + const result = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }).execute({ ...input, code: validCode, }); expect(result).toEqual(user); }); + + it('accepts a valid, unused backup code as a fallback and marks it used', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const rawBackupCode = 'a1b2c3d4e5f60718'; + const codeHash = createHash('sha256').update(rawBackupCode).digest('hex'); + const backupCode = makeTotpBackupCode({ id: 'backup-1', userId: 'user-1', codeHash }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository({ + findByCodeHash: vi.fn().mockResolvedValue(backupCode), + }); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const result = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }).execute({ ...input, code: rawBackupCode }); + + expect(result).toEqual(user); + expect(totpBackupCodeRepository.markUsed).toHaveBeenCalledWith('backup-1'); + }); + + it('rejects an already-used backup code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const rawBackupCode = 'a1b2c3d4e5f60718'; + const codeHash = createHash('sha256').update(rawBackupCode).digest('hex'); + const backupCode = makeTotpBackupCode({ + id: 'backup-1', + userId: 'user-1', + codeHash, + usedAt: new Date('2024-01-01T00:00:00.000Z'), + }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository({ + findByCodeHash: vi.fn().mockResolvedValue(backupCode), + }); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute({ ...input, code: rawBackupCode }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(totpBackupCodeRepository.markUsed).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts index 015bcd14..c970a3ec 100644 --- a/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts @@ -1,17 +1,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConfirmTotpSetupUseCase } from '@/use-cases/user/ConfirmTotpSetupUseCase.js'; import { createTotp } from '@/infrastructure/auth/totp.js'; -import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, +} from '@/__tests__/helpers/mocks.js'; + +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); describe('ConfirmTotpSetupUseCase', () => { + const generateId = vi.fn(() => 'backup-code-id'); + beforeEach(() => { vi.clearAllMocks(); + generateId.mockReturnValue('backup-code-id'); }); it('throws NOT_FOUND when the user does not exist', async () => { const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - const err = await new ConfirmTotpSetupUseCase({ userRepository }) + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) .execute({ userId: 'missing', code: '123456' }) .catch((e) => e); @@ -19,10 +36,15 @@ describe('ConfirmTotpSetupUseCase', () => { }); it('throws CONFLICT when 2FA is already enabled', async () => { - const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const user = makeUser({ totpEnabled: true, totpSecret: 'encrypted:ABCD1234' }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - const err = await new ConfirmTotpSetupUseCase({ userRepository }) + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) .execute({ userId: 'user-1', code: '123456' }) .catch((e) => e); @@ -32,8 +54,13 @@ describe('ConfirmTotpSetupUseCase', () => { it('throws CONFLICT when no setup is in progress', async () => { const user = makeUser({ totpEnabled: false, totpSecret: null }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - const err = await new ConfirmTotpSetupUseCase({ userRepository }) + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) .execute({ userId: 'user-1', code: '123456' }) .catch((e) => e); @@ -41,31 +68,50 @@ describe('ConfirmTotpSetupUseCase', () => { }); it('throws UNAUTHORIZED for an invalid code', async () => { + const secret = createTotp().generateSecret(); const user = makeUser({ totpEnabled: false, - totpSecret: createTotp().generateSecret(), + totpSecret: `encrypted:${secret}`, }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - const err = await new ConfirmTotpSetupUseCase({ userRepository }) + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) .execute({ userId: 'user-1', code: '000000' }) .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); expect(userRepository.update).not.toHaveBeenCalled(); + expect(totpBackupCodeRepository.create).not.toHaveBeenCalled(); }); - it('enables 2FA when given a valid code', async () => { + it('enables 2FA and issues backup codes when given a valid code', async () => { const secret = createTotp().generateSecret(); const validCode = await createTotp({ secret }).generate(); - const user = makeUser({ id: 'user-1', totpEnabled: false, totpSecret: secret }); + const user = makeUser({ id: 'user-1', totpEnabled: false, totpSecret: `encrypted:${secret}` }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - await new ConfirmTotpSetupUseCase({ userRepository }).execute({ + const result = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }).execute({ userId: 'user-1', code: validCode, }); expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpEnabled: true }); + expect(result.backupCodes).toHaveLength(10); + expect(new Set(result.backupCodes).size).toBe(10); // all unique + result.backupCodes.forEach((code) => expect(code).toMatch(/^[0-9a-f]{16}$/)); + expect(totpBackupCodeRepository.create).toHaveBeenCalledTimes(10); + expect(totpBackupCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', id: 'backup-code-id' }), + ); }); }); diff --git a/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts index 89ef4e57..92cbb1c6 100644 --- a/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import bcrypt from 'bcryptjs'; import { DisableTotpUseCase } from '@/use-cases/user/DisableTotpUseCase.js'; -import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, +} from '@/__tests__/helpers/mocks.js'; vi.mock('bcryptjs', () => ({ default: { @@ -19,8 +23,11 @@ describe('DisableTotpUseCase', () => { it('throws NOT_FOUND when the user does not exist', async () => { const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); - const err = await new DisableTotpUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('NOT_FOUND'); }); @@ -28,24 +35,30 @@ describe('DisableTotpUseCase', () => { it('throws UNAUTHORIZED when the password is wrong', async () => { const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); vi.mocked(bcrypt.compare).mockResolvedValue(false as never); - const err = await new DisableTotpUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); expect(userRepository.update).not.toHaveBeenCalled(); + expect(totpBackupCodeRepository.deleteAllForUser).not.toHaveBeenCalled(); }); - it('clears totpSecret and disables totpEnabled on correct password', async () => { + it('clears totpSecret, disables totpEnabled, and deletes backup codes on correct password', async () => { const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: 'ABCD1234' }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - await new DisableTotpUseCase({ userRepository }).execute(input); + await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }).execute(input); expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpEnabled: false, totpSecret: null, }); + expect(totpBackupCodeRepository.deleteAllForUser).toHaveBeenCalledWith('user-1'); }); }); diff --git a/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts index 1dc58085..a5e46dc8 100644 --- a/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts @@ -1,17 +1,33 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; import { GenerateTotpSecretUseCase } from '@/use-cases/user/GenerateTotpSecretUseCase.js'; import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); + describe('GenerateTotpSecretUseCase', () => { + const input = { userId: 'user-1', password: 'secret123' }; + beforeEach(() => { vi.clearAllMocks(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); }); it('throws NOT_FOUND when the user does not exist', async () => { const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); const err = await new GenerateTotpSecretUseCase({ userRepository }) - .execute('missing') + .execute({ ...input, userId: 'missing' }) .catch((e) => e); expect((err as { code: string }).code).toBe('NOT_FOUND'); @@ -22,23 +38,38 @@ describe('GenerateTotpSecretUseCase', () => { const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); const err = await new GenerateTotpSecretUseCase({ userRepository }) - .execute('user-1') + .execute(input) .catch((e) => e); expect((err as { code: string }).code).toBe('CONFLICT'); expect(userRepository.update).not.toHaveBeenCalled(); }); - it('generates a secret, persists it (not yet enabled), and returns setup data', async () => { + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('generates a secret, persists it encrypted (not yet enabled), and returns setup data', async () => { const user = makeUser({ id: 'user-1', email: 'test@example.com', totpEnabled: false }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); - const result = await new GenerateTotpSecretUseCase({ userRepository }).execute('user-1'); + const result = await new GenerateTotpSecretUseCase({ userRepository }).execute(input); expect(result.secret).toMatch(/^[A-Z2-7]+$/); // base32 expect(result.otpauthUrl).toMatch(/^otpauth:\/\/totp\//); expect(result.otpauthUrl).toContain(encodeURIComponent(user.email)); expect(result.qrCodeDataUrl).toMatch(/^data:image\/png;base64,/); - expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpSecret: result.secret }); + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + totpSecret: `encrypted:${result.secret}`, + }); }); }); diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index 90534cc8..b2a247b1 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -151,6 +151,15 @@ const SCHEMA_STATEMENTS = [ FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE )`, `CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId")`, + `CREATE TABLE "TotpBackupCode" ( + "id" TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL UNIQUE, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE + )`, + `CREATE INDEX "TotpBackupCode_userId_idx" ON "TotpBackupCode"("userId")`, ]; export interface TestDb { diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index b37bc5f3..e81a2821 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -22,6 +22,9 @@ import type { Note } from '@/domain/note/Note.js'; import type { InterviewRound } from '@/domain/interviewRound/InterviewRound.js'; import type { Contact } from '@/domain/contact/Contact.js'; import type { LoginEvent } from '@/domain/loginEvent/LoginEvent.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; export const makeUserRepository = (overrides?: Partial): IUserRepository => ({ findById: vi.fn(), @@ -169,6 +172,30 @@ export const makeEmailVerificationToken = ( ...overrides, }); +export const makeTotpBackupCodeRepository = ( + overrides?: Partial, +): ITotpBackupCodeRepository => ({ + create: vi.fn(), + findByCodeHash: vi.fn().mockResolvedValue(null), + markUsed: vi.fn().mockResolvedValue(undefined), + deleteAllForUser: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +export const makeTotpBackupCode = (overrides?: Partial): TotpBackupCode => ({ + id: 'backup-code-1', + userId: 'user-1', + codeHash: 'hashed-backup-code', + usedAt: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + +export const makeRateLimiter = (overrides?: Partial): IRateLimiter => ({ + consume: vi.fn().mockReturnValue(true), + ...overrides, +}); + // Minimal FastifyInstance stub for AuthResolver (only jwt is used) export const makeFastifyJwt = (): { jwt: { sign: ReturnType; verify: ReturnType }; diff --git a/apps/api/src/__tests__/http/errors/AppError.test.ts b/apps/api/src/__tests__/http/errors/AppError.test.ts index 7a214295..4793a174 100644 --- a/apps/api/src/__tests__/http/errors/AppError.test.ts +++ b/apps/api/src/__tests__/http/errors/AppError.test.ts @@ -6,6 +6,7 @@ import { UnauthorizedError, ForbiddenError, ValidationError, + RateLimitedError, fromCodedError, } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -86,6 +87,20 @@ describe('ValidationError', () => { }); }); +describe('RateLimitedError', () => { + it('defaults to "Too many requests" with a 429 status and RATE_LIMITED code', () => { + const err = new RateLimitedError(); + + expect(err.message).toBe('Too many requests'); + expect(err.statusCode).toBe(429); + expect(err.code).toBe(ERROR_CODES.RATE_LIMITED); + }); + + it('uses the given message when provided', () => { + expect(new RateLimitedError('Slow down').message).toBe('Slow down'); + }); +}); + describe('fromCodedError', () => { it('returns the same instance when already an AppError', () => { const original = new ConflictError('Already exists'); @@ -97,6 +112,7 @@ describe('fromCodedError', () => { [ERROR_CODES.UNAUTHORIZED, UnauthorizedError], [ERROR_CODES.FORBIDDEN, ForbiddenError], [ERROR_CODES.VALIDATION, ValidationError], + [ERROR_CODES.RATE_LIMITED, RateLimitedError], ])('maps a %s-coded Error to a %s, preserving the message', (code, ExpectedClass) => { const raw = Object.assign(new Error('Domain-specific message'), { code }); diff --git a/apps/api/src/__tests__/http/errors/formatError.test.ts b/apps/api/src/__tests__/http/errors/formatError.test.ts index 0a879dbc..fa8758c5 100644 --- a/apps/api/src/__tests__/http/errors/formatError.test.ts +++ b/apps/api/src/__tests__/http/errors/formatError.test.ts @@ -80,6 +80,19 @@ describe('formatError', () => { expect(result.extensions.statusCode).toBe(500); }); + it('maps a RATE_LIMITED-coded error to a client-safe GraphQLError without logging', () => { + const original = Object.assign(new Error('Too many attempts'), { + code: ERROR_CODES.RATE_LIMITED, + }); + const wrapper = new GraphQLError('wrapped', { originalError: original }); + + const result = formatError(wrapper); + + expect(result.extensions.code).toBe(ERROR_CODES.RATE_LIMITED); + expect(result.extensions.statusCode).toBe(429); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + it('logs for an error whose code is not in the expected client-facing set', () => { const original = Object.assign(new Error('Unexpected'), { code: 'SOME_UNKNOWN_CODE' }); const wrapper = new GraphQLError('wrapped', { originalError: original }); diff --git a/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts b/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts new file mode 100644 index 00000000..cc5c2f19 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ENV } from '@/constants.js'; +import { encryptTotpSecret, decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; + +describe('totpSecretCrypto', () => { + const originalKey = process.env[ENV.TOTP_ENCRYPTION_KEY]; + + beforeEach(() => { + process.env[ENV.TOTP_ENCRYPTION_KEY] = 'test-encryption-key-not-for-production'; + }); + + afterEach(() => { + if (originalKey === undefined) { + delete process.env[ENV.TOTP_ENCRYPTION_KEY]; + } else { + process.env[ENV.TOTP_ENCRYPTION_KEY] = originalKey; + } + }); + + it('decrypts back to the original plaintext', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const encrypted = encryptTotpSecret(secret); + const decrypted = decryptTotpSecret(encrypted); + + expect(decrypted).toBe(secret); + }); + + it('does not store the plaintext secret in the ciphertext', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const encrypted = encryptTotpSecret(secret); + + expect(encrypted).not.toContain(secret); + }); + + it('produces different ciphertext for the same plaintext on each call (random IV)', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const first = encryptTotpSecret(secret); + const second = encryptTotpSecret(secret); + + expect(first).not.toBe(second); + expect(decryptTotpSecret(first)).toBe(secret); + expect(decryptTotpSecret(second)).toBe(secret); + }); + + it('throws when the ciphertext has been tampered with', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const encrypted = encryptTotpSecret(secret); + const tamperedBytes = Buffer.from(encrypted, 'base64'); + tamperedBytes[tamperedBytes.length - 1] ^= 0xff; + const tampered = tamperedBytes.toString('base64'); + + expect(() => decryptTotpSecret(tampered)).toThrow(); + }); + + it('throws a clear error when TOTP_ENCRYPTION_KEY is not configured', () => { + delete process.env[ENV.TOTP_ENCRYPTION_KEY]; + + expect(() => encryptTotpSecret('JBSWY3DPEHPK3PXP')).toThrow(/TOTP_ENCRYPTION_KEY/); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts new file mode 100644 index 00000000..0248bfb2 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaTotpBackupCodeRepository } from '@/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.js'; +import { createTestDb, type TestDb } from '@/__tests__/helpers/createTestDb.js'; + +describe('PrismaTotpBackupCodeRepository', () => { + let db: TestDb; + let repo: PrismaTotpBackupCodeRepository; + + beforeAll(async () => { + db = await createTestDb(); + repo = new PrismaTotpBackupCodeRepository({ prisma: db.prisma }); + }); + + afterAll(() => db.cleanup()); + + beforeEach(async () => { + await db.prisma.totpBackupCode.deleteMany(); + await db.prisma.user.deleteMany(); + await db.prisma.user.create({ + data: { id: 'user-1', email: 'a@b.com', passwordHash: 'hashed' }, + }); + }); + + describe('create', () => { + it('persists a backup code and returns the entity', async () => { + const code = await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + expect(code.id).toBe('code-1'); + expect(code.userId).toBe('user-1'); + expect(code.codeHash).toBe('hash-1'); + expect(code.usedAt).toBeNull(); + expect(code.createdAt).toBeInstanceOf(Date); + }); + }); + + describe('findByCodeHash', () => { + it('returns the code when it exists', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + const found = await repo.findByCodeHash('hash-1'); + expect(found?.id).toBe('code-1'); + }); + + it('returns null when no code matches', async () => { + const found = await repo.findByCodeHash('missing-hash'); + expect(found).toBeNull(); + }); + }); + + describe('markUsed', () => { + it('sets usedAt on the code', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + await repo.markUsed('code-1'); + + const found = await repo.findByCodeHash('hash-1'); + expect(found?.usedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteAllForUser', () => { + it('removes all codes for the given user', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + await repo.create({ id: 'code-2', userId: 'user-1', codeHash: 'hash-2' }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByCodeHash('hash-1')).toBeNull(); + expect(await repo.findByCodeHash('hash-2')).toBeNull(); + }); + + it('does not affect codes belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + await repo.create({ id: 'code-2', userId: 'user-2', codeHash: 'hash-2' }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByCodeHash('hash-1')).toBeNull(); + expect(await repo.findByCodeHash('hash-2')).not.toBeNull(); + }); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts b/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts new file mode 100644 index 00000000..bf1eff9d --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; + +describe('RateLimiter', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows requests up to the configured maximum within the window', () => { + const limiter = new RateLimiter(3, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + }); + + it('rejects requests once the maximum is exceeded within the window', () => { + const limiter = new RateLimiter(2, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + }); + + it('tracks separate keys independently', () => { + const limiter = new RateLimiter(1, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-2')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + expect(limiter.consume('key-2')).toBe(false); + }); + + it('resets the count once the window has elapsed', () => { + const limiter = new RateLimiter(1, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + + vi.advanceTimersByTime(60_001); + + expect(limiter.consume('key-1')).toBe(true); + }); +}); diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts index 98041168..f2c84e0a 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts @@ -171,6 +171,7 @@ describe('AuthResolver', () => { email: 'test@example.com', password: 'password123', code: '123456', + ipAddress: device.ipAddress, }); expect(createSessionUseCase.execute).toHaveBeenCalledWith({ userId: user.id, ...device }); expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email, 'session-1'); diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts index 313d0e63..f9362f08 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts @@ -141,23 +141,32 @@ describe('UserResolver', () => { }), }); - const result = await new UserResolver(deps).beginTotpSetup('user-1'); + const result = await new UserResolver(deps).beginTotpSetup('user-1', 'secret123'); - expect(deps.generateTotpSecretUseCase.execute).toHaveBeenCalledWith('user-1'); + expect(deps.generateTotpSecretUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + password: 'secret123', + }); expect(result).toEqual(setup); }); }); describe('confirmTotpSetup', () => { - it('delegates to confirmTotpSetupUseCase with the correct arguments', async () => { - const deps = makeDeps(); + it('delegates to confirmTotpSetupUseCase and returns the result', async () => { + const output = { backupCodes: ['aaaa1111bbbb2222'] }; + const deps = makeDeps({ + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockResolvedValue(output), + }), + }); - await new UserResolver(deps).confirmTotpSetup('user-1', '123456'); + const result = await new UserResolver(deps).confirmTotpSetup('user-1', '123456'); expect(deps.confirmTotpSetupUseCase.execute).toHaveBeenCalledWith({ userId: 'user-1', code: '123456', }); + expect(result).toEqual(output); }); it('propagates errors from the use case', async () => { diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 96435bb4..aaccd529 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -16,6 +16,7 @@ export const ERROR_CODES = { NOT_FOUND: 'NOT_FOUND', CONFLICT: 'CONFLICT', VALIDATION: 'VALIDATION', + RATE_LIMITED: 'RATE_LIMITED', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; @@ -45,6 +46,7 @@ export const ENV = { AXIOM_TOKEN: 'AXIOM_TOKEN', AXIOM_DATASET: 'AXIOM_DATASET', AXIOM_METRICS_DATASET: 'AXIOM_METRICS_DATASET', + TOTP_ENCRYPTION_KEY: 'TOTP_ENCRYPTION_KEY', } as const; /** `NODE_ENV` values. */ @@ -102,6 +104,22 @@ export const TOTP_CONFIG = { EPOCH_TOLERANCE_S: 30, } as const; +/** Backup/recovery codes issued alongside TOTP enrollment. */ +export const TOTP_BACKUP_CODES = { + /** How many single-use codes to generate at enrollment. */ + COUNT: 10, + /** Number of random bytes hex-encoded into each code (16 hex chars). */ + RANDOM_BYTES: 8, +} as const; + +/** Rate limits for auth endpoints prone to abuse (in-process, fixed-window). */ +export const RATE_LIMIT = { + TOTP_VERIFICATION: { + MAX_ATTEMPTS: 5, + WINDOW_MS: 15 * 60 * 1000, // 15 minutes + }, +} as const; + /** Email-verification token settings. */ export const EMAIL_VERIFICATION_TOKEN = { /** Number of random bytes hex-encoded into the token body. */ diff --git a/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts b/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts new file mode 100644 index 00000000..e3839bb1 --- /dev/null +++ b/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts @@ -0,0 +1,7 @@ +export interface TotpBackupCode { + id: string; + userId: string; + codeHash: string; + usedAt: Date | null; + createdAt: Date; +} diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index b44ad387..32c6c013 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -19,6 +19,9 @@ import { PrismaContactRepository } from '@/infrastructure/db/repositories/Prisma import { PrismaLoginEventRepository } from '@/infrastructure/db/repositories/PrismaLoginEventRepository.js'; import { PrismaSessionRepository } from '@/infrastructure/db/repositories/PrismaSessionRepository.js'; import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; +import { PrismaTotpBackupCodeRepository } from '@/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.js'; +import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; import { LocalStorageProvider } from '@/infrastructure/storage/LocalStorageProvider.js'; import { GCSStorageProvider } from '@/infrastructure/storage/GCSStorageProvider.js'; @@ -107,7 +110,7 @@ import { RevokeSessionUseCase } from '@/use-cases/sessions/RevokeSessionUseCase. import { RevokeOtherSessionsUseCase } from '@/use-cases/sessions/RevokeOtherSessionsUseCase.js'; import type { FastifyInstance } from 'fastify'; -import { ENV, LLM_PROVIDER, STORAGE_PROVIDER } from '@/constants.js'; +import { ENV, LLM_PROVIDER, STORAGE_PROVIDER, RATE_LIMIT } from '@/constants.js'; import type { ILLMProvider } from '@/use-cases/ports/ILLMProvider.js'; // Augment the @fastify/awilix Cradle interface so diContainer and diScope are fully typed @@ -139,6 +142,8 @@ declare module '@fastify/awilix' { loginEventRepository: PrismaLoginEventRepository; sessionRepository: PrismaSessionRepository; emailVerificationTokenRepository: PrismaEmailVerificationTokenRepository; + totpBackupCodeRepository: PrismaTotpBackupCodeRepository; + totpRateLimiter: IRateLimiter; applicationMapper: ApplicationMapper; apiTokenMapper: ApiTokenMapper; @@ -276,6 +281,15 @@ export function buildContainer(fastify: FastifyInstance): void { emailVerificationTokenRepository: asClass(PrismaEmailVerificationTokenRepository, { lifetime: Lifetime.SINGLETON, }), + totpBackupCodeRepository: asClass(PrismaTotpBackupCodeRepository, { + lifetime: Lifetime.SINGLETON, + }), + totpRateLimiter: asValue( + new RateLimiter( + RATE_LIMIT.TOTP_VERIFICATION.MAX_ATTEMPTS, + RATE_LIMIT.TOTP_VERIFICATION.WINDOW_MS, + ), + ), // Mappers applicationMapper: asClass(ApplicationMapper, { lifetime: Lifetime.SINGLETON }), diff --git a/apps/api/src/http/errors/AppError.ts b/apps/api/src/http/errors/AppError.ts index b035587b..623fd3db 100644 --- a/apps/api/src/http/errors/AppError.ts +++ b/apps/api/src/http/errors/AppError.ts @@ -41,6 +41,12 @@ export class ValidationError extends AppError { } } +export class RateLimitedError extends AppError { + constructor(message = 'Too many requests') { + super(message, 429, ERROR_CODES.RATE_LIMITED); + } +} + export function fromCodedError(err: unknown): AppError { if (err instanceof AppError) return err; if (err instanceof Error) { @@ -56,6 +62,8 @@ export function fromCodedError(err: unknown): AppError { return new ForbiddenError(err.message); case ERROR_CODES.VALIDATION: return new ValidationError(err.message); + case ERROR_CODES.RATE_LIMITED: + return new RateLimitedError(err.message); } } return new AppError('Internal server error', 500, ERROR_CODES.INTERNAL_ERROR); diff --git a/apps/api/src/http/errors/formatError.ts b/apps/api/src/http/errors/formatError.ts index 738b50ed..bde782d3 100644 --- a/apps/api/src/http/errors/formatError.ts +++ b/apps/api/src/http/errors/formatError.ts @@ -8,6 +8,7 @@ const EXPECTED_ERROR_CODES: string[] = [ ERROR_CODES.CONFLICT, ERROR_CODES.UNAUTHORIZED, ERROR_CODES.FORBIDDEN, + ERROR_CODES.RATE_LIMITED, ]; export function formatError(err: GraphQLError): GraphQLError { diff --git a/apps/api/src/http/schema/mutations/userMutations.ts b/apps/api/src/http/schema/mutations/userMutations.ts index abdb51ec..d12d99f7 100644 --- a/apps/api/src/http/schema/mutations/userMutations.ts +++ b/apps/api/src/http/schema/mutations/userMutations.ts @@ -2,6 +2,7 @@ import { GraphQLError } from 'graphql'; import { builder } from '@/http/schema/builder.js'; import { clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; import { TotpSetupRef } from '@/http/schema/types/TotpSetupType.js'; +import { ConfirmTotpSetupResultRef } from '@/http/schema/types/ConfirmTotpSetupType.js'; import { ImportSummaryRef } from '@/http/schema/types/ImportSummaryType.js'; import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -90,12 +91,15 @@ builder.mutationField('deleteAccount', (t) => builder.mutationField('beginTotpSetup', (t) => t.field({ type: TotpSetupRef, - resolve: async (_root, _args, ctx) => { + args: { + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { if (!ctx.user) throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); const { userResolver } = ctx.diScope.cradle; try { - return await userResolver.beginTotpSetup(ctx.user.sub); + return await userResolver.beginTotpSetup(ctx.user.sub, args.password); } catch (err) { throw fromCodedError(err); } @@ -104,7 +108,8 @@ builder.mutationField('beginTotpSetup', (t) => ); builder.mutationField('confirmTotpSetup', (t) => - t.boolean({ + t.field({ + type: ConfirmTotpSetupResultRef, args: { code: t.arg.string({ required: true }), }, @@ -113,8 +118,7 @@ builder.mutationField('confirmTotpSetup', (t) => throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); const { userResolver } = ctx.diScope.cradle; try { - await userResolver.confirmTotpSetup(ctx.user.sub, args.code); - return true; + return await userResolver.confirmTotpSetup(ctx.user.sub, args.code); } catch (err) { throw fromCodedError(err); } diff --git a/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts b/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts new file mode 100644 index 00000000..7bb55984 --- /dev/null +++ b/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts @@ -0,0 +1,10 @@ +import { builder } from '@/http/schema/builder.js'; +import type { ConfirmTotpSetupOutput } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; + +export const ConfirmTotpSetupResultRef = + builder.objectRef('ConfirmTotpSetupResult'); +ConfirmTotpSetupResultRef.implement({ + fields: (t) => ({ + backupCodes: t.exposeStringList('backupCodes'), + }), +}); diff --git a/apps/api/src/infrastructure/auth/totpSecretCrypto.ts b/apps/api/src/infrastructure/auth/totpSecretCrypto.ts new file mode 100644 index 00000000..952c9c35 --- /dev/null +++ b/apps/api/src/infrastructure/auth/totpSecretCrypto.ts @@ -0,0 +1,39 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; +import { ENV } from '@/constants.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const KEY_LENGTH = 32; +const SCRYPT_SALT = 'job-finder-totp-secret'; + +function getKey(): Buffer { + const passphrase = process.env[ENV.TOTP_ENCRYPTION_KEY]; + if (!passphrase) { + throw new Error( + `${ENV.TOTP_ENCRYPTION_KEY} is not configured — required to encrypt/decrypt TOTP secrets`, + ); + } + return scryptSync(passphrase, SCRYPT_SALT, KEY_LENGTH); +} + +/** Encrypts a raw base32 TOTP secret for storage at rest. */ +export function encryptTotpSecret(plaintext: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, getKey(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); +} + +/** Decrypts a TOTP secret previously produced by {@link encryptTotpSecret}. */ +export function decryptTotpSecret(ciphertext: string): string { + const data = Buffer.from(ciphertext, 'base64'); + const iv = data.subarray(0, IV_BYTES); + const authTag = data.subarray(IV_BYTES, IV_BYTES + 16); + const encrypted = data.subarray(IV_BYTES + 16); + + const decipher = createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); +} diff --git a/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts new file mode 100644 index 00000000..e18c9b56 --- /dev/null +++ b/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts @@ -0,0 +1,55 @@ +import type { PrismaClient } from '@prisma/client'; +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import { getClient } from '../transactionContext.js'; + +type PrismaTotpBackupCode = { + id: string; + userId: string; + codeHash: string; + usedAt: Date | null; + createdAt: Date; +}; + +export class PrismaTotpBackupCodeRepository implements ITotpBackupCodeRepository { + private readonly prisma: PrismaClient; + + constructor({ prisma }: { prisma: PrismaClient }) { + this.prisma = prisma; + } + + private get db(): PrismaClient { + return getClient(this.prisma); + } + + async create(data: { id: string; userId: string; codeHash: string }): Promise { + const row = await this.db.totpBackupCode.create({ data }); + return this.toEntity(row); + } + + async findByCodeHash(codeHash: string): Promise { + const row = await this.db.totpBackupCode.findUnique({ where: { codeHash } }); + return row ? this.toEntity(row) : null; + } + + async markUsed(id: string): Promise { + await this.db.totpBackupCode.update({ + where: { id }, + data: { usedAt: new Date() }, + }); + } + + async deleteAllForUser(userId: string): Promise { + await this.db.totpBackupCode.deleteMany({ where: { userId } }); + } + + private toEntity(row: PrismaTotpBackupCode): TotpBackupCode { + return { + id: row.id, + userId: row.userId, + codeHash: row.codeHash, + usedAt: row.usedAt, + createdAt: row.createdAt, + }; + } +} diff --git a/apps/api/src/infrastructure/rateLimit/RateLimiter.ts b/apps/api/src/infrastructure/rateLimit/RateLimiter.ts new file mode 100644 index 00000000..12b5a662 --- /dev/null +++ b/apps/api/src/infrastructure/rateLimit/RateLimiter.ts @@ -0,0 +1,36 @@ +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; + +interface Bucket { + count: number; + resetAt: number; +} + +/** + * In-process, fixed-window rate limiter. Suitable for a single Fastify + * instance (no shared store) — buckets live in memory and are never + * persisted, so limits reset on process restart and aren't shared across + * horizontally-scaled instances. + */ +export class RateLimiter implements IRateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly maxAttempts: number, + private readonly windowMs: number, + ) {} + + consume(key: string): boolean { + const now = Date.now(); + const bucket = this.buckets.get(key); + + if (!bucket || now > bucket.resetAt) { + this.buckets.set(key, { count: 1, resetAt: now + this.windowMs }); + return true; + } + + if (bucket.count >= this.maxAttempts) return false; + + bucket.count += 1; + return true; + } +} diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index d811eac6..d1a25add 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -59,7 +59,12 @@ export class AuthResolver { code: string, device: DeviceInfo, ): Promise { - const user = await this.deps.loginWithTotpUseCase.execute({ email, password, code }); + const user = await this.deps.loginWithTotpUseCase.execute({ + email, + password, + code, + ipAddress: device.ipAddress, + }); const session = await this.deps.createSessionUseCase.execute({ userId: user.id, ...device }); return this.deps.tokenService.sign(user.id, user.email, session.id); } diff --git a/apps/api/src/interface-adapters/resolvers/UserResolver.ts b/apps/api/src/interface-adapters/resolvers/UserResolver.ts index 99438f6b..4340ded8 100644 --- a/apps/api/src/interface-adapters/resolvers/UserResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/UserResolver.ts @@ -9,7 +9,10 @@ import type { IGenerateTotpSecretUseCase, TotpSetup, } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; -import type { IConfirmTotpSetupUseCase } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; +import type { + IConfirmTotpSetupUseCase, + ConfirmTotpSetupOutput, +} from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; import type { IDisableTotpUseCase } from '@/use-cases/user/IDisableTotpUseCase.js'; import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; import type { @@ -64,12 +67,12 @@ export class UserResolver { return this.deps.exportUserDataUseCase.execute(userId); } - async beginTotpSetup(userId: string): Promise { - return this.deps.generateTotpSecretUseCase.execute(userId); + async beginTotpSetup(userId: string, password: string): Promise { + return this.deps.generateTotpSecretUseCase.execute({ userId, password }); } - async confirmTotpSetup(userId: string, code: string): Promise { - await this.deps.confirmTotpSetupUseCase.execute({ userId, code }); + async confirmTotpSetup(userId: string, code: string): Promise { + return this.deps.confirmTotpSetupUseCase.execute({ userId, code }); } async disableTotp(userId: string, password: string): Promise { diff --git a/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts index 2853c990..f116a150 100644 --- a/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts +++ b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts @@ -4,6 +4,7 @@ export interface LoginWithTotpInput { email: string; password: string; code: string; + ipAddress: string | null; } export interface ILoginWithTotpUseCase { diff --git a/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts index 68bc1209..f59b7d1e 100644 --- a/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts +++ b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts @@ -1,7 +1,11 @@ import bcrypt from 'bcryptjs'; +import { createHash } from 'crypto'; import type { User } from '@/domain/user/User.js'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; import { createTotp } from '@/infrastructure/auth/totp.js'; +import { decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; import { ERROR_CODES, TOTP_CONFIG } from '@/constants.js'; import type { ILoginWithTotpUseCase, @@ -10,6 +14,8 @@ import type { interface Deps { userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; + totpRateLimiter: IRateLimiter; } export class LoginWithTotpUseCase implements ILoginWithTotpUseCase { @@ -30,15 +36,41 @@ export class LoginWithTotpUseCase implements ILoginWithTotpUseCase { throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); } - const result = await createTotp({ secret: user.totpSecret }).verify(input.code, { - epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, - }); - if (!result.valid) { - throw Object.assign(new Error('Invalid verification code'), { - code: ERROR_CODES.UNAUTHORIZED, + const allowedByEmail = this.deps.totpRateLimiter.consume( + `totp:email:${input.email.toLowerCase()}`, + ); + const allowedByIp = input.ipAddress + ? this.deps.totpRateLimiter.consume(`totp:ip:${input.ipAddress}`) + : true; + if (!allowedByEmail || !allowedByIp) { + throw Object.assign(new Error('Too many verification attempts. Please try again later.'), { + code: ERROR_CODES.RATE_LIMITED, }); } + // TOTP codes are always 6 digits — anything else can only be a backup code. + const isTotpFormat = /^\d{6}$/.test(input.code); + const secret = decryptTotpSecret(user.totpSecret); + const validTotp = isTotpFormat + ? ( + await createTotp({ secret }).verify(input.code, { + epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, + }) + ).valid + : false; + + if (!validTotp) { + const codeHash = createHash('sha256').update(input.code).digest('hex'); + const backupCode = await this.deps.totpBackupCodeRepository.findByCodeHash(codeHash); + const validBackupCode = backupCode && backupCode.userId === user.id && !backupCode.usedAt; + if (!validBackupCode) { + throw Object.assign(new Error('Invalid verification code'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + await this.deps.totpBackupCodeRepository.markUsed(backupCode.id); + } + return user; } } diff --git a/apps/api/src/use-cases/ports/IRateLimiter.ts b/apps/api/src/use-cases/ports/IRateLimiter.ts new file mode 100644 index 00000000..f9bfc2b3 --- /dev/null +++ b/apps/api/src/use-cases/ports/IRateLimiter.ts @@ -0,0 +1,4 @@ +export interface IRateLimiter { + /** Returns true if the request identified by `key` is allowed, false if it should be rejected. */ + consume(key: string): boolean; +} diff --git a/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts b/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts new file mode 100644 index 00000000..efbeb684 --- /dev/null +++ b/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts @@ -0,0 +1,8 @@ +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; + +export interface ITotpBackupCodeRepository { + create(data: { id: string; userId: string; codeHash: string }): Promise; + findByCodeHash(codeHash: string): Promise; + markUsed(id: string): Promise; + deleteAllForUser(userId: string): Promise; +} diff --git a/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts index 6c80d2af..15e62d1d 100644 --- a/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts +++ b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts @@ -1,19 +1,25 @@ +import { createHash, randomBytes } from 'crypto'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; import { createTotp } from '@/infrastructure/auth/totp.js'; -import { ERROR_CODES, TOTP_CONFIG } from '@/constants.js'; +import { decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; +import { ERROR_CODES, TOTP_CONFIG, TOTP_BACKUP_CODES } from '@/constants.js'; import type { IConfirmTotpSetupUseCase, ConfirmTotpSetupInput, + ConfirmTotpSetupOutput, } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; interface Deps { userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; + generateId: () => string; } export class ConfirmTotpSetupUseCase implements IConfirmTotpSetupUseCase { constructor(private readonly deps: Deps) {} - async execute(input: ConfirmTotpSetupInput): Promise { + async execute(input: ConfirmTotpSetupInput): Promise { const user = await this.deps.userRepository.findById(input.userId); if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); @@ -28,7 +34,8 @@ export class ConfirmTotpSetupUseCase implements IConfirmTotpSetupUseCase { }); } - const result = await createTotp({ secret: user.totpSecret }).verify(input.code, { + const secret = decryptTotpSecret(user.totpSecret); + const result = await createTotp({ secret }).verify(input.code, { epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, }); if (!result.valid) { @@ -38,5 +45,20 @@ export class ConfirmTotpSetupUseCase implements IConfirmTotpSetupUseCase { } await this.deps.userRepository.update(input.userId, { totpEnabled: true }); + + const backupCodes = Array.from({ length: TOTP_BACKUP_CODES.COUNT }, () => + randomBytes(TOTP_BACKUP_CODES.RANDOM_BYTES).toString('hex'), + ); + await Promise.all( + backupCodes.map((code) => + this.deps.totpBackupCodeRepository.create({ + id: this.deps.generateId(), + userId: input.userId, + codeHash: createHash('sha256').update(code).digest('hex'), + }), + ), + ); + + return { backupCodes }; } } diff --git a/apps/api/src/use-cases/user/DisableTotpUseCase.ts b/apps/api/src/use-cases/user/DisableTotpUseCase.ts index 6efed22e..edeb24a7 100644 --- a/apps/api/src/use-cases/user/DisableTotpUseCase.ts +++ b/apps/api/src/use-cases/user/DisableTotpUseCase.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; import { ERROR_CODES } from '@/constants.js'; import type { IDisableTotpUseCase, @@ -8,6 +9,7 @@ import type { interface Deps { userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; } export class DisableTotpUseCase implements IDisableTotpUseCase { @@ -25,5 +27,6 @@ export class DisableTotpUseCase implements IDisableTotpUseCase { totpEnabled: false, totpSecret: null, }); + await this.deps.totpBackupCodeRepository.deleteAllForUser(input.userId); } } diff --git a/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts index d5bc5061..405488e5 100644 --- a/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts +++ b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts @@ -1,9 +1,12 @@ import QRCode from 'qrcode'; +import bcrypt from 'bcryptjs'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; import { createTotp } from '@/infrastructure/auth/totp.js'; +import { encryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; import { ERROR_CODES } from '@/constants.js'; import type { IGenerateTotpSecretUseCase, + GenerateTotpSecretInput, TotpSetup, } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; @@ -14,8 +17,8 @@ interface Deps { export class GenerateTotpSecretUseCase implements IGenerateTotpSecretUseCase { constructor(private readonly deps: Deps) {} - async execute(userId: string): Promise { - const user = await this.deps.userRepository.findById(userId); + async execute(input: GenerateTotpSecretInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); if (user.totpEnabled) { @@ -24,11 +27,18 @@ export class GenerateTotpSecretUseCase implements IGenerateTotpSecretUseCase { }); } + const validPassword = await bcrypt.compare(input.password, user.passwordHash); + if (!validPassword) { + throw Object.assign(new Error('Invalid password'), { code: ERROR_CODES.UNAUTHORIZED }); + } + const secret = createTotp().generateSecret(); const otpauthUrl = createTotp({ secret, label: user.email }).toURI(); const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl); - await this.deps.userRepository.update(userId, { totpSecret: secret }); + await this.deps.userRepository.update(input.userId, { + totpSecret: encryptTotpSecret(secret), + }); return { secret, otpauthUrl, qrCodeDataUrl }; } diff --git a/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts index b550a27e..bba5efcf 100644 --- a/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts +++ b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts @@ -3,6 +3,10 @@ export interface ConfirmTotpSetupInput { code: string; } +export interface ConfirmTotpSetupOutput { + backupCodes: string[]; +} + export interface IConfirmTotpSetupUseCase { - execute(input: ConfirmTotpSetupInput): Promise; + execute(input: ConfirmTotpSetupInput): Promise; } diff --git a/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts index 4dbcdc23..f7815fd6 100644 --- a/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts +++ b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts @@ -4,6 +4,11 @@ export interface TotpSetup { qrCodeDataUrl: string; } +export interface GenerateTotpSecretInput { + userId: string; + password: string; +} + export interface IGenerateTotpSecretUseCase { - execute(userId: string): Promise; + execute(input: GenerateTotpSecretInput): Promise; } diff --git a/apps/web/src/__tests__/components/AccountPage.test.tsx b/apps/web/src/__tests__/components/AccountPage.test.tsx index 51455673..aa24a734 100644 --- a/apps/web/src/__tests__/components/AccountPage.test.tsx +++ b/apps/web/src/__tests__/components/AccountPage.test.tsx @@ -327,8 +327,17 @@ describe('AccountPage', () => { }, }; mockGqlRequest.mockResolvedValueOnce(setupData); - fireEvent.click(screen.getByRole('button', { name: /enable 2fa/i })); + const enableBtn = screen.getByRole('button', { name: /enable 2fa/i }); + const beginForm = enableBtn.closest('form')!; + const beginPasswordInput = beginForm.querySelector('input[type="password"]')!; + fireEvent.change(beginPasswordInput, { target: { value: 'mypassword' } }); + fireEvent.click(enableBtn); + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('BeginTotpSetup'), { + password: 'mypassword', + }); + }); await waitFor(() => { expect(screen.getByAltText('Two-factor authentication QR code')).toHaveAttribute( 'src', @@ -337,7 +346,8 @@ describe('AccountPage', () => { }); expect(screen.getByText('ABCD1234')).toBeInTheDocument(); - mockGqlRequest.mockResolvedValueOnce({ confirmTotpSetup: true }); + const backupCodes = ['aaaa1111bbbb2222', 'cccc3333dddd4444']; + mockGqlRequest.mockResolvedValueOnce({ confirmTotpSetup: { backupCodes } }); fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '654321' } }); fireEvent.click(screen.getByRole('button', { name: /^confirm$/i })); @@ -346,6 +356,9 @@ describe('AccountPage', () => { code: '654321', }); }); + await waitFor(() => { + backupCodes.forEach((code) => expect(screen.getByText(code)).toBeInTheDocument()); + }); }); it('shows a disable form when 2FA is already enabled', async () => { diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx index d97b88d7..abd56508 100644 --- a/apps/web/src/routes/_authenticated/account.tsx +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -127,8 +127,8 @@ const TOTP_ENABLED_QUERY = ` `; const BEGIN_TOTP_SETUP = ` - mutation BeginTotpSetup { - beginTotpSetup { + mutation BeginTotpSetup($password: String!) { + beginTotpSetup(password: $password) { secret otpauthUrl qrCodeDataUrl @@ -138,7 +138,9 @@ const BEGIN_TOTP_SETUP = ` const CONFIRM_TOTP_SETUP = ` mutation ConfirmTotpSetup($code: String!) { - confirmTotpSetup(code: $code) + confirmTotpSetup(code: $code) { + backupCodes + } } `; @@ -176,6 +178,10 @@ const deleteSchema = z.object({ password: z.string().min(1, 'Required'), }); +const totpBeginSchema = z.object({ + password: z.string().min(1, 'Required'), +}); + const totpConfirmSchema = z.object({ code: z.string().min(6, 'Enter the 6-digit code').max(6, 'Enter the 6-digit code'), }); @@ -188,6 +194,7 @@ type ProfileForm = z.infer; type EmailForm = z.infer; type PasswordForm = z.infer; type DeleteForm = z.infer; +type TotpBeginForm = z.infer; type TotpConfirmForm = z.infer; type TotpDisableForm = z.infer; @@ -359,23 +366,30 @@ export function AccountPage() { }); const totpEnabled = totpData?.totpEnabled ?? false; const [totpSetup, setTotpSetup] = useState(null); - const [totpSetupError, setTotpSetupError] = useState(null); + const [backupCodes, setBackupCodes] = useState(null); - const onBeginTotpSetup = async () => { - setTotpSetupError(null); + const totpBeginForm = useForm({ resolver: zodResolver(totpBeginSchema) }); + const onBeginTotpSetup = async (data: TotpBeginForm) => { try { - const res = await gqlClient.request<{ beginTotpSetup: TotpSetup }>(BEGIN_TOTP_SETUP); + const res = await gqlClient.request<{ beginTotpSetup: TotpSetup }>(BEGIN_TOTP_SETUP, data); setTotpSetup(res.beginTotpSetup); + totpBeginForm.reset(); } catch (err) { - setTotpSetupError(extractGqlError(err) ?? 'Failed to start two-factor setup.'); + totpBeginForm.setError('root', { + message: extractGqlError(err) ?? 'Failed to start two-factor setup.', + }); } }; const totpConfirmForm = useForm({ resolver: zodResolver(totpConfirmSchema) }); const onConfirmTotpSetup = async (data: TotpConfirmForm) => { try { - await gqlClient.request(CONFIRM_TOTP_SETUP, data); + const res = await gqlClient.request<{ confirmTotpSetup: { backupCodes: string[] } }>( + CONFIRM_TOTP_SETUP, + data, + ); setTotpSetup(null); + setBackupCodes(res.confirmTotpSetup.backupCodes); totpConfirmForm.reset(); await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); } catch (err) { @@ -390,6 +404,7 @@ export function AccountPage() { try { await gqlClient.request(DISABLE_TOTP, data); totpDisableForm.reset(); + setBackupCodes(null); await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); } catch (err) { totpDisableForm.setError('root', { @@ -689,7 +704,27 @@ export function AccountPage() {

- {totpEnabled ? ( + {backupCodes ? ( +
+

Two-factor authentication is enabled.

+

+ Save these backup codes somewhere safe. Each one can be used once to sign in if you + lose access to your authenticator app — they won't be shown again. +

+
    + {backupCodes.map((code) => ( +
  • {code}
  • + ))} +
+ +
+ ) : totpEnabled ? (

Two-factor authentication is enabled.

@@ -763,20 +798,34 @@ export function AccountPage() {
) : ( -
- {totpSetupError && ( +
+
+ + + {totpBeginForm.formState.errors.password && ( +

+ {totpBeginForm.formState.errors.password.message} +

+ )} +
+ {totpBeginForm.formState.errors.root && (

- {totpSetupError} + {totpBeginForm.formState.errors.root.message}

)} -
+ )}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index f8fdd2aa..8f0dd4cf 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -14,7 +14,10 @@ const schema = z.object({ type FormValues = z.infer; const totpSchema = z.object({ - code: z.string().min(6, 'Enter the 6-digit code').max(6, 'Enter the 6-digit code'), + code: z + .string() + .min(6, 'Enter your 6-digit code or a backup code') + .max(20, 'Enter your 6-digit code or a backup code'), }); type TotpFormValues = z.infer; @@ -166,7 +169,7 @@ function TotpStep({ credentials, onBack }: { credentials: FormValues; onBack: () Two-factor authentication

- Enter the 6-digit code from your authenticator app. + Enter the 6-digit code from your authenticator app, or one of your backup codes.

@@ -177,7 +180,6 @@ function TotpStep({ credentials, onBack }: { credentials: FormValues; onBack: ()