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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user