diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index a02aa387..80543bed 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -20,6 +20,10 @@ const envSchema = z.object({ RATE_LIMIT_DATA_MAX: z.string().default('200'), JWT_SECRET: z.string().default('dev-jwt-secret'), JWT_REFRESH_SECRET: z.string().default('dev-jwt-refresh-secret'), + // Key used to encrypt TOTP secrets at rest. Falls back to JWT_SECRET so local + // development keeps working, but it should be set to its own value in production. + TWO_FACTOR_ENCRYPTION_KEY: z.string().optional(), + TWO_FACTOR_ISSUER: z.string().default('PayD'), AUDIT_LOGGING_ENABLED: z.string().default('true'), // deprecated — always enabled ADVANCED_RATE_LIMIT_ENABLED: z.string().default('true'), // deprecated — always enabled TENANT_ISOLATION_STRICT_MODE: z.string().default('true'), // deprecated — always enabled diff --git a/backend/src/config/initDb.ts b/backend/src/config/initDb.ts index 71c9581a..35e398f3 100644 --- a/backend/src/config/initDb.ts +++ b/backend/src/config/initDb.ts @@ -7,15 +7,31 @@ CREATE TABLE IF NOT EXISTS users ( email VARCHAR(255) UNIQUE, name VARCHAR(255), organization_id INTEGER REFERENCES organizations(id) ON DELETE SET NULL, - role VARCHAR(20) DEFAULT 'EMPLOYEE' CHECK (role IN ('EMPLOYER', 'EMPLOYEE')), + role VARCHAR(20) DEFAULT 'EMPLOYEE' CHECK (role IN ('EMPLOYER', 'EMPLOYEE', 'ADMIN')), refresh_token TEXT, - totp_secret VARCHAR(255), + totp_secret TEXT, + totp_pending_secret TEXT, is_2fa_enabled BOOLEAN DEFAULT FALSE, - recovery_codes TEXT[], + two_factor_enabled_at TIMESTAMPTZ, + totp_last_used_step BIGINT, + two_factor_failed_attempts INTEGER NOT NULL DEFAULT 0, + two_factor_locked_until TIMESTAMPTZ, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS user_recovery_codes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash CHAR(64) NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, code_hash) +); + +CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user_id + ON user_recovery_codes(user_id); + CREATE TABLE IF NOT EXISTS social_identities ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend/src/controllers/__tests__/authController.test.ts b/backend/src/controllers/__tests__/authController.test.ts index 8a1903fb..95bf97f8 100644 --- a/backend/src/controllers/__tests__/authController.test.ts +++ b/backend/src/controllers/__tests__/authController.test.ts @@ -1,109 +1,552 @@ +/** + * Integration tests for the 2FA auth endpoints. + * + * Passport and the database are mocked; JWTs are real, so the tests exercise + * the actual authentication and role checks guarding these routes. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import request from 'supertest'; import express from 'express'; -import authRoutes from '../../routes/authRoutes.js'; +import jwt from 'jsonwebtoken'; import { authenticator } from '@otplib/preset-default'; -import pg from 'pg'; -jest.mock('pg', () => { - const mPool = { - query: jest.fn(), - }; - return { Pool: jest.fn(() => mPool) }; -}); +// QR-code generation and the first ts-jest transform are slow enough that the +// 5s default can trip on a loaded machine. +jest.setTimeout(30_000); + +const mockQuery = jest.fn(); + +jest.unstable_mockModule('../../config/database.js', () => ({ + query: mockQuery, + pool: { query: mockQuery }, + default: { query: mockQuery }, +})); + +// Passport is only needed so the OAuth routes can be registered. +jest.unstable_mockModule('passport', () => ({ + default: { + authenticate: () => (_req: any, _res: any, next: any) => next(), + use: jest.fn(), + serializeUser: jest.fn(), + deserializeUser: jest.fn(), + }, +})); + +const { config } = await import('../../config/env.js'); +const authRoutes = (await import('../../routes/authRoutes.js')).default; +const { hashRecoveryCode } = await import('../../services/twoFactorService.js'); +const { TOKEN_TYPE_2FA_CHALLENGE, TOKEN_TYPE_ACCESS } = + await import('../../services/authService.js'); const app = express(); app.use(express.json()); app.use('/api/auth', authRoutes); -describe('Auth Controller 2FA Integration', () => { - let pool: any; +const ADMIN_ID = 42; + +function userRow(overrides: Record = {}) { + return { + id: ADMIN_ID, + wallet_address: 'GADMIN', + email: 'admin@payd.test', + organization_id: 1, + role: 'ADMIN', + is_2fa_enabled: false, + totp_secret: null, + totp_pending_secret: null, + two_factor_enabled_at: null, + two_factor_locked_until: null, + // Computed by the database with the database's clock, never in JS. + is_locked: false, + ...overrides, + }; +} + +function accessToken(role = 'ADMIN', id = ADMIN_ID) { + return jwt.sign({ id, role, organizationId: 1, typ: TOKEN_TYPE_ACCESS }, config.JWT_SECRET, { + expiresIn: '1h', + }); +} + +type QueryResult = { rows: any[]; rowCount?: number }; +type Route = [RegExp, (params: any[]) => QueryResult]; + +function route(...routes: Route[]) { + mockQuery.mockImplementation(async (sql: string, params: any[] = []) => { + for (const [pattern, handler] of routes) { + if (pattern.test(sql)) return handler(params); + } + return { rows: [], rowCount: 0 }; + }); +} + +function issuedSql(): string[] { + return mockQuery.mock.calls.map((call: any[]) => String(call[0])); +} + +const SELECT_2FA_USER = /SELECT id, wallet_address, email, role/; +const SELECT_LOGIN_USER = /is_2fa_enabled FROM users WHERE wallet_address/; +const SELECT_SESSION_USER = /SELECT id, wallet_address, email, organization_id, role FROM users/; +const COUNT_CODES = /SELECT COUNT\(\*\)/; +const UPDATE_STEP = /SET totp_last_used_step = \$2/; +const CONSUME_RECOVERY = /UPDATE user_recovery_codes/; + +/** + * Enrols an admin and returns the secret plus the stored ciphertext. + * + * QR generation is slow, so the pair is produced once and shared: the tests + * only need *a* valid secret, not a distinct one each time. + */ +let enrolment: Promise<{ secret: string; encrypted: string }> | null = null; +async function enrol(): Promise<{ secret: string; encrypted: string }> { + if (!enrolment) { + enrolment = (async () => { + route([SELECT_2FA_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const response = await request(app) + .post('/api/auth/2fa/setup') + .set('Authorization', `Bearer ${accessToken()}`); + + const update = mockQuery.mock.calls.find((call: any[]) => + /totp_pending_secret = \$2/.test(String(call[0])) + ) as any[]; + + return { secret: response.body.secret as string, encrypted: update[1][1] }; + })(); + } + + const result = await enrolment; + mockQuery.mockReset(); + return result; +} + +describe('Auth 2FA endpoints', () => { beforeEach(() => { - pool = new pg.Pool(); - jest.clearAllMocks(); + mockQuery.mockReset(); }); describe('POST /api/auth/2fa/setup', () => { - it('generates a secret and returns a QR code properly maintaining is_2fa_enabled as false', async () => { - pool.query.mockResolvedValueOnce({ rows: [] }); // User not found - pool.query.mockResolvedValueOnce({}); // Insert success + it('returns a QR code for the authenticated admin without enabling 2FA', async () => { + route([SELECT_2FA_USER, () => ({ rows: [userRow()], rowCount: 1 })]); const response = await request(app) .post('/api/auth/2fa/setup') - .send({ walletAddress: 'GCXX_TEST_WALLET' }); + .set('Authorization', `Bearer ${accessToken()}`); expect(response.status).toBe(200); - expect(response.body).toHaveProperty('qrCode'); - expect(response.body).toHaveProperty('secret'); - expect(response.body).toHaveProperty('recoveryCodes'); - expect(response.body.recoveryCodes.length).toBe(10); - expect(pool.query).toHaveBeenCalledTimes(2); + expect(response.body.qrCode).toMatch(/^data:image\/png;base64,/); + expect(response.body.otpauthUrl).toContain('otpauth://totp/'); + expect(issuedSql().some((sql) => /is_2fa_enabled = TRUE/.test(sql))).toBe(false); }); - it('requires walletAddress structured securely', async () => { - const response = await request(app).post('/api/auth/2fa/setup').send({}); + it('rejects unauthenticated callers', async () => { + const response = await request(app).post('/api/auth/2fa/setup'); - expect(response.status).toBe(400); - expect(response.body.error).toBe('Missing walletAddress'); + expect(response.status).toBe(401); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('rejects non-privileged roles', async () => { + const response = await request(app) + .post('/api/auth/2fa/setup') + .set('Authorization', `Bearer ${accessToken('EMPLOYEE')}`); + + expect(response.status).toBe(403); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('rejects a 2FA challenge token used as an access token', async () => { + const challengeToken = jwt.sign( + { id: ADMIN_ID, typ: TOKEN_TYPE_2FA_CHALLENGE }, + config.JWT_SECRET, + { expiresIn: '5m' } + ); + + const response = await request(app) + .post('/api/auth/2fa/setup') + .set('Authorization', `Bearer ${challengeToken}`); + + expect(response.status).toBe(403); + expect(mockQuery).not.toHaveBeenCalled(); }); }); describe('POST /api/auth/2fa/verify', () => { - it('verifies valid tokens completely altering is_2fa_enabled perfectly', async () => { - const secret = authenticator.generateSecret(); - const token = authenticator.generate(secret); + it('enables 2FA and returns exactly 8 recovery codes', async () => { + const { secret, encrypted } = await enrol(); - pool.query.mockResolvedValueOnce({ rows: [{ totp_secret: secret }] }); // Select secret - pool.query.mockResolvedValueOnce({}); // Update is_2fa_enabled = true + route( + [ + SELECT_2FA_USER, + () => ({ rows: [userRow({ totp_pending_secret: encrypted })], rowCount: 1 }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })] + ); const response = await request(app) .post('/api/auth/2fa/verify') - .send({ walletAddress: 'GCXX_TEST_WALLET', token }); + .set('Authorization', `Bearer ${accessToken()}`) + .send({ code: authenticator.generate(secret) }); expect(response.status).toBe(200); - expect(response.body.success).toBe(true); + expect(response.body.enabled).toBe(true); + expect(response.body.recoveryCodes).toHaveLength(8); + expect(response.body.recoveryCodeCount).toBe(8); + expect(issuedSql().some((sql) => /is_2fa_enabled = TRUE/.test(sql))).toBe(true); }); - it('rejects invalid tokens maintaining database structures strictly', async () => { - const secret = authenticator.generateSecret(); + it('rejects an invalid code and leaves 2FA disabled', async () => { + const { encrypted } = await enrol(); - pool.query.mockResolvedValueOnce({ rows: [{ totp_secret: secret }] }); + route( + [ + SELECT_2FA_USER, + () => ({ rows: [userRow({ totp_pending_secret: encrypted })], rowCount: 1 }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })] + ); + + const response = await request(app) + .post('/api/auth/2fa/verify') + .set('Authorization', `Bearer ${accessToken()}`) + .send({ code: '000000' }); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('INVALID_CODE'); + expect(response.body).not.toHaveProperty('recoveryCodes'); + expect(issuedSql().some((sql) => /is_2fa_enabled = TRUE/.test(sql))).toBe(false); + }); + it('requires a code', async () => { const response = await request(app) .post('/api/auth/2fa/verify') - .send({ walletAddress: 'GCXX_TEST_WALLET', token: '000000' }); + .set('Authorization', `Bearer ${accessToken()}`) + .send({}); + + expect(response.status).toBe(400); + }); + }); + + describe('POST /api/auth/login', () => { + it('issues a session directly when 2FA is disabled', async () => { + route([SELECT_LOGIN_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const response = await request(app).post('/api/auth/login').send({ walletAddress: 'GADMIN' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('accessToken'); + expect(response.body.requires2fa).toBeUndefined(); + }); + + it('withholds the session and returns a challenge when 2FA is enabled', async () => { + route([ + SELECT_LOGIN_USER, + () => ({ rows: [userRow({ is_2fa_enabled: true })], rowCount: 1 }), + ]); + + const response = await request(app).post('/api/auth/login').send({ walletAddress: 'GADMIN' }); + + expect(response.status).toBe(200); + expect(response.body.requires2fa).toBe(true); + expect(response.body).not.toHaveProperty('accessToken'); + expect(response.body).not.toHaveProperty('refreshToken'); + + const claims = jwt.verify(response.body.challengeToken, config.JWT_SECRET) as any; + expect(claims.typ).toBe(TOKEN_TYPE_2FA_CHALLENGE); + expect(claims.id).toBe(ADMIN_ID); + expect(claims.role).toBeUndefined(); + }); + + it('requires a wallet address', async () => { + const response = await request(app).post('/api/auth/login').send({}); + + expect(response.status).toBe(400); + }); + }); + + describe('POST /api/auth/2fa/authenticate', () => { + async function challenge() { + route([ + SELECT_LOGIN_USER, + () => ({ rows: [userRow({ is_2fa_enabled: true })], rowCount: 1 }), + ]); + const login = await request(app).post('/api/auth/login').send({ walletAddress: 'GADMIN' }); + mockQuery.mockReset(); + return login.body.challengeToken as string; + } + + it('completes login with a valid TOTP code', async () => { + const { secret, encrypted } = await enrol(); + const challengeToken = await challenge(); + + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })], + [COUNT_CODES, () => ({ rows: [{ count: 8 }], rowCount: 1 })], + [SELECT_SESSION_USER, () => ({ rows: [userRow()], rowCount: 1 })] + ); + + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ challengeToken, code: authenticator.generate(secret) }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('accessToken'); + expect(response.body).toHaveProperty('refreshToken'); + expect(response.body.usedRecoveryCode).toBe(false); + + const claims = jwt.verify(response.body.accessToken, config.JWT_SECRET) as any; + expect(claims.typ).toBe(TOKEN_TYPE_ACCESS); + expect(claims.role).toBe('ADMIN'); + }); + + it('completes login with a recovery code and consumes it', async () => { + const { encrypted } = await enrol(); + const challengeToken = await challenge(); + + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [CONSUME_RECOVERY, () => ({ rows: [{ id: 3 }], rowCount: 1 })], + [COUNT_CODES, () => ({ rows: [{ count: 7 }], rowCount: 1 })], + [SELECT_SESSION_USER, () => ({ rows: [userRow()], rowCount: 1 })] + ); + + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ challengeToken, code: 'ABCDE-FGHIJ' }); + + expect(response.status).toBe(200); + expect(response.body.usedRecoveryCode).toBe(true); + expect(response.body.recoveryCodesRemaining).toBe(7); + + const consume = mockQuery.mock.calls.find((call: any[]) => + CONSUME_RECOVERY.test(String(call[0])) + ) as any[]; + expect(consume[1][1]).toBe(hashRecoveryCode('ABCDE-FGHIJ')); + }); + + it('rejects an invalid code without issuing a session', async () => { + const { encrypted } = await enrol(); + const challengeToken = await challenge(); + + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })] + ); + + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ challengeToken, code: '000000' }); + + expect(response.status).toBe(401); + expect(response.body).not.toHaveProperty('accessToken'); + }); + + it('rejects an access token presented as a challenge', async () => { + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ challengeToken: accessToken(), code: '123456' }); expect(response.status).toBe(401); - expect(response.body.error).toBe('Invalid 2FA token'); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('rejects an expired challenge', async () => { + const expired = jwt.sign({ id: ADMIN_ID, typ: TOKEN_TYPE_2FA_CHALLENGE }, config.JWT_SECRET, { + expiresIn: '-1s', + }); + + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ challengeToken: expired, code: '123456' }); + + expect(response.status).toBe(401); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('requires both a challenge and a code', async () => { + const response = await request(app) + .post('/api/auth/2fa/authenticate') + .send({ code: '123456' }); + + expect(response.status).toBe(400); }); }); describe('POST /api/auth/2fa/disable', () => { - it('disables 2FA safely validating token logic precisely mapping states natively', async () => { - const secret = authenticator.generateSecret(); - const token = authenticator.generate(secret); + it('disables 2FA with a valid current TOTP code', async () => { + const { secret, encrypted } = await enrol(); - pool.query.mockResolvedValueOnce({ rows: [{ totp_secret: secret, is_2fa_enabled: true }] }); - pool.query.mockResolvedValueOnce({}); + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })] + ); const response = await request(app) .post('/api/auth/2fa/disable') - .send({ walletAddress: 'GCXX_TEST_WALLET', token }); + .set('Authorization', `Bearer ${accessToken()}`) + .send({ code: authenticator.generate(secret) }); expect(response.status).toBe(200); - expect(response.body.success).toBe(true); + expect(response.body.enabled).toBe(false); + expect(issuedSql().some((sql) => /is_2fa_enabled = FALSE/.test(sql))).toBe(true); }); - it('fails to disable if 2FA is already off avoiding arbitrary leaks structurally', async () => { - pool.query.mockResolvedValueOnce({ - rows: [{ totp_secret: 'some_secret', is_2fa_enabled: false }], - }); + it('refuses to disable without a valid TOTP code', async () => { + const { encrypted } = await enrol(); + + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: ADMIN_ID }], rowCount: 1 })] + ); const response = await request(app) .post('/api/auth/2fa/disable') - .send({ walletAddress: 'GCXX_TEST_WALLET', token: '123456' }); + .set('Authorization', `Bearer ${accessToken()}`) + .send({ code: '000000' }); + + expect(response.status).toBe(401); + expect(issuedSql().some((sql) => /is_2fa_enabled = FALSE/.test(sql))).toBe(false); + }); + + it('refuses when 2FA is not enabled', async () => { + route([SELECT_2FA_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const response = await request(app) + .post('/api/auth/2fa/disable') + .set('Authorization', `Bearer ${accessToken()}`) + .send({ code: '123456' }); expect(response.status).toBe(400); + expect(response.body.code).toBe('NOT_ENABLED'); + }); + + it('rejects unauthenticated callers', async () => { + const response = await request(app).post('/api/auth/2fa/disable').send({ code: '123456' }); + + expect(response.status).toBe(401); + expect(mockQuery).not.toHaveBeenCalled(); + }); + }); + + describe('OAuth callbacks', () => { + // Passport is mocked to pass straight through, so the callback handler runs + // with whatever req.user the strategy would have produced. + const asOAuthUser = (user: Record) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).user = user; + next(); + }); + app.use('/api/auth', authRoutes); + return app; + }; + + it('hands out a session when the account has no 2FA', async () => { + const response = await request(asOAuthUser(userRow())).get('/api/auth/google/callback'); + + expect(response.status).toBe(302); + const redirect = new URL(response.headers.location); + expect(redirect.pathname).toBe('/auth-callback'); + + const claims = jwt.verify(redirect.searchParams.get('token')!, config.JWT_SECRET) as any; + expect(claims.typ).toBe(TOKEN_TYPE_ACCESS); + }); + + it('cannot be used to bypass 2FA — issues a challenge instead of a session', async () => { + const response = await request(asOAuthUser(userRow({ is_2fa_enabled: true }))).get( + '/api/auth/google/callback' + ); + + expect(response.status).toBe(302); + const redirect = new URL(response.headers.location); + expect(redirect.searchParams.get('requires2fa')).toBe('1'); + // No access token anywhere in the redirect. + expect(redirect.searchParams.get('token')).toBeNull(); + + const claims = jwt.verify( + redirect.searchParams.get('challengeToken')!, + config.JWT_SECRET + ) as any; + expect(claims.typ).toBe(TOKEN_TYPE_2FA_CHALLENGE); + expect(claims.role).toBeUndefined(); + }); + + it('applies the same rule to the GitHub callback', async () => { + const response = await request(asOAuthUser(userRow({ is_2fa_enabled: true }))).get( + '/api/auth/github/callback' + ); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain('requires2fa=1'); + expect(response.headers.location).not.toContain('token=ey'); + }); + }); + + describe('GET /api/auth/2fa/status', () => { + it('reports enrolment state without exposing the secret', async () => { + route( + [ + SELECT_2FA_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: 'v1.super.secret.value', + two_factor_enabled_at: new Date('2026-01-02T03:04:05Z'), + }), + ], + rowCount: 1, + }), + ], + [COUNT_CODES, () => ({ rows: [{ count: 5 }], rowCount: 1 })] + ); + + const response = await request(app) + .get('/api/auth/2fa/status') + .set('Authorization', `Bearer ${accessToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + enabled: true, + enabledAt: '2026-01-02T03:04:05.000Z', + setupPending: false, + recoveryCodesRemaining: 5, + }); + expect(response.text).not.toContain('super.secret.value'); }); }); }); diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index 22071b7f..89e25391 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -1,227 +1,243 @@ import express from 'express'; -import { authenticator } from '@otplib/preset-default'; -import QRCode from 'qrcode'; -import crypto from 'crypto'; -import { Pool } from 'pg'; -import { config } from '../config/env.js'; import jwt from 'jsonwebtoken'; +import { config } from '../config/env.js'; +import { query } from '../config/database.js'; +import { + generateRefreshToken, + generateToken, + generateTwoFactorChallengeToken, + verifyTwoFactorChallengeToken, +} from '../services/authService.js'; +import { + RECOVERY_CODE_COUNT, + TwoFactorError, + confirmSetup, + disable as disableTwoFactor, + getStatus, + startSetup, + verifySecondFactor, +} from '../services/twoFactorService.js'; + +/** + * Translates a {@link TwoFactorError} into its HTTP response. Anything else is + * reported as a generic 500 so internal details never reach the client. + */ +function sendTwoFactorError(res: express.Response, error: unknown) { + if (error instanceof TwoFactorError) { + return res.status(error.status).json({ error: error.message, code: error.code }); + } + + console.error('2FA operation failed:', error); + return res.status(500).json({ error: 'Internal server error' }); +} -const pool = new Pool({ connectionString: config.DATABASE_URL }); +/** Issues an access/refresh pair and persists the refresh token. */ +async function issueSession(user: { + id: number; + wallet_address: string | null; + email?: string | null; + organization_id: number | null; + role: string; +}) { + const accessToken = generateToken(user); + const refreshToken = generateRefreshToken(user); + + await query('UPDATE users SET refresh_token = $1 WHERE id = $2', [refreshToken, user.id]); + + return { accessToken, refreshToken }; +} export class AuthController { /** * POST /api/auth/2fa/setup - * Generates a structural totp_secret natively mapping `is_2fa_enabled=false`. - * Evaluates the wallet address binding user boundaries optimally. + * Starts enrolment for the authenticated admin: mints a secret, stores it as + * pending, and returns the QR code to scan. 2FA is not enabled until the + * admin confirms a code via `/2fa/verify`. */ static async setup2fa(req: express.Request, res: express.Response) { - const { walletAddress } = req.body; - if (!walletAddress) { - return res.status(400).json({ error: 'Missing walletAddress' }); - } - try { - const secret = authenticator.generateSecret(); - const otpauthUrl = authenticator.keyuri(walletAddress, 'PayD', secret); - const dataUrl = await QRCode.toDataURL(otpauthUrl); - - // Generate unique recovery codes bounding fallbacks precisely natively - const recoveryCodes = Array.from({ length: 10 }, () => crypto.randomBytes(4).toString('hex')); - - // Check if user exists structurally natively avoiding conflicts - const result = await pool.query('SELECT id FROM users WHERE wallet_address = $1', [ - walletAddress, - ]); - - if (result.rows.length === 0) { - await pool.query( - `INSERT INTO users (wallet_address, totp_secret, recovery_codes, is_2fa_enabled) - VALUES ($1, $2, $3, false)`, - [walletAddress, secret, recoveryCodes] - ); - } else { - await pool.query( - `UPDATE users SET totp_secret = $1, recovery_codes = $2, is_2fa_enabled = false - WHERE wallet_address = $3`, - [secret, recoveryCodes, walletAddress] - ); - } - - res.json({ - qrCode: dataUrl, - secret, - recoveryCodes, - }); - } catch (error: any) { - res.status(500).json({ error: error.message }); + const { secret, otpauthUrl, qrCode } = await startSetup(req.user!.id); + return res.json({ qrCode, otpauthUrl, secret }); + } catch (error) { + return sendTwoFactorError(res, error); } } /** * POST /api/auth/2fa/verify - * Evaluates the `totp_secret` against the incoming `token` turning `is_2fa_enabled=true` mapping successful interactions. + * Completes enrolment for the authenticated admin. On success 2FA is enabled + * and the recovery codes are returned — this is the only time they are shown. */ static async verify2fa(req: express.Request, res: express.Response) { - const { walletAddress, token } = req.body; - if (!walletAddress || !token) { - return res.status(400).json({ error: 'Missing parameters' }); + const { token, code } = req.body ?? {}; + const submitted = code ?? token; + + if (!submitted) { + return res.status(400).json({ error: 'Missing 2FA code' }); } try { - const result = await pool.query( - 'SELECT id, wallet_address, organization_id, role, totp_secret FROM users WHERE wallet_address = $1', - [walletAddress] - ); - if (result.rows.length === 0) { - return res.status(404).json({ error: 'User not found' }); - } + const recoveryCodes = await confirmSetup(req.user!.id, submitted); + return res.json({ + success: true, + enabled: true, + recoveryCodes, + recoveryCodeCount: RECOVERY_CODE_COUNT, + message: '2FA enabled. Store these recovery codes somewhere safe — they are shown once.', + }); + } catch (error) { + return sendTwoFactorError(res, error); + } + } - const user = result.rows[0]; - const isValid = authenticator.check(token, user.totp_secret); - - if (isValid) { - await pool.query('UPDATE users SET is_2fa_enabled = true WHERE wallet_address = $1', [ - walletAddress, - ]); - - // Issue tokens upon successful 2FA verification - const accessToken = jwt.sign( - { - id: user.id, - walletAddress: user.wallet_address, - organizationId: user.organization_id, - role: user.role, - }, - config.JWT_SECRET, - { expiresIn: '1h' } - ); + /** + * POST /api/auth/2fa/disable + * Turns 2FA off for the authenticated admin. Requires a current TOTP code; + * recovery codes are not accepted for this operation. + */ + static async disable2fa(req: express.Request, res: express.Response) { + const { token, code } = req.body ?? {}; + const submitted = code ?? token; - const refreshToken = jwt.sign({ id: user.id }, config.JWT_REFRESH_SECRET, { - expiresIn: '7d', - }); + if (!submitted) { + return res.status(400).json({ error: 'Missing 2FA code' }); + } - await pool.query('UPDATE users SET refresh_token = $1 WHERE id = $2', [ - refreshToken, - user.id, - ]); + try { + await disableTwoFactor(req.user!.id, submitted); + return res.json({ success: true, enabled: false, message: '2FA disabled' }); + } catch (error) { + return sendTwoFactorError(res, error); + } + } - res.json({ - success: true, - accessToken, - refreshToken, - message: '2FA verified successfully', - }); - } else { - res.status(401).json({ error: 'Invalid 2FA token' }); - } - } catch (error: any) { - res.status(500).json({ error: error.message }); + /** + * GET /api/auth/2fa/status + * Reports enrolment state for the authenticated user. Never exposes the + * secret or any recovery code. + */ + static async status2fa(req: express.Request, res: express.Response) { + try { + return res.json(await getStatus(req.user!.id)); + } catch (error) { + return sendTwoFactorError(res, error); } } /** - * POST /api/auth/2fa/disable - * Evaluates valid disabling structures tracking secret clearances parsing structurally exactly avoiding leaks. + * POST /api/auth/2fa/authenticate + * Second step of login. Exchanges the challenge token from `/login` plus a + * TOTP or recovery code for a real session. */ - static async disable2fa(req: express.Request, res: express.Response) { - const { walletAddress, token } = req.body; - if (!walletAddress || !token) { - return res.status(400).json({ error: 'Missing requirements tracking bounds' }); + static async authenticate2fa(req: express.Request, res: express.Response) { + const { challengeToken, code, token } = req.body ?? {}; + const submitted = code ?? token; + + if (!challengeToken || !submitted) { + return res.status(400).json({ error: 'Missing challenge token or 2FA code' }); + } + + const userId = verifyTwoFactorChallengeToken(challengeToken); + if (userId === null) { + return res.status(401).json({ error: 'Invalid or expired 2FA challenge. Log in again.' }); } try { - const result = await pool.query( - 'SELECT totp_secret, is_2fa_enabled FROM users WHERE wallet_address = $1', - [walletAddress] + const { usedRecoveryCode } = await verifySecondFactor(userId, submitted); + + const result = await query( + 'SELECT id, wallet_address, email, organization_id, role FROM users WHERE id = $1', + [userId] ); - if (result.rows.length === 0 || !result.rows[0].is_2fa_enabled) { - return res - .status(400) - .json({ error: '2FA is not structurally fully enabled over the user correctly parsing' }); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); } - const { totp_secret } = result.rows[0]; - const isValid = authenticator.check(token, totp_secret); + const remaining = await query( + 'SELECT COUNT(*)::int AS count FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL', + [userId] + ); - if (isValid) { - await pool.query( - 'UPDATE users SET is_2fa_enabled = false, totp_secret = NULL, recovery_codes = NULL WHERE wallet_address = $1', - [walletAddress] - ); - res.json({ success: true, message: '2FA removed flawlessly properly' }); - } else { - res.status(401).json({ error: 'Invalid 2FA token limiting disable structurally' }); - } - } catch (error: any) { - res.status(500).json({ error: error.message }); + const session = await issueSession(result.rows[0]); + + return res.json({ + success: true, + ...session, + usedRecoveryCode, + recoveryCodesRemaining: remaining.rows[0]?.count ?? 0, + }); + } catch (error) { + return sendTwoFactorError(res, error); } } + /** * POST /api/auth/login - * Simple wallet-based login. Returns tokens or requires 2FA. + * Wallet-based login. When the account has 2FA enabled no session is issued; + * the caller gets a short-lived challenge to complete at + * `/api/auth/2fa/authenticate`. */ static async login(req: express.Request, res: express.Response) { - const { walletAddress } = req.body; + const { walletAddress } = req.body ?? {}; if (!walletAddress) { return res.status(400).json({ error: 'Missing walletAddress' }); } try { - const result = await pool.query( - 'SELECT id, wallet_address, organization_id, role, is_2fa_enabled FROM users WHERE wallet_address = $1', + const result = await query( + 'SELECT id, wallet_address, email, organization_id, role, is_2fa_enabled FROM users WHERE wallet_address = $1', [walletAddress] ); if (result.rows.length === 0) { // For demo purposes, auto-register as EMPLOYEE if not found // In production, this would be a separate registration flow - const insertResult = await pool.query( + const insertResult = await query( 'INSERT INTO users (wallet_address, role) VALUES ($1, $2) RETURNING *', [walletAddress, 'EMPLOYEE'] ); - const newUser = insertResult.rows[0]; - const accessToken = jwt.sign( - { - id: newUser.id, - walletAddress: newUser.wallet_address, - organizationId: newUser.organization_id, - role: newUser.role, - }, - config.JWT_SECRET, - { expiresIn: '1h' } - ); - return res.json({ accessToken }); + return res.json({ accessToken: generateToken(insertResult.rows[0]) }); } const user = result.rows[0]; if (user.is_2fa_enabled) { - return res.json({ requires2fa: true }); + return res.json({ + requires2fa: true, + challengeToken: generateTwoFactorChallengeToken(user.id), + }); } - const accessToken = jwt.sign( - { - id: user.id, - walletAddress: user.wallet_address, - organizationId: user.organization_id, - role: user.role, - }, - config.JWT_SECRET, - { expiresIn: '1h' } - ); + return res.json(await issueSession(user)); + } catch (error: any) { + console.error('Login failed:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + } - const refreshToken = jwt.sign({ id: user.id }, config.JWT_REFRESH_SECRET, { - expiresIn: '7d', - }); + /** + * Shared handler for the OAuth callbacks. Passport has already established + * the first factor at this point, so an account with 2FA enabled is sent back + * with a challenge instead of a session — otherwise an admin could sidestep + * their second factor simply by signing in with Google or GitHub. + */ + static oauthCallback(req: express.Request, res: express.Response) { + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + const user = req.user as (express.User & { is_2fa_enabled?: boolean }) | undefined; - await pool.query('UPDATE users SET refresh_token = $1 WHERE id = $2', [ - refreshToken, - user.id, - ]); + if (!user) { + return res.redirect(`${frontendUrl}/login?error=oauth_failed`); + } - res.json({ accessToken, refreshToken }); - } catch (error: any) { - res.status(500).json({ error: error.message }); + if (user.is_2fa_enabled) { + const challengeToken = generateTwoFactorChallengeToken(user.id); + return res.redirect( + `${frontendUrl}/auth-callback?requires2fa=1&challengeToken=${encodeURIComponent(challengeToken)}` + ); } + + return res.redirect( + `${frontendUrl}/auth-callback?token=${encodeURIComponent(generateToken(user))}` + ); } /** @@ -229,15 +245,15 @@ export class AuthController { * Refreshes access token using a valid refresh token. */ static async refresh(req: express.Request, res: express.Response) { - const { refreshToken } = req.body; + const { refreshToken } = req.body ?? {}; if (!refreshToken) { return res.status(400).json({ error: 'Missing refresh token' }); } try { const decoded = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as { id: number }; - const result = await pool.query( - 'SELECT id, wallet_address, organization_id, role, refresh_token FROM users WHERE id = $1', + const result = await query( + 'SELECT id, wallet_address, email, organization_id, role, refresh_token FROM users WHERE id = $1', [decoded.id] ); @@ -245,21 +261,9 @@ export class AuthController { return res.status(401).json({ error: 'Invalid refresh token' }); } - const user = result.rows[0]; - const accessToken = jwt.sign( - { - id: user.id, - walletAddress: user.wallet_address, - organizationId: user.organization_id, - role: user.role, - }, - config.JWT_SECRET, - { expiresIn: '1h' } - ); - - res.json({ accessToken }); + return res.json({ accessToken: generateToken(result.rows[0]) }); } catch (error) { - res.status(401).json({ error: 'Invalid or expired refresh token' }); + return res.status(401).json({ error: 'Invalid or expired refresh token' }); } } } diff --git a/backend/src/db/migrations/029_admin_two_factor_auth.sql b/backend/src/db/migrations/029_admin_two_factor_auth.sql new file mode 100644 index 00000000..d0c373d8 --- /dev/null +++ b/backend/src/db/migrations/029_admin_two_factor_auth.sql @@ -0,0 +1,63 @@ +-- TOTP-based two-factor authentication for privileged (admin) accounts. +-- +-- Replaces the first-pass 2FA columns added in 003_create_users_2fa.sql with a +-- design that keeps enrolment and activation separate, stores recovery codes as +-- single-use hashes instead of plaintext, and records enough state to reject +-- replayed TOTP codes and throttle brute-force attempts. + +-- The application has always modelled an ADMIN role, but the original CHECK +-- constraint only allowed EMPLOYER/EMPLOYEE, so an admin row could never exist. +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users + ADD CONSTRAINT users_role_check CHECK (role IN ('EMPLOYER', 'EMPLOYEE', 'ADMIN')); + +-- Secret captured during setup. It is only promoted to totp_secret once the +-- admin proves possession of the authenticator, so an abandoned setup can never +-- leave an account half-enrolled. +ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_pending_secret TEXT; + +-- totp_secret previously held a plaintext base32 secret; it now holds an +-- AES-256-GCM ciphertext, which needs more room than VARCHAR(255). +ALTER TABLE users ALTER COLUMN totp_secret TYPE TEXT; + +-- TIMESTAMPTZ, not TIMESTAMP: node-pg parses a bare TIMESTAMP as *local* time, +-- so on a server whose timezone is not UTC the value comes back shifted. For +-- two_factor_locked_until that silently disables the brute-force lockout. +ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled_at TIMESTAMPTZ; + +-- Highest TOTP time step already spent by this user. A code is accepted only +-- when its step is strictly greater, which makes every code single-use. +ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_last_used_step BIGINT; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_failed_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_locked_until TIMESTAMPTZ; + +-- Recovery codes move out of the plaintext users.recovery_codes array into +-- their own table so each code can be hashed and individually invalidated. +CREATE TABLE IF NOT EXISTS user_recovery_codes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash CHAR(64) NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, code_hash) +); + +CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user_id + ON user_recovery_codes(user_id); + +-- Unused codes are the only ones ever looked up during login. +CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_unused + ON user_recovery_codes(user_id, code_hash) + WHERE used_at IS NULL; + +-- Any code still sitting in the old array is plaintext and cannot be trusted. +ALTER TABLE users DROP COLUMN IF EXISTS recovery_codes; + +-- Existing plaintext secrets predate encryption at rest, so enrolments are +-- reset rather than silently re-used. Affected admins re-run 2FA setup. +UPDATE users +SET totp_secret = NULL, + totp_pending_secret = NULL, + is_2fa_enabled = FALSE +WHERE totp_secret IS NOT NULL; diff --git a/backend/src/middlewares/__tests__/require2fa.test.ts b/backend/src/middlewares/__tests__/require2fa.test.ts new file mode 100644 index 00000000..961e4180 --- /dev/null +++ b/backend/src/middlewares/__tests__/require2fa.test.ts @@ -0,0 +1,213 @@ +/** + * Unit tests for the 2FA step-up middleware guarding sensitive payment routes. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; +import { authenticator } from '@otplib/preset-default'; + +// QR-code generation and the first ts-jest transform are slow enough that the +// 5s default can trip on a loaded machine. +jest.setTimeout(30_000); + +const mockQuery = jest.fn(); + +jest.unstable_mockModule('../../config/database.js', () => ({ + query: mockQuery, + pool: { query: mockQuery }, + default: { query: mockQuery }, +})); + +const { require2FA } = await import('../require2fa.js'); +const { startSetup } = await import('../../services/twoFactorService.js'); + +const USER_ID = 42; + +type QueryResult = { rows: any[]; rowCount?: number }; +type Route = [RegExp, (params: any[]) => QueryResult]; + +const SELECT_FLAG = /SELECT is_2fa_enabled FROM users/; +const SELECT_2FA_USER = /SELECT id, wallet_address, email, role/; +const UPDATE_STEP = /SET totp_last_used_step = \$2/; + +function route(...routes: Route[]) { + mockQuery.mockImplementation(async (sql: string, params: any[] = []) => { + for (const [pattern, handler] of routes) { + if (pattern.test(sql)) return handler(params); + } + return { rows: [], rowCount: 0 }; + }); +} + +function buildRes() { + const res = { + statusCode: 0, + body: undefined as any, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: any) { + this.body = payload; + return this; + }, + }; + return res as unknown as Response & { statusCode: number; body: any }; +} + +function buildReq(overrides: Partial = {}): Request { + return { headers: {}, body: {}, user: { id: USER_ID }, ...overrides } as unknown as Request; +} + +/** + * Produces a secret plus the ciphertext the service would have stored. + * + * QR generation is slow, so the pair is produced once and shared: the tests + * only need *a* valid secret, not a distinct one each time. + */ +let enrolment: Promise<{ secret: string; encrypted: string }> | null = null; + +async function enrol(): Promise<{ secret: string; encrypted: string }> { + if (enrolment) { + const cached = await enrolment; + mockQuery.mockReset(); + return cached; + } + + route([ + SELECT_2FA_USER, + () => ({ + rows: [ + { + id: USER_ID, + wallet_address: 'GADMIN', + email: null, + role: 'ADMIN', + is_2fa_enabled: false, + totp_secret: null, + totp_pending_secret: null, + two_factor_enabled_at: null, + two_factor_locked_until: null, + is_locked: false, + }, + ], + rowCount: 1, + }), + ]); + + const { secret } = await startSetup(USER_ID); + const update = mockQuery.mock.calls.find((call: any[]) => + /totp_pending_secret = \$2/.test(String(call[0])) + ) as any[]; + + mockQuery.mockReset(); + const result = { secret, encrypted: update[1][1] as string }; + enrolment = Promise.resolve(result); + return result; +} + +function enabledUserRow(encrypted: string) { + return { + id: USER_ID, + wallet_address: 'GADMIN', + email: null, + role: 'ADMIN', + is_2fa_enabled: true, + totp_secret: encrypted, + totp_pending_secret: null, + two_factor_enabled_at: new Date(), + two_factor_locked_until: null, + is_locked: false, + }; +} + +describe('require2FA', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + it('rejects requests that have not been authenticated', async () => { + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await require2FA(buildReq({ user: undefined } as any), res, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('lets accounts without 2FA through', async () => { + route([SELECT_FLAG, () => ({ rows: [{ is_2fa_enabled: false }], rowCount: 1 })]); + const next = jest.fn() as unknown as NextFunction; + + await require2FA(buildReq(), buildRes(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('blocks a 2FA account that supplies no code', async () => { + route([SELECT_FLAG, () => ({ rows: [{ is_2fa_enabled: true }], rowCount: 1 })]); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await require2FA(buildReq(), res, next); + + expect(res.statusCode).toBe(401); + expect(res.body.code).toBe('TWO_FACTOR_REQUIRED'); + expect(next).not.toHaveBeenCalled(); + }); + + it('accepts a valid code from the x-2fa-token header', async () => { + const { secret, encrypted } = await enrol(); + route( + [SELECT_FLAG, () => ({ rows: [{ is_2fa_enabled: true }], rowCount: 1 })], + [SELECT_2FA_USER, () => ({ rows: [enabledUserRow(encrypted)], rowCount: 1 })], + [UPDATE_STEP, () => ({ rows: [{ id: USER_ID }], rowCount: 1 })] + ); + const next = jest.fn() as unknown as NextFunction; + + await require2FA( + buildReq({ headers: { 'x-2fa-token': authenticator.generate(secret) } } as any), + buildRes(), + next + ); + + expect(next).toHaveBeenCalled(); + }); + + it('rejects a code that was already spent, so a header cannot be replayed', async () => { + const { secret, encrypted } = await enrol(); + route( + [SELECT_FLAG, () => ({ rows: [{ is_2fa_enabled: true }], rowCount: 1 })], + [SELECT_2FA_USER, () => ({ rows: [enabledUserRow(encrypted)], rowCount: 1 })], + // The guarded UPDATE matches nothing once the step has been burned. + [UPDATE_STEP, () => ({ rows: [], rowCount: 0 })] + ); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await require2FA( + buildReq({ headers: { 'x-2fa-token': authenticator.generate(secret) } } as any), + res, + next + ); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('ignores a wallet address supplied by the client', async () => { + route([SELECT_FLAG, () => ({ rows: [{ is_2fa_enabled: false }], rowCount: 1 })]); + const next = jest.fn() as unknown as NextFunction; + + await require2FA( + buildReq({ body: { walletAddress: 'GSOMEONE_ELSE' }, headers: {} } as any), + buildRes(), + next + ); + + expect(next).toHaveBeenCalled(); + expect(mockQuery.mock.calls[0][1]).toEqual([USER_ID]); + }); +}); diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index 1a11253f..7dd346a7 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { config } from '../config/env.js'; import { JWTPayload } from '../types/auth.js'; +import { TOKEN_TYPE_ACCESS } from '../services/authService.js'; /** * Middleware to authenticate requests using JWT @@ -17,7 +18,14 @@ export const authenticateJWT = (req: Request, res: Response, next: NextFunction) } try { - const decoded = jwt.verify(token, config.JWT_SECRET) as JWTPayload; + const decoded = jwt.verify(token, config.JWT_SECRET) as JWTPayload & { typ?: string }; + + // Two-factor challenge tokens are signed with the same secret but grant + // no API access. Tokens minted before `typ` existed are still accepted. + if (decoded.typ !== undefined && decoded.typ !== TOKEN_TYPE_ACCESS) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + req.user = decoded; next(); } catch (error) { diff --git a/backend/src/middlewares/require2fa.ts b/backend/src/middlewares/require2fa.ts index e4051e48..a52f973a 100644 --- a/backend/src/middlewares/require2fa.ts +++ b/backend/src/middlewares/require2fa.ts @@ -1,53 +1,47 @@ import { Request, Response, NextFunction } from 'express'; -import { authenticator } from '@otplib/preset-default'; -import pg from 'pg'; -import { config } from '../config/env.js'; - -const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); - +import { TwoFactorError, verifySecondFactor } from '../services/twoFactorService.js'; +import { query } from '../config/database.js'; + +/** + * Step-up check for sensitive operations (payouts, withdrawals). + * + * Accounts without 2FA pass straight through; accounts with 2FA enabled must + * present a fresh code in the `x-2fa-token` header. The code is consumed on + * success, so replaying the same header on a second request is rejected. + * + * Must run after `authenticateJWT` — the account comes from the verified JWT, + * never from a client-supplied wallet address. + */ export const require2FA = async (req: Request, res: Response, next: NextFunction) => { - const walletAddress = - (req.headers['x-user-wallet'] as string) || req.body.walletAddress || req.body.secretKey; - const token = req.headers['x-2fa-token'] as string; + const userId = req.user?.id; - if (!walletAddress) { - return res - .status(400) - .json({ error: 'Identity bound wallet header requirements missing natively' }); + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); } try { - const result = await pool.query( - 'SELECT is_2fa_enabled, totp_secret FROM users WHERE wallet_address = $1', - [walletAddress] - ); + const result = await query('SELECT is_2fa_enabled FROM users WHERE id = $1', [userId]); - // If not found or not enabled, let them pass implicitly protecting their access rights safely structure if (result.rows.length === 0 || !result.rows[0].is_2fa_enabled) { return next(); } - const { totp_secret } = result.rows[0]; - - // Block the action natively resolving to 401 requiring token input natively - if (!token) { + const token = req.headers['x-2fa-token']; + if (typeof token !== 'string' || token.length === 0) { return res.status(401).json({ - error: - '2FA token required enforcing verification bounds cleanly properly structured explicitly mapping over limits strictly', + error: 'This operation requires a 2FA code in the x-2fa-token header', + code: 'TWO_FACTOR_REQUIRED', }); } - const isValid = authenticator.check(token, totp_secret); - - if (isValid) { - next(); - } else { - res.status(401).json({ - error: - 'Invalid 2FA token bound tracking bounds exclusively missing requirements correctly strictly structurally isolating issues seamlessly', - }); + await verifySecondFactor(userId, token); + return next(); + } catch (error) { + if (error instanceof TwoFactorError) { + return res.status(error.status).json({ error: error.message, code: error.code }); } - } catch (error: any) { - res.status(500).json({ error: error.message }); + + console.error('2FA step-up check failed:', error); + return res.status(500).json({ error: 'Internal server error' }); } }; diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 8ad23846..9ea8475f 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -1,16 +1,46 @@ import { Router } from 'express'; import passport from 'passport'; -import { generateToken } from '../services/authService.js'; import { AuthController } from '../controllers/authController.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { authorizeRoles } from '../middlewares/rbac.js'; +import { TWO_FACTOR_ROLES } from '../services/twoFactorService.js'; const router = Router(); router.post('/login', AuthController.login); router.post('/refresh', AuthController.refresh); -router.post('/2fa/setup', AuthController.setup2fa); -router.post('/2fa/verify', AuthController.verify2fa); -router.post('/2fa/disable', AuthController.disable2fa); +// ── Two-factor authentication ────────────────────────────────────────────── +// +// Enrolment endpoints are account settings, so they run on the caller's own +// session and are limited to the privileged roles the feature targets. The +// account is always taken from the verified JWT, never from the request body, +// so nobody can enrol or disable 2FA on someone else's account. + +// Second step of login: exchanges the challenge issued by /login for a session. +// Unauthenticated by design — the challenge token is the credential. +router.post('/2fa/authenticate', AuthController.authenticate2fa); + +router.get('/2fa/status', authenticateJWT, AuthController.status2fa); + +router.post( + '/2fa/setup', + authenticateJWT, + authorizeRoles(...TWO_FACTOR_ROLES), + AuthController.setup2fa +); +router.post( + '/2fa/verify', + authenticateJWT, + authorizeRoles(...TWO_FACTOR_ROLES), + AuthController.verify2fa +); +router.post( + '/2fa/disable', + authenticateJWT, + authorizeRoles(...TWO_FACTOR_ROLES), + AuthController.disable2fa +); // Google Auth router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); @@ -18,13 +48,7 @@ router.get('/google', passport.authenticate('google', { scope: ['profile', 'emai router.get( '/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), - (req, res) => { - const token = generateToken(req.user); - // Redirect to frontend with token (adjust URL as needed) - res.redirect( - `${process.env.FRONTEND_URL || 'http://localhost:5173'}/auth-callback?token=${token}` - ); - } + AuthController.oauthCallback ); // GitHub Auth @@ -33,12 +57,7 @@ router.get('/github', passport.authenticate('github', { scope: ['user:email'] }) router.get( '/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), - (req, res) => { - const token = generateToken(req.user); - res.redirect( - `${process.env.FRONTEND_URL || 'http://localhost:5173'}/auth-callback?token=${token}` - ); - } + AuthController.oauthCallback ); export default router; diff --git a/backend/src/services/__tests__/twoFactorService.stateful.test.ts b/backend/src/services/__tests__/twoFactorService.stateful.test.ts new file mode 100644 index 00000000..f638a740 --- /dev/null +++ b/backend/src/services/__tests__/twoFactorService.stateful.test.ts @@ -0,0 +1,309 @@ +/** + * Behavioural tests for the 2FA guarantees that only show up across a sequence + * of calls: the brute-force lockout threshold, and single-use enforcement under + * concurrency. + * + * These run against a small in-memory stand-in for the two tables the service + * touches, which implements the exact semantics of the guarded statements the + * service issues (`UPDATE … WHERE used_at IS NULL`, + * `UPDATE … WHERE totp_last_used_step < $2`, and the failure-counter CASE). + * The stand-in models Postgres; it is not proof that Postgres behaves this way. + * The companion assertions in twoFactorService.test.ts pin the guard clauses to + * the SQL text, so a change that dropped a guard fails there. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { authenticator } from '@otplib/preset-default'; + +// QR-code generation and the first ts-jest transform are slow enough that the +// 5s default can trip on a loaded machine. +jest.setTimeout(30_000); + +const mockQuery = jest.fn(); + +jest.unstable_mockModule('../../config/database.js', () => ({ + query: mockQuery, + pool: { query: mockQuery }, + default: { query: mockQuery }, +})); + +const { confirmSetup, hashRecoveryCode, startSetup, verifySecondFactor, TwoFactorError } = + await import('../twoFactorService.js'); + +const USER_ID = 42; +const MAX_FAILED_ATTEMPTS = 5; + +interface FakeUser { + id: number; + wallet_address: string | null; + email: string | null; + role: string; + is_2fa_enabled: boolean; + totp_secret: string | null; + totp_pending_secret: string | null; + two_factor_enabled_at: Date | null; + two_factor_locked_until: Date | null; + totp_last_used_step: number | null; + two_factor_failed_attempts: number; +} + +/** + * In-memory stand-in for the `users` and `user_recovery_codes` rows, applying + * the same guards the service's SQL applies. + */ +function installFakeDatabase() { + const user: FakeUser = { + id: USER_ID, + wallet_address: 'GADMIN', + email: 'admin@payd.test', + role: 'ADMIN', + is_2fa_enabled: false, + totp_secret: null, + totp_pending_secret: null, + two_factor_enabled_at: null, + two_factor_locked_until: null, + totp_last_used_step: null, + two_factor_failed_attempts: 0, + }; + + const recoveryCodes = new Map(); + + mockQuery.mockImplementation(async (sql: string, params: any[] = []) => { + // Yield to the event loop so genuinely interleaved callers race here, the + // way concurrent requests race on a real connection pool. + await Promise.resolve(); + + if (/SELECT id, wallet_address, email, role/.test(sql)) { + const lockedUntil = user.two_factor_locked_until; + return { + rows: [ + { + ...user, + // The database computes this against its own clock. + is_locked: lockedUntil !== null && lockedUntil.getTime() > Date.now(), + }, + ], + rowCount: 1, + }; + } + + if (/SELECT COUNT\(\*\)/.test(sql)) { + const unused = [...recoveryCodes.values()].filter((c) => !c.used).length; + return { rows: [{ count: unused }], rowCount: 1 }; + } + + // UPDATE users SET totp_last_used_step = $2 WHERE id = $1 + // AND (totp_last_used_step IS NULL OR totp_last_used_step < $2) + if (/SET totp_last_used_step = \$2/.test(sql)) { + const step = Number(params[1]); + if (user.totp_last_used_step === null || user.totp_last_used_step < step) { + user.totp_last_used_step = step; + return { rows: [{ id: USER_ID }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + + // UPDATE user_recovery_codes SET used_at = NOW() … AND used_at IS NULL + if (/UPDATE user_recovery_codes/.test(sql)) { + const record = recoveryCodes.get(params[1]); + if (record && !record.used) { + record.used = true; + return { rows: [{ id: 1 }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + + // Branch order matters: several of these statements also reset the failure + // counter, so the most specific pattern has to be tested first. + if (/is_2fa_enabled = TRUE/.test(sql)) { + user.totp_secret = user.totp_pending_secret; + user.totp_pending_secret = null; + user.is_2fa_enabled = true; + user.two_factor_enabled_at = new Date(); + user.two_factor_failed_attempts = 0; + user.two_factor_locked_until = null; + return { rows: [], rowCount: 1 }; + } + + // Checked before the counter branches: startSetup's UPDATE also resets + // two_factor_failed_attempts, so it would otherwise match one of them. + if (/totp_pending_secret = \$2/.test(sql)) { + user.totp_pending_secret = params[1]; + return { rows: [], rowCount: 1 }; + } + + // The failure-counter CASE. + if (/two_factor_failed_attempts \+ 1/.test(sql)) { + const maxAttempts = Number(params[1]); + const lockoutMs = Number(params[2]); + const lockedUntil = user.two_factor_locked_until; + const lockExpired = lockedUntil !== null && lockedUntil.getTime() <= Date.now(); + + if (lockExpired) { + user.two_factor_failed_attempts = 1; + user.two_factor_locked_until = null; + } else { + user.two_factor_failed_attempts += 1; + if (user.two_factor_failed_attempts >= maxAttempts) { + user.two_factor_locked_until = new Date(Date.now() + lockoutMs); + } + } + return { rows: [], rowCount: 1 }; + } + + if (/two_factor_failed_attempts = 0/.test(sql)) { + user.two_factor_failed_attempts = 0; + user.two_factor_locked_until = null; + return { rows: [], rowCount: 1 }; + } + + if (/DELETE FROM user_recovery_codes/.test(sql)) { + recoveryCodes.clear(); + return { rows: [], rowCount: 0 }; + } + + if (/INSERT INTO user_recovery_codes/.test(sql)) { + for (const hash of params[1] as string[]) recoveryCodes.set(hash, { used: false }); + return { rows: [], rowCount: (params[1] as string[]).length }; + } + + return { rows: [], rowCount: 0 }; + }); + + return { user, recoveryCodes }; +} + +/** Enrols the fake admin and returns the secret plus the issued codes. */ +async function enrolFully() { + const { secret } = await startSetup(USER_ID); + const codes = await confirmSetup(USER_ID, authenticator.generate(secret)); + return { secret, codes }; +} + +/** + * Accepted TOTP codes are burned, so a test needing a second valid code has to + * reach the next 30-second step. Waiting for it in real time would make the + * suite crawl, so the clock is moved forward instead — both otplib and the + * service read the same `Date.now`, so they stay consistent. + */ +const TOTP_STEP_SECONDS = 30; + +const realNow = Date.now.bind(Date); +let clockOffset = 0; +Date.now = () => realNow() + clockOffset; + +const nextStepCode = (secret: string): string => { + clockOffset += (TOTP_STEP_SECONDS + 1) * 1000; + return authenticator.generate(secret); +}; + +describe('twoFactorService — behaviour across calls', () => { + beforeEach(() => { + mockQuery.mockReset(); + clockOffset = 0; + }); + + describe('brute-force lockout', () => { + it('locks verification on the fifth consecutive failure', async () => { + installFakeDatabase(); + const { secret } = await enrolFully(); + + // The first four failures are reported as bad codes, not as a lockout. + for (let attempt = 1; attempt < MAX_FAILED_ATTEMPTS; attempt++) { + await expect(verifySecondFactor(USER_ID, '000000')).rejects.toMatchObject({ + code: 'INVALID_CODE', + status: 401, + }); + } + + // The fifth failure trips the lock. + await expect(verifySecondFactor(USER_ID, '000000')).rejects.toMatchObject({ + code: 'INVALID_CODE', + status: 401, + }); + + // A genuinely valid code is now refused with 429 rather than accepted. + await expect(verifySecondFactor(USER_ID, nextStepCode(secret))).rejects.toMatchObject({ + code: 'TWO_FACTOR_LOCKED', + status: 429, + }); + }); + + it('clears the failure count after a successful verification', async () => { + installFakeDatabase(); + const { secret } = await enrolFully(); + + for (let attempt = 0; attempt < MAX_FAILED_ATTEMPTS - 1; attempt++) { + await expect(verifySecondFactor(USER_ID, '000000')).rejects.toBeInstanceOf(TwoFactorError); + } + + await expect(verifySecondFactor(USER_ID, nextStepCode(secret))).resolves.toEqual({ + usedRecoveryCode: false, + }); + + // Four more failures must not lock, because the counter was reset. + for (let attempt = 0; attempt < MAX_FAILED_ATTEMPTS - 1; attempt++) { + await expect(verifySecondFactor(USER_ID, '000000')).rejects.toMatchObject({ + code: 'INVALID_CODE', + }); + } + }); + }); + + describe('single use under concurrency', () => { + it('accepts a recovery code exactly once across concurrent requests', async () => { + installFakeDatabase(); + const { codes } = await enrolFully(); + + const results = await Promise.allSettled( + Array.from({ length: 10 }, () => verifySecondFactor(USER_ID, codes[0])) + ); + + const accepted = results.filter((r) => r.status === 'fulfilled'); + expect(accepted).toHaveLength(1); + expect((accepted[0] as PromiseFulfilledResult).value).toEqual({ + usedRecoveryCode: true, + }); + }); + + it('accepts a TOTP code exactly once across concurrent requests', async () => { + installFakeDatabase(); + const { secret } = await enrolFully(); + const code = nextStepCode(secret); + + const results = await Promise.allSettled( + Array.from({ length: 10 }, () => verifySecondFactor(USER_ID, code)) + ); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + }); + + it('leaves the other recovery codes usable', async () => { + installFakeDatabase(); + const { codes } = await enrolFully(); + + await expect(verifySecondFactor(USER_ID, codes[0])).resolves.toEqual({ + usedRecoveryCode: true, + }); + await expect(verifySecondFactor(USER_ID, codes[1])).resolves.toEqual({ + usedRecoveryCode: true, + }); + + // …but neither of those two a second time. + await expect(verifySecondFactor(USER_ID, codes[0])).rejects.toMatchObject({ + code: 'INVALID_CODE', + }); + }); + + it('stores only hashes, so the issued codes never appear in the table', async () => { + const { recoveryCodes } = installFakeDatabase(); + const { codes } = await enrolFully(); + + expect(recoveryCodes.size).toBe(8); + expect([...recoveryCodes.keys()].sort()).toEqual(codes.map(hashRecoveryCode).sort()); + for (const code of codes) { + expect(recoveryCodes.has(code)).toBe(false); + } + }); + }); +}); diff --git a/backend/src/services/__tests__/twoFactorService.test.ts b/backend/src/services/__tests__/twoFactorService.test.ts new file mode 100644 index 00000000..ad4a9bae --- /dev/null +++ b/backend/src/services/__tests__/twoFactorService.test.ts @@ -0,0 +1,568 @@ +/** + * Unit tests for the TOTP two-factor service. + * + * The database layer is mocked, so no live PostgreSQL is required. Queries are + * routed by matching against the SQL text rather than by call order, which + * keeps the tests readable and insensitive to incidental reordering. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { authenticator } from '@otplib/preset-default'; + +// QR-code generation and the first ts-jest transform are slow enough that the +// 5s default can trip on a loaded machine. +jest.setTimeout(30_000); + +const mockQuery = jest.fn(); + +jest.unstable_mockModule('../../config/database.js', () => ({ + query: mockQuery, + pool: { query: mockQuery }, + default: { query: mockQuery }, +})); + +const { + RECOVERY_CODE_COUNT, + TwoFactorError, + confirmSetup, + disable, + generateRecoveryCodes, + getStatus, + hashRecoveryCode, + normalizeRecoveryCode, + startSetup, + verifySecondFactor, +} = await import('../twoFactorService.js'); + +type QueryResult = { rows: any[]; rowCount?: number }; +type Route = [RegExp, (params: any[]) => QueryResult]; + +/** Routes each mocked query to the first handler whose pattern matches. */ +function route(...routes: Route[]) { + mockQuery.mockImplementation(async (sql: string, params: any[] = []) => { + for (const [pattern, handler] of routes) { + if (pattern.test(sql)) return handler(params); + } + return { rows: [], rowCount: 0 }; + }); +} + +/** Every SQL statement the service issued, in order. */ +function issuedSql(): string[] { + return mockQuery.mock.calls.map((call: any[]) => String(call[0])); +} + +function sqlMatching(pattern: RegExp): string[] { + return issuedSql().filter((sql) => pattern.test(sql)); +} + +const SELECT_USER = /SELECT id, wallet_address, email, role/; +const COUNT_CODES = /SELECT COUNT\(\*\)/; +const UPDATE_STEP = /SET totp_last_used_step = \$2/; +const CONSUME_RECOVERY = /UPDATE user_recovery_codes/; +const INSERT_RECOVERY = /INSERT INTO user_recovery_codes/; +const RECORD_FAILURE = /two_factor_failed_attempts \+ 1/; + +function userRow(overrides: Record = {}) { + return { + id: 42, + wallet_address: 'GADMIN', + email: 'admin@payd.test', + role: 'ADMIN', + is_2fa_enabled: false, + totp_secret: null, + totp_pending_secret: null, + two_factor_enabled_at: null, + two_factor_locked_until: null, + // Computed by the database with the database's clock, never in JS. + is_locked: false, + ...overrides, + }; +} + +/** + * Runs enrolment far enough to learn both the plaintext secret (for generating + * valid codes) and the encrypted blob the service persisted. + * + * QR generation is slow, so the pair is produced once and shared: the tests + * only need *a* valid secret, not a distinct one each time. + */ +let enrolment: Promise<{ secret: string; encrypted: string }> | null = null; + +async function enrol(): Promise<{ secret: string; encrypted: string }> { + if (!enrolment) { + enrolment = (async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const { secret } = await startSetup(42); + const update = mockQuery.mock.calls.find((call: any[]) => + /totp_pending_secret = \$2/.test(String(call[0])) + ) as any[]; + + return { secret, encrypted: update[1][1] }; + })(); + } + + const result = await enrolment; + mockQuery.mockReset(); + return result; +} + +describe('twoFactorService', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + describe('recovery codes', () => { + it('generates exactly 8 codes by default', () => { + expect(RECOVERY_CODE_COUNT).toBe(8); + expect(generateRecoveryCodes()).toHaveLength(8); + }); + + it('generates distinct, readable, unambiguous codes', () => { + const codes = generateRecoveryCodes(); + + expect(new Set(codes).size).toBe(8); + for (const code of codes) { + expect(code).toMatch(/^[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}$/); + } + }); + + it('hashes case- and format-insensitively so codes can be typed back loosely', () => { + expect(normalizeRecoveryCode('abcde-fghij')).toBe('ABCDEFGHIJ'); + expect(hashRecoveryCode('abcde-fghij')).toBe(hashRecoveryCode('ABCDE FGHIJ')); + }); + + it('never stores a recovery code in a recoverable form', () => { + const hash = hashRecoveryCode('ABCDE-FGHIJ'); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(hash).not.toContain('ABCDE'); + }); + }); + + describe('startSetup', () => { + it('returns a scannable QR code and otpauth URL without enabling 2FA', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const setup = await startSetup(42); + + expect(setup.qrCode).toMatch(/^data:image\/png;base64,/); + expect(setup.otpauthUrl).toContain('otpauth://totp/'); + expect(setup.otpauthUrl).toContain('issuer=PayD'); + expect(setup.otpauthUrl).toContain(encodeURIComponent(setup.secret)); + // The secret must land in the *pending* column only. + expect(sqlMatching(/totp_pending_secret = \$2/)).toHaveLength(1); + expect(sqlMatching(/is_2fa_enabled = TRUE/)).toHaveLength(0); + }); + + it('encrypts the secret before it reaches the database', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + const { secret } = await startSetup(42); + const stored = ( + mockQuery.mock.calls.find((call: any[]) => + /totp_pending_secret = \$2/.test(String(call[0])) + ) as any[] + )[1][1]; + + expect(stored).not.toContain(secret); + expect(stored.startsWith('v1.')).toBe(true); + }); + + it('refuses to re-enrol while 2FA is already enabled', async () => { + route([SELECT_USER, () => ({ rows: [userRow({ is_2fa_enabled: true })], rowCount: 1 })]); + + await expect(startSetup(42)).rejects.toMatchObject({ status: 409, code: 'ALREADY_ENABLED' }); + }); + + it('reports a 404 for an unknown user', async () => { + route([SELECT_USER, () => ({ rows: [], rowCount: 0 })]); + + await expect(startSetup(999)).rejects.toBeInstanceOf(TwoFactorError); + }); + }); + + describe('confirmSetup', () => { + it('enables 2FA and issues 8 recovery codes for a valid code', async () => { + const { secret, encrypted } = await enrol(); + + route( + [SELECT_USER, () => ({ rows: [userRow({ totp_pending_secret: encrypted })], rowCount: 1 })], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + const codes = await confirmSetup(42, authenticator.generate(secret)); + + expect(codes).toHaveLength(8); + expect(sqlMatching(/is_2fa_enabled = TRUE/)).toHaveLength(1); + expect(sqlMatching(INSERT_RECOVERY)).toHaveLength(1); + + // Only hashes are persisted — never the plaintext codes. + const insert = mockQuery.mock.calls.find((call: any[]) => + INSERT_RECOVERY.test(String(call[0])) + ) as any[]; + expect(insert[1][1]).toEqual(codes.map(hashRecoveryCode)); + expect(insert[1][1]).not.toContain(codes[0]); + }); + + it('rejects an invalid code and leaves 2FA disabled', async () => { + const { encrypted } = await enrol(); + + route( + [SELECT_USER, () => ({ rows: [userRow({ totp_pending_secret: encrypted })], rowCount: 1 })], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + await expect(confirmSetup(42, '000000')).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + expect(sqlMatching(/is_2fa_enabled = TRUE/)).toHaveLength(0); + expect(sqlMatching(RECORD_FAILURE)).toHaveLength(1); + }); + + it('refuses to enable 2FA when setup was never started', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + await expect(confirmSetup(42, '123456')).rejects.toMatchObject({ + status: 400, + code: 'SETUP_NOT_STARTED', + }); + expect(sqlMatching(/is_2fa_enabled = TRUE/)).toHaveLength(0); + }); + }); + + describe('verifySecondFactor', () => { + it('accepts a current TOTP code', async () => { + const { secret, encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + await expect(verifySecondFactor(42, authenticator.generate(secret))).resolves.toEqual({ + usedRecoveryCode: false, + }); + }); + + it('rejects a TOTP code that was already spent', async () => { + const { secret, encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + // The guarded UPDATE matches no row when the step was already burned. + [UPDATE_STEP, () => ({ rows: [], rowCount: 0 })] + ); + + await expect(verifySecondFactor(42, authenticator.generate(secret))).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + }); + + it('accepts an unused recovery code and marks it consumed', async () => { + const { encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [CONSUME_RECOVERY, () => ({ rows: [{ id: 7 }], rowCount: 1 })] + ); + + await expect(verifySecondFactor(42, 'abcde-fghij')).resolves.toEqual({ + usedRecoveryCode: true, + }); + + const consume = mockQuery.mock.calls.find((call: any[]) => + CONSUME_RECOVERY.test(String(call[0])) + ) as any[]; + expect(consume[0]).toContain('used_at IS NULL'); + expect(consume[1][1]).toBe(hashRecoveryCode('abcde-fghij')); + }); + + it('rejects a recovery code that was already redeemed', async () => { + const { encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [CONSUME_RECOVERY, () => ({ rows: [], rowCount: 0 })] + ); + + await expect(verifySecondFactor(42, 'abcde-fghij')).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + }); + + it('refuses verification for an account without 2FA', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + await expect(verifySecondFactor(42, '123456')).rejects.toMatchObject({ + status: 400, + code: 'NOT_ENABLED', + }); + }); + + it('locks out verification while the account is in cool-off', async () => { + const { encrypted } = await enrol(); + + route([ + SELECT_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: encrypted, + two_factor_locked_until: new Date(Date.now() + 60_000), + is_locked: true, + }), + ], + rowCount: 1, + }), + ]); + + await expect(verifySecondFactor(42, '123456')).rejects.toMatchObject({ + status: 429, + code: 'TWO_FACTOR_LOCKED', + }); + }); + + it('asks the database whether the account is locked', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + await expect(verifySecondFactor(42, '123456')).rejects.toBeInstanceOf(TwoFactorError); + + // The lock decision has to be computed by Postgres against its own clock. + const select = sqlMatching(SELECT_USER)[0]; + expect(select).toContain('two_factor_locked_until > NOW()'); + expect(select).toContain('AS is_locked'); + }); + + it('stays locked even when the stored timestamp reads as past locally', async () => { + // Regression: two_factor_locked_until used to be a bare TIMESTAMP, which + // node-pg parses as *local* time. On a server east of UTC the value came + // back in the past, so a JS-side comparison silently skipped the lockout + // and brute-force attempts sailed through. The column is TIMESTAMPTZ now, + // and the decision comes from the database either way. + const { encrypted } = await enrol(); + + route([ + SELECT_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: encrypted, + two_factor_locked_until: new Date(Date.now() - 60 * 60_000), + is_locked: true, + }), + ], + rowCount: 1, + }), + ]); + + await expect(verifySecondFactor(42, '123456')).rejects.toMatchObject({ + status: 429, + code: 'TWO_FACTOR_LOCKED', + }); + }); + + it('restarts the failure count after a lockout has expired', async () => { + const { encrypted } = await enrol(); + + route([ + SELECT_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: encrypted, + two_factor_locked_until: new Date(Date.now() - 60_000), + }), + ], + rowCount: 1, + }), + ]); + + await expect(verifySecondFactor(42, '000000')).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + + // Serving one cool-off must not leave the account one mistake from the next. + const failure = sqlMatching(RECORD_FAILURE)[0]; + expect(failure).toContain('two_factor_locked_until <= NOW() THEN 1'); + }); + + it('ignores a lockout that has already expired', async () => { + const { secret, encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: encrypted, + two_factor_locked_until: new Date(Date.now() - 60_000), + is_locked: false, + }), + ], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + await expect(verifySecondFactor(42, authenticator.generate(secret))).resolves.toEqual({ + usedRecoveryCode: false, + }); + }); + }); + + describe('disable', () => { + it('clears the secret and recovery codes for a valid TOTP code', async () => { + const { secret, encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + await disable(42, authenticator.generate(secret)); + + expect(sqlMatching(/is_2fa_enabled = FALSE/)).toHaveLength(1); + expect(sqlMatching(/DELETE FROM user_recovery_codes/)).toHaveLength(1); + const clear = sqlMatching(/is_2fa_enabled = FALSE/)[0]; + expect(clear).toContain('totp_secret = NULL'); + }); + + it('refuses to disable without a valid current TOTP code', async () => { + const { encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [UPDATE_STEP, () => ({ rows: [{ id: 42 }], rowCount: 1 })] + ); + + await expect(disable(42, '000000')).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + expect(sqlMatching(/is_2fa_enabled = FALSE/)).toHaveLength(0); + }); + + it('does not accept a recovery code in place of a TOTP code', async () => { + const { encrypted } = await enrol(); + + route( + [ + SELECT_USER, + () => ({ + rows: [userRow({ is_2fa_enabled: true, totp_secret: encrypted })], + rowCount: 1, + }), + ], + [CONSUME_RECOVERY, () => ({ rows: [{ id: 7 }], rowCount: 1 })] + ); + + await expect(disable(42, 'abcde-fghij')).rejects.toMatchObject({ + status: 401, + code: 'INVALID_CODE', + }); + expect(sqlMatching(CONSUME_RECOVERY)).toHaveLength(0); + expect(sqlMatching(/is_2fa_enabled = FALSE/)).toHaveLength(0); + }); + + it('refuses when 2FA is not enabled', async () => { + route([SELECT_USER, () => ({ rows: [userRow()], rowCount: 1 })]); + + await expect(disable(42, '123456')).rejects.toMatchObject({ + status: 400, + code: 'NOT_ENABLED', + }); + }); + }); + + describe('getStatus', () => { + it('reports enrolment state without leaking the secret', async () => { + const enabledAt = new Date('2026-01-02T03:04:05Z'); + + route( + [ + SELECT_USER, + () => ({ + rows: [ + userRow({ + is_2fa_enabled: true, + totp_secret: 'v1.secret', + two_factor_enabled_at: enabledAt, + }), + ], + rowCount: 1, + }), + ], + [COUNT_CODES, () => ({ rows: [{ count: 6 }], rowCount: 1 })] + ); + + const status = await getStatus(42); + + expect(status).toEqual({ + enabled: true, + enabledAt: enabledAt.toISOString(), + setupPending: false, + recoveryCodesRemaining: 6, + }); + expect(JSON.stringify(status)).not.toContain('secret'); + }); + + it('flags a setup that was started but never confirmed', async () => { + route( + [ + SELECT_USER, + () => ({ rows: [userRow({ totp_pending_secret: 'v1.pending' })], rowCount: 1 }), + ], + [COUNT_CODES, () => ({ rows: [{ count: 0 }], rowCount: 1 })] + ); + + await expect(getStatus(42)).resolves.toMatchObject({ enabled: false, setupPending: true }); + }); + }); +}); diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index a669d327..32608778 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -1,6 +1,26 @@ import jwt from 'jsonwebtoken'; import { config } from '../config/env.js'; +/** + * Token kinds carried in the `typ` claim. + * + * A challenge token is handed out after a password/wallet check when the + * account still owes a second factor. It is signed with the same secret as an + * access token, so `typ` is what keeps the two apart: `authenticateJWT` rejects + * anything that is not an access token, which stops a challenge token from + * being replayed against the rest of the API. + */ +export const TOKEN_TYPE_ACCESS = 'access'; +export const TOKEN_TYPE_2FA_CHALLENGE = '2fa_challenge'; + +/** How long an admin has to enter their TOTP code before re-authenticating. */ +export const TWO_FACTOR_CHALLENGE_TTL = '5m'; + +export interface TwoFactorChallengeClaims { + id: number; + typ: typeof TOKEN_TYPE_2FA_CHALLENGE; +} + export const generateToken = (user: any) => { return jwt.sign( { @@ -9,8 +29,41 @@ export const generateToken = (user: any) => { email: user.email ?? null, organizationId: user.organization_id ?? user.organizationId ?? null, role: user.role, + typ: TOKEN_TYPE_ACCESS, }, config.JWT_SECRET, { expiresIn: '1h' } ); }; + +export const generateRefreshToken = (user: any) => { + return jwt.sign({ id: user.id }, config.JWT_REFRESH_SECRET, { expiresIn: '7d' }); +}; + +/** + * Issues the short-lived token that binds the second-factor step to the + * identity that just completed the first factor. It grants no API access. + */ +export const generateTwoFactorChallengeToken = (userId: number) => { + return jwt.sign({ id: userId, typ: TOKEN_TYPE_2FA_CHALLENGE }, config.JWT_SECRET, { + expiresIn: TWO_FACTOR_CHALLENGE_TTL, + }); +}; + +/** + * Verifies a challenge token and returns the user id it was minted for, or + * `null` when the token is missing, expired, tampered with, or of another kind. + */ +export const verifyTwoFactorChallengeToken = (token: unknown): number | null => { + if (typeof token !== 'string' || token.length === 0) return null; + + try { + const decoded = jwt.verify(token, config.JWT_SECRET) as Partial; + if (decoded?.typ !== TOKEN_TYPE_2FA_CHALLENGE || typeof decoded.id !== 'number') { + return null; + } + return decoded.id; + } catch { + return null; + } +}; diff --git a/backend/src/services/twoFactorService.ts b/backend/src/services/twoFactorService.ts new file mode 100644 index 00000000..2ec1e10e --- /dev/null +++ b/backend/src/services/twoFactorService.ts @@ -0,0 +1,502 @@ +/** + * TOTP-based two-factor authentication for privileged (admin) accounts. + * + * Enrolment is a two-step handshake: `startSetup` stores a *pending* secret and + * hands back a QR code, and `confirmSetup` only promotes that secret — and only + * then issues recovery codes — once the admin proves possession of the + * authenticator. An abandoned setup therefore never enables 2FA. + * + * Secrets are encrypted at rest, recovery codes are stored as single-use + * hashes, accepted TOTP codes are burned so they cannot be replayed, and + * repeated failures lock verification for a cool-off period. + */ + +import crypto from 'crypto'; +import { authenticator } from '@otplib/preset-default'; +import QRCode from 'qrcode'; +import { query } from '../config/database.js'; +import { config } from '../config/env.js'; +import { UserRole } from '../types/auth.js'; + +/** Number of recovery codes handed out when 2FA is enabled. */ +export const RECOVERY_CODE_COUNT = 8; + +/** Length of the TOTP time step, in seconds. Matches the otplib default. */ +const TOTP_STEP_SECONDS = 30; + +/** Steps of clock drift tolerated on either side of the current one. */ +const TOTP_WINDOW = 1; + +/** Consecutive failures before verification is locked for this account. */ +const MAX_FAILED_ATTEMPTS = 5; + +/** How long verification stays locked once the failure limit is hit. */ +const LOCKOUT_MS = 15 * 60 * 1000; + +/** + * Roles allowed to enrol in 2FA — the admin-level roles. `EMPLOYER` is included + * because it is the role that actually carries admin privileges across the + * codebase (payroll, employees, schedules, assets). + */ +export const TWO_FACTOR_ROLES: UserRole[] = ['ADMIN', 'EMPLOYER']; + +/** + * Alphabet for recovery codes: Crockford base32 minus the characters that are + * easy to confuse when a code is read off a screen and typed back in. + */ +const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +const ENCRYPTION_PREFIX = 'v1'; + +/** + * otplib instance with an explicit drift window. `clone` keeps the preset's + * crypto plugins, which a bare `create` would drop. + */ +const totp = authenticator.clone({ + step: TOTP_STEP_SECONDS, + window: TOTP_WINDOW, +}); + +export class TwoFactorError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code: string + ) { + super(message); + this.name = 'TwoFactorError'; + } +} + +export interface TwoFactorStatus { + enabled: boolean; + enabledAt: string | null; + setupPending: boolean; + recoveryCodesRemaining: number; +} + +export interface TwoFactorSetup { + secret: string; + otpauthUrl: string; + qrCode: string; +} + +// ─── Encryption at rest ────────────────────────────────────────────────────── + +function encryptionKey(): Buffer { + const material = config.TWO_FACTOR_ENCRYPTION_KEY || config.JWT_SECRET; + // The key material is an arbitrary-length passphrase; hash it down to the + // 32 bytes AES-256 requires. + return crypto.createHash('sha256').update(material).digest(); +} + +function encryptSecret(secret: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [ + ENCRYPTION_PREFIX, + iv.toString('base64'), + tag.toString('base64'), + ciphertext.toString('base64'), + ].join('.'); +} + +function decryptSecret(stored: string): string { + const parts = stored.split('.'); + if (parts.length !== 4 || parts[0] !== ENCRYPTION_PREFIX) { + throw new TwoFactorError( + 'Stored 2FA secret is unreadable. Re-run 2FA setup.', + 500, + 'SECRET_UNREADABLE' + ); + } + + const [, iv, tag, ciphertext] = parts; + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + encryptionKey(), + Buffer.from(iv, 'base64') + ); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'base64')), + decipher.final(), + ]).toString('utf8'); +} + +// ─── Codes ─────────────────────────────────────────────────────────────────── + +/** `true` when the value looks like a TOTP code, without touching any secret. */ +export function isTotpCodeShaped(code: unknown): code is string { + return typeof code === 'string' && /^\d{6}$/.test(code.trim()); +} + +/** + * Generates {@link RECOVERY_CODE_COUNT} distinct recovery codes formatted as + * `XXXXX-XXXXX`. Randomness comes from `crypto.randomBytes`, mapped onto the + * alphabet by rejection sampling so every character stays uniformly likely. + */ +export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): string[] { + const codes = new Set(); + while (codes.size < count) { + const chars: string[] = []; + while (chars.length < 10) { + for (const byte of crypto.randomBytes(16)) { + // 256 is not a multiple of 32, but 32 divides 256 exactly, so a plain + // mask over the low 5 bits is already uniform. + chars.push(RECOVERY_ALPHABET[byte & 0x1f]); + if (chars.length === 10) break; + } + } + codes.add(`${chars.slice(0, 5).join('')}-${chars.slice(5).join('')}`); + } + return [...codes]; +} + +/** Strips formatting so `abcde-fghij` and `ABCDEFGHIJ` hash identically. */ +export function normalizeRecoveryCode(code: string): string { + return code.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); +} + +export function hashRecoveryCode(code: string): string { + return crypto.createHash('sha256').update(normalizeRecoveryCode(code)).digest('hex'); +} + +// ─── Lockout ───────────────────────────────────────────────────────────────── + +/** + * Rejects the request when the account is in its brute-force cool-off. + * + * The decision comes from `isLocked`, which the database computes with its own + * clock — never from comparing the timestamp here. A bare `TIMESTAMP` column + * comes back through node-pg parsed as local time, so on a server whose + * timezone is not UTC a JS-side comparison would put the lock in the past and + * silently let brute-force attempts through. The timestamp is used only to tell + * the caller how long is left. + */ +function assertNotLocked(isLocked: boolean, lockedUntil: Date | string | null): void { + if (!isLocked) return; + + const remainingMs = lockedUntil ? new Date(lockedUntil).getTime() - Date.now() : 0; + const seconds = Math.max(1, Math.ceil(remainingMs / 1000)); + + throw new TwoFactorError( + `Too many failed 2FA attempts. Try again in ${seconds} seconds.`, + 429, + 'TWO_FACTOR_LOCKED' + ); +} + +async function recordFailure(userId: number): Promise { + // A failure after a lockout has expired starts a fresh count, so serving one + // cool-off does not leave the account a single mistake away from the next. + await query( + `UPDATE users + SET two_factor_failed_attempts = CASE + WHEN two_factor_locked_until IS NOT NULL AND two_factor_locked_until <= NOW() THEN 1 + ELSE two_factor_failed_attempts + 1 + END, + two_factor_locked_until = CASE + WHEN two_factor_locked_until IS NOT NULL AND two_factor_locked_until <= NOW() THEN NULL + WHEN two_factor_failed_attempts + 1 >= $2 + THEN NOW() + ($3 || ' milliseconds')::interval + ELSE two_factor_locked_until + END + WHERE id = $1`, + [userId, MAX_FAILED_ATTEMPTS, String(LOCKOUT_MS)] + ); +} + +async function clearFailures(userId: number): Promise { + await query( + `UPDATE users + SET two_factor_failed_attempts = 0, + two_factor_locked_until = NULL + WHERE id = $1`, + [userId] + ); +} + +// ─── TOTP verification ─────────────────────────────────────────────────────── + +/** + * Checks `code` against `secret` and, on success, burns the time step it used + * so the same code cannot be presented twice. + */ +async function consumeTotpCode(userId: number, code: string, secret: string): Promise { + let delta: number | null; + try { + delta = totp.checkDelta(code.trim(), secret); + } catch { + // A malformed secret or code makes otplib throw; treat it as a failure + // rather than surfacing anything about the stored secret. + return false; + } + + if (delta === null) return false; + + const step = Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + delta; + + // Accept only if this step is newer than the last one spent. The condition + // lives in the UPDATE so two concurrent requests cannot both win. + const result = await query( + `UPDATE users + SET totp_last_used_step = $2 + WHERE id = $1 + AND (totp_last_used_step IS NULL OR totp_last_used_step < $2) + RETURNING id`, + [userId, step] + ); + + return (result.rowCount ?? 0) === 1; +} + +/** + * Consumes an unused recovery code. The `used_at IS NULL` guard is part of the + * UPDATE, so a code can only ever be redeemed once even under concurrency. + */ +async function consumeRecoveryCode(userId: number, code: string): Promise { + const result = await query( + `UPDATE user_recovery_codes + SET used_at = NOW() + WHERE user_id = $1 + AND code_hash = $2 + AND used_at IS NULL + RETURNING id`, + [userId, hashRecoveryCode(code)] + ); + + return (result.rowCount ?? 0) === 1; +} + +// ─── Queries ───────────────────────────────────────────────────────────────── + +interface UserTwoFactorRow { + id: number; + wallet_address: string | null; + email: string | null; + role: string; + is_2fa_enabled: boolean; + totp_secret: string | null; + totp_pending_secret: string | null; + two_factor_enabled_at: Date | null; + two_factor_locked_until: Date | null; + /** Computed by the database, using the database's clock. */ + is_locked: boolean; +} + +async function loadUser(userId: number): Promise { + const result = await query( + `SELECT id, wallet_address, email, role, is_2fa_enabled, totp_secret, + totp_pending_secret, two_factor_enabled_at, two_factor_locked_until, + (two_factor_locked_until IS NOT NULL AND two_factor_locked_until > NOW()) + AS is_locked + FROM users + WHERE id = $1`, + [userId] + ); + + if (result.rows.length === 0) { + throw new TwoFactorError('User not found', 404, 'USER_NOT_FOUND'); + } + + return result.rows[0] as UserTwoFactorRow; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export async function getStatus(userId: number): Promise { + const user = await loadUser(userId); + const remaining = await query( + 'SELECT COUNT(*)::int AS count FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL', + [userId] + ); + + return { + enabled: Boolean(user.is_2fa_enabled), + enabledAt: user.two_factor_enabled_at + ? new Date(user.two_factor_enabled_at).toISOString() + : null, + setupPending: !user.is_2fa_enabled && Boolean(user.totp_pending_secret), + recoveryCodesRemaining: remaining.rows[0]?.count ?? 0, + }; +} + +/** + * Step 1 of enrolment: mint a secret, store it as *pending*, and return the + * otpauth URL plus a QR code for the authenticator app. 2FA stays disabled. + */ +export async function startSetup(userId: number): Promise { + const user = await loadUser(userId); + + if (user.is_2fa_enabled) { + throw new TwoFactorError( + '2FA is already enabled. Disable it before enrolling a new device.', + 409, + 'ALREADY_ENABLED' + ); + } + + const secret = totp.generateSecret(); + const accountName = user.email || user.wallet_address || `user-${user.id}`; + const otpauthUrl = totp.keyuri(accountName, config.TWO_FACTOR_ISSUER, secret); + const qrCode = await QRCode.toDataURL(otpauthUrl); + + // A fresh setup restarts the handshake: any earlier pending secret and any + // stale lockout are discarded. + await query( + `UPDATE users + SET totp_pending_secret = $2, + two_factor_failed_attempts = 0, + two_factor_locked_until = NULL + WHERE id = $1`, + [userId, encryptSecret(secret)] + ); + + return { secret, otpauthUrl, qrCode }; +} + +/** + * Step 2 of enrolment: verify a code from the pending secret, then enable 2FA + * and issue exactly {@link RECOVERY_CODE_COUNT} recovery codes. The plaintext + * codes are returned once here and never stored or logged. + */ +export async function confirmSetup(userId: number, code: string): Promise { + const user = await loadUser(userId); + + if (user.is_2fa_enabled) { + throw new TwoFactorError('2FA is already enabled', 409, 'ALREADY_ENABLED'); + } + + if (!user.totp_pending_secret) { + throw new TwoFactorError('Start 2FA setup before verifying a code', 400, 'SETUP_NOT_STARTED'); + } + + assertNotLocked(user.is_locked, user.two_factor_locked_until); + + if (!isTotpCodeShaped(code)) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + const verified = await consumeTotpCode(userId, code, decryptSecret(user.totp_pending_secret)); + if (!verified) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + const recoveryCodes = generateRecoveryCodes(); + + await query( + `UPDATE users + SET totp_secret = totp_pending_secret, + totp_pending_secret = NULL, + is_2fa_enabled = TRUE, + two_factor_enabled_at = NOW(), + two_factor_failed_attempts = 0, + two_factor_locked_until = NULL + WHERE id = $1`, + [userId] + ); + + // Re-enrolment starts from a clean set; codes from a previous enrolment are + // meaningless once the secret changes. + await query('DELETE FROM user_recovery_codes WHERE user_id = $1', [userId]); + await query( + `INSERT INTO user_recovery_codes (user_id, code_hash) + SELECT $1, UNNEST($2::text[])`, + [userId, recoveryCodes.map(hashRecoveryCode)] + ); + + return recoveryCodes; +} + +/** + * Verifies a second factor for an account that already has 2FA enabled. Accepts + * either a TOTP code or one of the recovery codes, each usable only once. + */ +export async function verifySecondFactor( + userId: number, + code: string +): Promise<{ usedRecoveryCode: boolean }> { + const user = await loadUser(userId); + + if (!user.is_2fa_enabled || !user.totp_secret) { + throw new TwoFactorError('2FA is not enabled for this account', 400, 'NOT_ENABLED'); + } + + assertNotLocked(user.is_locked, user.two_factor_locked_until); + + if (typeof code !== 'string' || code.trim().length === 0) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + // A six-digit value is a TOTP code; anything else is treated as a recovery + // code, so the two never fall back onto each other. + const usedRecoveryCode = !isTotpCodeShaped(code); + const verified = usedRecoveryCode + ? await consumeRecoveryCode(userId, code) + : await consumeTotpCode(userId, code, decryptSecret(user.totp_secret)); + + if (!verified) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + await clearFailures(userId); + return { usedRecoveryCode }; +} + +/** + * Disables 2FA. Requires a current TOTP code — recovery codes are deliberately + * not accepted here, so a leaked recovery code cannot strip the second factor. + */ +export async function disable(userId: number, code: string): Promise { + const user = await loadUser(userId); + + if (!user.is_2fa_enabled || !user.totp_secret) { + throw new TwoFactorError('2FA is not enabled for this account', 400, 'NOT_ENABLED'); + } + + assertNotLocked(user.is_locked, user.two_factor_locked_until); + + if (!isTotpCodeShaped(code)) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + const verified = await consumeTotpCode(userId, code, decryptSecret(user.totp_secret)); + if (!verified) { + await recordFailure(userId); + throw new TwoFactorError('Invalid 2FA code', 401, 'INVALID_CODE'); + } + + await query( + `UPDATE users + SET is_2fa_enabled = FALSE, + totp_secret = NULL, + totp_pending_secret = NULL, + two_factor_enabled_at = NULL, + totp_last_used_step = NULL, + two_factor_failed_attempts = 0, + two_factor_locked_until = NULL + WHERE id = $1`, + [userId] + ); + + await query('DELETE FROM user_recovery_codes WHERE user_id = $1', [userId]); +} + +export const twoFactorService = { + RECOVERY_CODE_COUNT, + getStatus, + startSetup, + confirmSetup, + verifySecondFactor, + disable, +}; + +export default twoFactorService; diff --git a/docs/TWO_FACTOR_AUTH.md b/docs/TWO_FACTOR_AUTH.md new file mode 100644 index 00000000..9a9724e8 --- /dev/null +++ b/docs/TWO_FACTOR_AUTH.md @@ -0,0 +1,76 @@ +# Two-Factor Authentication for Admin Accounts + +PayD admin accounts hold elevated privileges — payroll execution, employee management, organization settings — so they can be protected with a TOTP second factor (RFC 6238), compatible with Google Authenticator, 1Password, Aegis, and any other standard authenticator app. + +## Who can enrol + +Enrolment endpoints are limited to the privileged roles: `ADMIN` and `EMPLOYER`. `EMPLOYER` is the role that actually carries admin privileges throughout the codebase (payroll, employees, schedules, assets), and `ADMIN` is now a usable role — migration `029` widened the `users.role` CHECK constraint, which previously rejected it. + +The account being modified always comes from the verified JWT, never from the request body, so no caller can enrol or disable 2FA on someone else's account. + +## Enrolment is a two-step handshake + +Setup never enables 2FA on its own. The secret is parked in `users.totp_pending_secret` and is only promoted to `users.totp_secret` once a code from the authenticator is verified, so an abandoned setup leaves the account exactly as it was. + +``` +POST /api/auth/2fa/setup → { qrCode, otpauthUrl, secret } (2FA still off) +POST /api/auth/2fa/verify → { recoveryCodes: [8 codes] } (2FA now on) +``` + +The eight recovery codes are returned exactly once, by `/2fa/verify`. Only their SHA-256 hashes are stored. + +## Login requires the second factor + +`POST /api/auth/login` does not issue a session for an account with 2FA enabled. It returns a short-lived (5 minute) challenge token instead, which is exchanged for a real session only alongside a valid code: + +``` +POST /api/auth/login → { requires2fa: true, challengeToken } +POST /api/auth/2fa/authenticate → { accessToken, refreshToken, ... } +``` + +The OAuth callbacks (`/auth/google/callback`, `/auth/github/callback`) follow the same rule — an admin with 2FA enabled is redirected to `/auth-callback?requires2fa=1&challengeToken=…` rather than handed a token, so signing in with a social provider cannot sidestep the second factor. + +Challenge tokens are signed with `JWT_SECRET` like access tokens, so they carry a `typ` claim to keep them apart. `authenticateJWT` rejects any token whose `typ` is not `access`, which stops a challenge token being replayed against the rest of the API. Tokens minted before `typ` existed are still accepted. + +## Endpoints + +| Method | Path | Auth | Purpose | +| ------ | ----------------------------- | ----------------- | ---------------------------------------------------- | +| `POST` | `/api/auth/2fa/setup` | JWT + admin role | Start enrolment; returns QR code and otpauth URL | +| `POST` | `/api/auth/2fa/verify` | JWT + admin role | Confirm a code, enable 2FA, return 8 recovery codes | +| `POST` | `/api/auth/2fa/disable` | JWT + admin role | Disable 2FA; requires a current TOTP code | +| `GET` | `/api/auth/2fa/status` | JWT | Enrolment state and unused recovery-code count | +| `POST` | `/api/auth/2fa/authenticate` | challenge token | Second step of login; returns access/refresh tokens | + +`/2fa/authenticate` accepts either a 6-digit TOTP code or a recovery code in the `code` field. Everything else accepts TOTP codes only. + +Disabling deliberately refuses recovery codes: a leaked recovery code should let its owner back in, not let anyone strip the second factor off the account. + +## Step-up for sensitive operations + +`require2FA` (applied to the SEP-31 and SEP-24 payment routes) lets accounts without 2FA through and requires a fresh code in the `x-2fa-token` header from accounts that have it enabled. The code is consumed on use, so the same header cannot be replayed on a second request; a caller making back-to-back sensitive requests needs a new code for each. + +## Security properties + +- **Secrets encrypted at rest.** TOTP secrets are stored as AES-256-GCM ciphertext (`v1...`), keyed by `TWO_FACTOR_ENCRYPTION_KEY` and falling back to `JWT_SECRET` for local development. Set a dedicated key in production. +- **Recovery codes are single-use hashes.** Codes live in `user_recovery_codes` as SHA-256 hashes and are redeemed by an `UPDATE … WHERE used_at IS NULL`, so a code cannot be redeemed twice even under concurrent requests. +- **TOTP codes are single-use.** `users.totp_last_used_step` records the highest time step already spent; a code is accepted only when its step is strictly greater, which closes the replay window a code would otherwise have for the rest of its 30-second period. +- **Clock drift.** One step (30 seconds) either side of the current one is accepted. +- **Brute-force lockout.** Five consecutive failures lock verification for 15 minutes; a success clears the counter, and an expired lockout restarts the count rather than leaving the account one mistake from the next. Whether an account is currently locked is decided by Postgres (`two_factor_locked_until > NOW()`), never by comparing the timestamp in Node — `two_factor_locked_until` is `TIMESTAMPTZ` for the same reason. A bare `TIMESTAMP` comes back through node-pg parsed as local time, which on a server east of UTC lands in the past and silently disables the lockout entirely. +- **Nothing sensitive is logged.** Secrets, recovery codes, and submitted codes never reach the logs, and failures are reported as a generic `INVALID_CODE` so they do not distinguish "wrong code" from "no such enrolment". + +## Configuration + +| Variable | Default | Purpose | +| --------------------------- | -------------------- | ------------------------------------------------ | +| `TWO_FACTOR_ENCRYPTION_KEY` | falls back to `JWT_SECRET` | Key material for encrypting TOTP secrets | +| `TWO_FACTOR_ISSUER` | `PayD` | Issuer name shown in the authenticator app | + +## Migration notes + +Migration `029_admin_two_factor_auth.sql`: + +- widens the `users.role` CHECK constraint to include `ADMIN`; +- adds `totp_pending_secret`, `two_factor_enabled_at`, `totp_last_used_step`, `two_factor_failed_attempts`, `two_factor_locked_until` (the timestamp columns are `TIMESTAMPTZ`, see the lockout note above); +- creates `user_recovery_codes` and drops the plaintext `users.recovery_codes` array; +- clears any pre-existing plaintext `totp_secret`, since those predate encryption at rest. Affected admins re-run setup from **Settings → Two-Factor Authentication**. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35d47f8f..3f3ab9b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import ErrorBoundary from './components/ErrorBoundary'; import ErrorFallback from './components/ErrorFallback'; import Settings from './pages/Settings'; import WebhookSettings from './pages/WebhookSettings'; +import TwoFactorSettings from './pages/TwoFactorSettings'; import CustomReportBuilder from './pages/CustomReportBuilder'; import CrossAssetPayment from './pages/CrossAssetPayment'; import TransactionHistory from './pages/TransactionHistory'; @@ -160,6 +161,14 @@ function App() { } /> + {}} />}> + + + } + /> { const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const { t } = useTranslation(); + + // Present only when the account has 2FA enabled: the first factor succeeded + // but no session is issued until a one-time code is supplied. + const challengeToken = searchParams.get('challengeToken'); + + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { + if (challengeToken) return; + const token = searchParams.get('token'); if (token) { localStorage.setItem('payd_auth_token', token); @@ -14,7 +27,63 @@ const AuthCallback: React.FC = () => { } else { void navigate('/login?error=no_token'); } - }, [searchParams, navigate]); + }, [searchParams, navigate, challengeToken]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!challengeToken) return; + + setError(null); + setIsSubmitting(true); + try { + const session = await completeTwoFactorLogin(challengeToken, code.trim()); + localStorage.setItem('payd_auth_token', session.accessToken); + void navigate('/'); + } catch (submitError) { + setError(twoFactorErrorMessage(submitError, t('twoFactor.errors.loginFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + if (challengeToken) { + return ( +
+
{ + void handleSubmit(event); + }} + className="glass noise p-10 rounded-3xl max-w-md w-full border border-white/10 shadow-2xl flex flex-col gap-4" + > +

{t('twoFactor.loginTitle')}

+

{t('twoFactor.loginDescription')}

+ + setCode(event.target.value)} + placeholder="123456" + className="w-full bg-black/20 border border-hi rounded-xl p-4 text-text text-center font-mono tracking-[0.5em] outline-none focus:border-accent/50 focus:bg-accent/5 transition-all" + /> + + {error ?

{error}

: null} + + + +

