slice 2a COMPLETE: TOTP 2FA (enable2fa/confirm2fa/login-2fa + protectedProcedure)

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.
This commit is contained in:
Investor Flow Build
2026-06-29 19:32:09 -04:00
parent 9df05ade71
commit 89bdf0edc4
3 changed files with 67 additions and 4 deletions
+39 -4
View File
@@ -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<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({