From 89bdf0edc47cd7f4ff99751fd2b4ffc86e250aef Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 29 Jun 2026 19:32:09 -0400 Subject: [PATCH] slice 2a COMPLETE: TOTP 2FA (enable2fa/confirm2fa/login-2fa + protectedProcedure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit totp.ts + backup-codes.ts produced by ornith (remote model, reviewed+approved); router/context/schema integration by orchestrator (delicate edit-existing — ornith timed out on a dispatch of that size in-session). 58/58 backend tests green. 2FA flow: enable2fa -> confirm2fa -> login now requires a valid TOTP. --- app/server/src/db/schema.sql | 1 + app/server/src/trpc/__tests__/router.test.ts | 27 ++++++++++++ app/server/src/trpc/router.ts | 43 ++++++++++++++++++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/app/server/src/db/schema.sql b/app/server/src/db/schema.sql index b0b540e..0ff0dea 100644 --- a/app/server/src/db/schema.sql +++ b/app/server/src/db/schema.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS users ( complexity TEXT NOT NULL DEFAULT 'beginner', -- beginner|intermediate|advanced risk_tolerance TEXT NOT NULL DEFAULT 'moderate', convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction + backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2) created_at TEXT NOT NULL ); diff --git a/app/server/src/trpc/__tests__/router.test.ts b/app/server/src/trpc/__tests__/router.test.ts index a73639a..9a72630 100644 --- a/app/server/src/trpc/__tests__/router.test.ts +++ b/app/server/src/trpc/__tests__/router.test.ts @@ -100,3 +100,30 @@ test('market.snapshot serves cached values (not stale) after drain populates cac assert.equal(snap.stale.candles, false); assert.equal(snap.stale.sector, false); }); + +// --- Slice 2: 2FA flow --- +import { totp as computeTotp } from '../../auth/totp.ts'; + +test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => { + const { freshCtx } = setup(); + // signup + const signupCtx = freshCtx(); + const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' }); + // enable2fa (protected: set ctx.userId) + const eCtx = freshCtx(); eCtx.userId = userId; + const en = await appRouter.createCaller(eCtx).auth.enable2fa({}); + assert.ok(en.totpSecret); + assert.ok(en.qrUrl.startsWith('otpauth://TOTP/')); + assert.equal(en.backupCodes.length, 10); + // confirm2fa with a valid code + const code = computeTotp(en.totpSecret); + const cCtx = freshCtx(); cCtx.userId = userId; + const c = await appRouter.createCaller(cCtx).auth.confirm2fa({ totp: code }); + assert.equal(c.ok, true); + // login WITHOUT totp now fails + await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'UNAUTHORIZED'); + // login WITH valid totp succeeds + const code2 = computeTotp(en.totpSecret); + const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 }); + assert.equal(res.userId, userId); +}); diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index 3e40af5..d13e416 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -1,16 +1,22 @@ -// Investor Flow — tRPC router (DESIGN.md §2.3). Slice 1: auth.signup/login/logout/me + market.snapshot. -// market.snapshot is the M1 mega-endpoint (quote + daily candles + sector overlay in one round trip). import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import type { Context } from './context.ts'; import { hashPassword, verifyPassword, createSession, clearCookie } from './context.ts'; +import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts'; +import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts'; import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts'; const t = initTRPC.context().create(); const router = t.router; const publicProcedure = t.procedure; +// Require an authenticated session for user-private procedures (DESIGN §2.2 SessionGuard). +const protectedProcedure = publicProcedure.use(({ ctx, next }) => { + if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' }); + return next({ ctx }); +}); + const authRouter = router({ signup: publicProcedure .input(z.object({ email: z.string().email(), password: z.string().min(8) })) @@ -25,11 +31,17 @@ const authRouter = router({ return { userId }; }), login: publicProcedure - .input(z.object({ email: z.string().email(), password: z.string() })) + .input(z.object({ email: z.string().email(), password: z.string(), totp: z.string().optional() })) .mutation(async ({ ctx, input }) => { const email = input.email.toLowerCase(); - const row = ctx.db.prepare('SELECT id, pw_hash FROM users WHERE email=?').get(email) as { id: string; pw_hash: string } | undefined; + const row = ctx.db.prepare('SELECT id, pw_hash, is_2fa_enabled, totp_secret FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null } | undefined; if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' }); + // Two-factor gate: if enabled, require a valid TOTP (slice 2). + if (row.is_2fa_enabled === 1) { + if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' }); + } + } const { cookie } = createSession(ctx.db, row.id); ctx.resHeaders.append('Set-Cookie', cookie); return { userId: row.id }; @@ -43,6 +55,29 @@ const authRouter = router({ const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined; return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture } : null; }), + // Slice 2 — TOTP 2FA enrollment (does not enable until confirm2fa). + enable2fa: protectedProcedure + .input(z.object({})) + .mutation(async ({ ctx }) => { + const userId = ctx.userId as string; + const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined; + const secret = generateBase32Secret(); + const codes = generateBackupCodes(10); + const hashes = codes.map((c) => hashBackupCode(c)); + ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId); + return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes }; + }), + // Slice 2 — confirm a TOTP to turn on 2FA. + confirm2fa: protectedProcedure + .input(z.object({ totp: z.string() })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.userId as string; + const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined; + if (!row?.totp_secret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Enable two-factor first.' }); + if (!verifyTotp(input.totp, row.totp_secret)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid two-factor code.' }); + ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId); + return { ok: true }; + }), }); const marketRouter = router({