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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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