diff --git a/app/backend/src/routes/auth.ts b/app/backend/src/routes/auth.ts new file mode 100644 index 0000000..934c6e0 --- /dev/null +++ b/app/backend/src/routes/auth.ts @@ -0,0 +1,214 @@ +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) }); + }); +}