import type { FastifyInstance } from 'fastify'; import { randomBytes } from 'node:crypto'; import { z } from 'zod'; import * as argon2 from '@node-rs/argon2'; import { prisma } from '../db.js'; import { redis } from '../redis.js'; import type { SessionData } from '../plugins/session.js'; import { verifyCaptcha } from '../lib/captcha.js'; import { issueOtp, verifyOtp } from '../lib/otp.js'; import { checkRateLimit, rateLimitResponse } from '../lib/rate-limit.js'; import { logSecurityEvent, recordLoginFailure, checkAccountLock } from '../lib/security-audit.js'; const LOGIN_FAIL_MAX = 5; const TWO_FACTOR_TTL_SECONDS = 5 * 60; const USERNAME_RE = /^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/; const loginSchema = z.object({ identifier: z.string().trim().min(3).max(254), password: z.string().min(1).max(256), captchaId: z.string().trim().min(1).max(64), userSolution: z.string().trim().min(1).max(20), }); const twoFactorVerifySchema = z.object({ ticket: z.string().trim().min(16).max(160), code: z.string().trim().regex(/^\d{6}$/), }); const twoFactorSettingsSchema = z.object({ enabled: z.coerce.boolean(), }); function publicUser(u: { id: string; email: string; phone: string | null; username: string | null; name: string; bio: string | null; avatarUrl: string | null; role: string; status: string; emailVerifiedAt: Date | null; phoneVerifiedAt: Date | null; twoFactorEnabled: boolean; }) { return { id: u.id, email: u.email, phone: u.phone, username: u.username, name: u.name, bio: u.bio, avatarUrl: u.avatarUrl, role: u.role, status: u.status, emailVerified: !!u.emailVerifiedAt, phoneVerified: !!u.phoneVerifiedAt, twoFactorEnabled: u.twoFactorEnabled, }; } export default async function authRoutes(app: FastifyInstance) { app.post('/api/v1/auth/login', async (req, reply) => { const parsed = loginSchema.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: 'invalid_input' }); } const { password, captchaId, userSolution } = parsed.data; const identifier = parsed.data.identifier.replace(/^@/, '').toLowerCase(); const captchaValid = await verifyCaptcha(captchaId, userSolution); if (!captchaValid) { return reply.code(400).send({ error: 'invalid_captcha' }); } const ip = req.ip; const failKey = `loginfail:${ip}`; const failCount = Number((await redis.get(failKey)) ?? 0); if (failCount >= LOGIN_FAIL_MAX) { const ttl = await redis.ttl(failKey); reply.header('Retry-After', String(Math.max(ttl, 1))); return reply.code(429).send({ error: 'too_many_attempts', retryAfter: ttl }); } const user = USERNAME_RE.test(identifier) && !identifier.includes('@') ? await prisma.user.findUnique({ where: { username: identifier } }) : await prisma.user.findUnique({ where: { email: identifier } }); const fakeHash = '$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const ok = await argon2 .verify(user?.passwordHash ?? fakeHash, password) .catch(() => false); if (!user || !ok) { const next = await redis.incr(failKey); if (next === 1) await redis.expire(failKey, 15 * 60); const { locked } = await recordLoginFailure(ip, user?.id ?? null, req.headers['user-agent']); if (locked) { return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 }); } return reply.code(401).send({ error: 'invalid_credentials' }); } if (user.status !== 'active') { return reply.code(403).send({ error: 'account_' + user.status }); } if (await checkAccountLock(user.id)) { return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 }); } if (user.twoFactorEnabled) { if (!user.phone) return reply.code(409).send({ error: 'two_factor_phone_required' }); const sent = await issueOtp(user.phone); if (!sent.sent) return reply.code(503).send({ error: 'two_factor_unavailable', reason: sent.reason }); const ticket = randomBytes(32).toString('base64url'); await redis.set(`2fa:${ticket}`, user.id, 'EX', TWO_FACTOR_TTL_SECONDS); await redis.del(failKey); return reply.send({ ok: true, twoFactorRequired: true, ticket }); } await redis.del(failKey); await logSecurityEvent({ type: 'login_success', userId: user.id, ip, userAgent: req.headers['user-agent'] }); const pca = user.passwordChangedAt?.getTime() ?? 0; if (req.session?.sid) { await reply.rotateSession({ userId: user.id, role: user.role as SessionData['role'], }); } else { await reply.createSession({ userId: user.id, role: user.role as SessionData['role'], }, pca); } await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }).catch(() => undefined); return reply.send({ ok: true, user: publicUser(user) }); }); app.post('/api/v1/auth/2fa/verify', async (req, reply) => { const rl = await checkRateLimit(req.ip, '2fa-verify', 5, 300); if (rl.limited) return rateLimitResponse(reply, rl.retryAfter); const parsed = twoFactorVerifySchema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const userId = await redis.get(`2fa:${parsed.data.ticket}`); if (!userId) return reply.code(400).send({ error: 'two_factor_ticket_expired' }); if (await checkAccountLock(userId)) { return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 }); } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user?.phone) return reply.code(400).send({ error: 'two_factor_phone_required' }); const ok = await verifyOtp(user.phone, parsed.data.code); if (!ok) { await logSecurityEvent({ type: '2fa_failure', userId, ip: req.ip, userAgent: req.headers['user-agent'] }); return reply.code(400).send({ error: 'invalid_otp' }); } await redis.del(`2fa:${parsed.data.ticket}`); await logSecurityEvent({ type: 'login_success', userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'] }); const pca = user.passwordChangedAt?.getTime() ?? 0; if (req.session?.sid) { await reply.rotateSession({ userId: user.id, role: user.role as SessionData['role'], }); } else { await reply.createSession({ userId: user.id, role: user.role as SessionData['role'], }, pca); } await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }).catch(() => undefined); return reply.send({ ok: true, user: publicUser(user) }); }); app.put('/api/v1/auth/2fa', async (req, reply) => { if (!req.session) return reply.code(401).send({ error: 'unauthenticated' }); const parsed = twoFactorSettingsSchema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const user = await prisma.user.findUnique({ where: { id: req.session.userId } }); if (!user) return reply.code(404).send({ error: 'user_not_found' }); if (parsed.data.enabled && !user.phone) return reply.code(409).send({ error: 'phone_required' }); const updated = await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: parsed.data.enabled, twoFactorMethod: parsed.data.enabled ? 'sms' : null }, }); await logSecurityEvent({ type: parsed.data.enabled ? '2fa_enabled' : '2fa_disabled', userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'], }); return reply.send({ ok: true, user: publicUser(updated) }); }); app.post('/api/v1/auth/logout', async (_req, reply) => { await reply.destroySession(); return reply.send({ ok: true }); }); app.get('/api/v1/auth/me', async (req, reply) => { if (!req.session) return reply.code(401).send({ error: 'unauthenticated' }); const user = await prisma.user.findUnique({ where: { id: req.session.userId } }); if (!user) { await reply.destroySession(); return reply.code(401).send({ error: 'unauthenticated' }); } return reply.send({ user: publicUser(user) }); }); }