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
+1
View File
@@ -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
);
@@ -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);
});
+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({