import type { FastifyInstance } from "fastify"; import { db, eq, recoveryCodes, userSettings, users, wallets, dailyRewards, sql } from "@spinza/database"; import { STARTING_BALANCE, checkUsername, loginSchema, recoverSchema, registerSchema, passwordSchema } from "@spinza/shared"; import { errors } from "../lib/errors"; import { generateRecoveryCode, hashPassword, hashRecoveryCode, verifyPassword, verifyRecoveryCode } from "../lib/crypto"; import { rateLimit } from "../lib/redis"; import { createSession, destroyAllSessions, destroySession, requireUser } from "../plugins/auth"; import { clientIp, logSecurity } from "../lib/security"; import { flag, profanityWords, maintenance } from "../lib/settings"; import { applyCredit, lockWallet, saveWallet } from "../services/wallet"; import { z } from "zod"; import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; async function limit(req: Parameters[0], scope: string, max: number, windowSec: number) { const retry = await rateLimit(`${scope}:${clientIp(req)}`, max, windowSec); if (retry) { await logSecurity(req, "rate_limit", { meta: { scope } }); throw errors.rateLimited(retry); } } export async function authRoutes(app: FastifyInstance) { app.post("/api/auth/check-username", async (req) => { await limit(req, "check-username", 60, 60); const body = z.object({ username: z.string().max(64) }).parse(req.body); const check = checkUsername(body.username, profanityWords()); if (!check.ok) return { available: false, reason: check.reason }; const normalized = body.username.trim().toLowerCase(); const exists = await db.query.users.findFirst({ where: eq(users.usernameNormalized, normalized), columns: { id: true } }); return { available: !exists, reason: exists ? "taken" : undefined }; }); app.post("/api/auth/register", async (req, reply) => { await limit(req, "register", 10, 600); if (!flag("registration.enabled")) throw errors.forbidden("Registration is temporarily closed."); const m = maintenance(); if (m.enabled) throw errors.maintenance(m.message); const parsed = registerSchema.safeParse(req.body); if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten()); const { username, password } = parsed.data; const check = checkUsername(username, profanityWords()); if (!check.ok) { const msg = { length: "Username must be 3–24 characters.", charset: "Use lowercase letters, numbers, _ or -.", reserved: "This username is reserved.", profanity: "This username is not allowed." }[check.reason!]; throw errors.badRequest(msg, { field: "username" }); } const passwordHash = await hashPassword(password); const recoveryCode = generateRecoveryCode(); const codeHash = await hashRecoveryCode(recoveryCode); let userId: string; try { userId = await db.transaction(async (tx) => { const [u] = await tx .insert(users) .values({ username, usernameNormalized: username, passwordHash, ageConfirmedAt: new Date(), lastLoginAt: new Date() }) .returning({ id: users.id }); await tx.insert(wallets).values({ userId: u.id, balance: 0 }); await tx.insert(userSettings).values({ userId: u.id }); await tx.insert(recoveryCodes).values({ userId: u.id, codeHash }); await tx.insert(dailyRewards).values({ userId: u.id }); const w = await lockWallet(tx, u.id); await applyCredit(tx, w, "INITIAL_GRANT", STARTING_BALANCE, "welcome", { reason: "Welcome to Spinza" }); await saveWallet(tx, w); return u.id; }); } catch (e) { if (pgCode(e) === PG_UNIQUE_VIOLATION) throw errors.conflict("USERNAME_TAKEN", "That username is already taken."); throw e; } await createSession(reply, req, userId); await logSecurity(req, "register", { userId }); return { user: { id: userId, username, isNew: true }, balance: STARTING_BALANCE, recoveryCode, notice: "Save this recovery code. Spinza does not collect your email address. If you lose your password and recovery code, your account cannot be recovered.", }; }); app.post("/api/auth/login", async (req, reply) => { await limit(req, "login", 20, 300); const parsed = loginSchema.safeParse(req.body); if (!parsed.success) throw errors.unauthorized("Invalid username or password."); const { username, password } = parsed.data; const perUser = await rateLimit(`login-user:${username}`, 10, 300); if (perUser) throw errors.rateLimited(perUser); const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) }); const ok = u ? await verifyPassword(u.passwordHash, password) : await verifyPassword("$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password); if (!u || !ok) { await logSecurity(req, "login.failed", { userId: u?.id ?? null, meta: { username } }); throw errors.unauthorized("Invalid username or password."); } if (u.status !== "active") throw errors.forbidden("This account is suspended."); await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, u.id)); await createSession(reply, req, u.id); await logSecurity(req, "login.success", { userId: u.id }); return { user: { id: u.id, username: u.username } }; }); app.post("/api/auth/logout", async (req, reply) => { const uid = req.user?.id; await destroySession(reply, req); if (uid) await logSecurity(req, "logout", { userId: uid }); return { ok: true }; }); app.post("/api/auth/recover", async (req, reply) => { await limit(req, "recover", 8, 900); const parsed = recoverSchema.safeParse(req.body); if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten()); const { username, recoveryCode, newPassword } = parsed.data; const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) }); const rc = u ? await db.query.recoveryCodes.findFirst({ where: eq(recoveryCodes.userId, u.id) }) : null; const ok = rc ? await verifyRecoveryCode(rc.codeHash, recoveryCode) : false; if (!u || !rc || !ok) { await logSecurity(req, "recovery.failed", { userId: u?.id ?? null, severity: "warn", meta: { username } }); throw errors.unauthorized("Invalid username or recovery code."); } const nextCode = generateRecoveryCode(); const passwordHash = await hashPassword(newPassword); await db.transaction(async (tx) => { await tx.update(users).set({ passwordHash }).where(eq(users.id, u.id)); await tx.update(recoveryCodes).set({ codeHash: await hashRecoveryCode(nextCode), rotatedAt: new Date(), usedAt: new Date(), useCount: sql`${recoveryCodes.useCount} + 1` }).where(eq(recoveryCodes.userId, u.id)); }); await destroyAllSessions(u.id); await createSession(reply, req, u.id); await logSecurity(req, "recovery.used", { userId: u.id, severity: "warn" }); return { user: { id: u.id, username: u.username }, recoveryCode: nextCode, notice: "Your recovery code has been rotated. Save the new one." }; }); app.post("/api/auth/password", async (req) => { const user = requireUser(req); const body = z.object({ currentPassword: z.string().min(1), newPassword: passwordSchema }).parse(req.body); const u = await db.query.users.findFirst({ where: eq(users.id, user.id) }); if (!u || !(await verifyPassword(u.passwordHash, body.currentPassword))) throw errors.unauthorized("Current password is incorrect."); await db.update(users).set({ passwordHash: await hashPassword(body.newPassword) }).where(eq(users.id, u.id)); await logSecurity(req, "password.changed", { userId: u.id }); return { ok: true }; }); /** Rotate the recovery code (requires password). */ app.post("/api/auth/recovery-code/rotate", async (req) => { const user = requireUser(req); const body = z.object({ password: z.string().min(1) }).parse(req.body); const u = await db.query.users.findFirst({ where: eq(users.id, user.id) }); if (!u || !(await verifyPassword(u.passwordHash, body.password))) throw errors.unauthorized("Password is incorrect."); const code = generateRecoveryCode(); await db.insert(recoveryCodes).values({ userId: u.id, codeHash: await hashRecoveryCode(code) }).onConflictDoUpdate({ target: recoveryCodes.userId, set: { codeHash: await hashRecoveryCode(code), rotatedAt: new Date() } }); return { recoveryCode: code }; }); }