{t('twoFactor.loginRecoveryHint')}

+
+
+ ); + } return (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 022b40ca..c27ba70b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { Webhook } from 'lucide-react'; +import { Webhook, ShieldCheck } from 'lucide-react'; export default function Settings() { const { t, i18n } = useTranslation(); @@ -34,6 +34,21 @@ export default function Settings() {
+ +
+
+ +
+
+

{t('settings.twoFactorLabel')}

+

{t('settings.twoFactorDescription')}

+
+
+ + (null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [setup, setSetup] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [code, setCode] = useState(''); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasCopied, setHasCopied] = useState(false); + + const loadStatus = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + setStatus(await fetchTwoFactorStatus()); + } catch (loadError) { + setError(twoFactorErrorMessage(loadError, t('twoFactor.errors.loadFailed'))); + } finally { + setIsLoading(false); + } + }, [t]); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const handleStartSetup = async () => { + setFormError(null); + setIsSubmitting(true); + try { + setSetup(await startTwoFactorSetup()); + setCode(''); + } catch (setupError) { + setFormError(twoFactorErrorMessage(setupError, t('twoFactor.errors.setupFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + const handleEnable = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + setIsSubmitting(true); + try { + const result = await enableTwoFactor(code.trim()); + setRecoveryCodes(result.recoveryCodes); + setSetup(null); + setCode(''); + await loadStatus(); + } catch (enableError) { + setFormError(twoFactorErrorMessage(enableError, t('twoFactor.errors.enableFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + const handleDisable = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + setIsSubmitting(true); + try { + await disableTwoFactor(code.trim()); + setCode(''); + setRecoveryCodes(null); + await loadStatus(); + } catch (disableError) { + setFormError(twoFactorErrorMessage(disableError, t('twoFactor.errors.disableFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + const handleCopyCodes = async () => { + if (!recoveryCodes) return; + await navigator.clipboard.writeText(recoveryCodes.join('\n')); + setHasCopied(true); + window.setTimeout(() => setHasCopied(false), 2000); + }; + + const codeInput = (label: string) => ( +
+ + setCode(event.target.value)} + placeholder="123456" + className={`${inputClass} font-mono tracking-[0.5em]`} + /> +
+ ); + + return ( +
+
+
+

{t('twoFactor.title')}

+

{t('twoFactor.subtitle')}

+
+
+ + {isLoading ? ( +
+ + {t('twoFactor.loading')} +
+ ) : null} + + {error ?

{error}

: null} + + {!isLoading && status ? ( +
+

+ {status.enabled ? ( + + ) : ( + + )} + {status.enabled ? t('twoFactor.statusEnabled') : t('twoFactor.statusDisabled')} +

+

+ {status.enabled + ? t('twoFactor.statusEnabledDescription', { + count: status.recoveryCodesRemaining, + }) + : t('twoFactor.statusDisabledDescription')} +

+
+ ) : null} + + {/* Recovery codes are returned once, when 2FA is switched on. */} + {recoveryCodes ? ( +
+

+ + {t('twoFactor.recoveryTitle')} +

+

{t('twoFactor.recoveryDescription')}

+
    + {recoveryCodes.map((recoveryCode) => ( +
  • + {recoveryCode} +
  • + ))} +
+ +
+ ) : null} + + {!isLoading && status && !status.enabled ? ( +
+

{t('twoFactor.enableTitle')}

+ + {setup ? ( +
{ + void handleEnable(event); + }} + className="flex flex-col gap-4" + > +

{t('twoFactor.scanDescription')}

+ {t('twoFactor.qrAlt')} +
+ + {t('twoFactor.manualKeyLabel')} + + {setup.secret} +
+ + {codeInput(t('twoFactor.codeLabel'))} + + {formError ?

{formError}

: null} + + +
+ ) : ( +
+

{t('twoFactor.enableDescription')}

+ {formError ?

{formError}

: null} + +
+ )} +
+ ) : null} + + {!isLoading && status?.enabled ? ( +
{ + void handleDisable(event); + }} + className="w-full card glass noise p-8 flex flex-col gap-4" + > +

{t('twoFactor.disableTitle')}

+

{t('twoFactor.disableDescription')}

+ + {codeInput(t('twoFactor.codeLabel'))} + + {formError ?

{formError}

: null} + + +
+ ) : null} +
+ ); +} diff --git a/frontend/src/services/twoFactorApi.ts b/frontend/src/services/twoFactorApi.ts new file mode 100644 index 00000000..1a0af14a --- /dev/null +++ b/frontend/src/services/twoFactorApi.ts @@ -0,0 +1,96 @@ +import axios from 'axios'; + +const RAW_API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api/v1'; +const API_ROOT = RAW_API_URL.replace(/\/api\/v1\/?$/, '').replace(/\/api\/?$/, ''); +const TWO_FACTOR_URL = `${API_ROOT}/api/auth/2fa`; + +function authHeaders() { + const token = localStorage.getItem('payd_auth_token'); + return token ? { Authorization: `Bearer ${token}` } : undefined; +} + +export interface TwoFactorStatus { + enabled: boolean; + enabledAt: string | null; + setupPending: boolean; + recoveryCodesRemaining: number; +} + +export interface TwoFactorSetup { + /** Data URL of the QR code to scan with an authenticator app. */ + qrCode: string; + otpauthUrl: string; + /** Shown so the secret can be typed in manually when a QR scan is not possible. */ + secret: string; +} + +export interface TwoFactorEnableResult { + recoveryCodes: string[]; + recoveryCodeCount: number; +} + +/** Login response when the account still owes a second factor. */ +export interface TwoFactorChallenge { + requires2fa: true; + challengeToken: string; +} + +export interface TwoFactorSession { + accessToken: string; + refreshToken: string; + usedRecoveryCode: boolean; + recoveryCodesRemaining: number; +} + +export async function fetchTwoFactorStatus(): Promise { + const { data } = await axios.get(`${TWO_FACTOR_URL}/status`, { + headers: authHeaders(), + }); + return data; +} + +/** Step 1 of enrolment. 2FA stays off until {@link enableTwoFactor} succeeds. */ +export async function startTwoFactorSetup(): Promise { + const { data } = await axios.post( + `${TWO_FACTOR_URL}/setup`, + {}, + { headers: authHeaders() } + ); + return data; +} + +/** Step 2 of enrolment: confirms the code and returns the one-time recovery codes. */ +export async function enableTwoFactor(code: string): Promise { + const { data } = await axios.post( + `${TWO_FACTOR_URL}/verify`, + { code }, + { headers: authHeaders() } + ); + return data; +} + +/** Turns 2FA off. The backend requires a current TOTP code, not a recovery code. */ +export async function disableTwoFactor(code: string): Promise { + await axios.post(`${TWO_FACTOR_URL}/disable`, { code }, { headers: authHeaders() }); +} + +/** Second step of login: exchanges the challenge from /login for a session. */ +export async function completeTwoFactorLogin( + challengeToken: string, + code: string +): Promise { + const { data } = await axios.post(`${TWO_FACTOR_URL}/authenticate`, { + challengeToken, + code, + }); + return data; +} + +/** Pulls the API's error message out of an axios failure, with a fallback. */ +export function twoFactorErrorMessage(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + const message = (error.response?.data as { error?: string } | undefined)?.error; + if (message) return message; + } + return error instanceof Error ? error.message : fallback; +}