Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
ALTER TABLE "User" ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -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");
14 changes: 14 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,30 @@ model User {
emailVerifiedAt DateTime?
weeklyDigestEnabled Boolean @default(true)
followUpRemindersEnabled Boolean @default(true)
totpSecret String?
totpEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
applications JobApplication[]
apiTokens ApiToken[]
emailVerificationTokens EmailVerificationToken[]
sessions Session[]
loginEvents LoginEvent[]
totpBackupCodes TotpBackupCode[]
passwordResetTokens PasswordResetToken[]
}

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 {
id String @id
userId String
Expand Down
201 changes: 201 additions & 0 deletions apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
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,
makeTotpBackupCodeRepository,
makeTotpBackupCode,
makeRateLimiter,
} 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('LoginWithTotpUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

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 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: '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,
totpBackupCodeRepository,
totpRateLimiter,
})
.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) });
const totpBackupCodeRepository = makeTotpBackupCodeRepository();
const totpRateLimiter = makeRateLimiter();
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);

const err = await new LoginWithTotpUseCase({
userRepository,
totpBackupCodeRepository,
totpRateLimiter,
})
.execute(input)
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
});

it('throws RATE_LIMITED when too many verification attempts have been made', 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({ consume: vi.fn().mockReturnValue(false) });
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);

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);

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: `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,
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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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,
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,
totpBackupCodeRepository,
generateId,
})
.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: 'encrypted:ABCD1234' });
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) });
const totpBackupCodeRepository = makeTotpBackupCodeRepository();

const err = await new ConfirmTotpSetupUseCase({
userRepository,
totpBackupCodeRepository,
generateId,
})
.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 totpBackupCodeRepository = makeTotpBackupCodeRepository();

const err = await new ConfirmTotpSetupUseCase({
userRepository,
totpBackupCodeRepository,
generateId,
})
.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 secret = createTotp().generateSecret();
const user = makeUser({
totpEnabled: false,
totpSecret: `encrypted:${secret}`,
});
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) });
const totpBackupCodeRepository = makeTotpBackupCodeRepository();

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 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: `encrypted:${secret}` });
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) });
const totpBackupCodeRepository = makeTotpBackupCodeRepository();

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' }),
);
});
});
Loading
Loading