SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.4 KB · 152 lines typescript
Raw Blame History
1import type { FastifyInstance } from "fastify";2import { db, eq, recoveryCodes, userSettings, users, wallets, dailyRewards, sql } from "@spinza/database";3import { STARTING_BALANCE, checkUsername, loginSchema, recoverSchema, registerSchema, passwordSchema } from "@spinza/shared";4import { errors } from "../lib/errors";5import { generateRecoveryCode, hashPassword, hashRecoveryCode, verifyPassword, verifyRecoveryCode } from "../lib/crypto";6import { rateLimit } from "../lib/redis";7import { createSession, destroyAllSessions, destroySession, requireUser } from "../plugins/auth";8import { clientIp, logSecurity } from "../lib/security";9import { flag, profanityWords, maintenance } from "../lib/settings";10import { applyCredit, lockWallet, saveWallet } from "../services/wallet";11import { z } from "zod";12import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";1314async function limit(req: Parameters<typeof clientIp>[0], scope: string, max: number, windowSec: number) {15  const retry = await rateLimit(`${scope}:${clientIp(req)}`, max, windowSec);16  if (retry) {17    await logSecurity(req, "rate_limit", { meta: { scope } });18    throw errors.rateLimited(retry);19  }20}2122export async function authRoutes(app: FastifyInstance) {23  app.post("/api/auth/check-username", async (req) => {24    await limit(req, "check-username", 60, 60);25    const body = z.object({ username: z.string().max(64) }).parse(req.body);26    const check = checkUsername(body.username, profanityWords());27    if (!check.ok) return { available: false, reason: check.reason };28    const normalized = body.username.trim().toLowerCase();29    const exists = await db.query.users.findFirst({ where: eq(users.usernameNormalized, normalized), columns: { id: true } });30    return { available: !exists, reason: exists ? "taken" : undefined };31  });3233  app.post("/api/auth/register", async (req, reply) => {34    await limit(req, "register", 10, 600);35    if (!flag("registration.enabled")) throw errors.forbidden("Registration is temporarily closed.");36    const m = maintenance();37    if (m.enabled) throw errors.maintenance(m.message);38    const parsed = registerSchema.safeParse(req.body);39    if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());40    const { username, password } = parsed.data;41    const check = checkUsername(username, profanityWords());42    if (!check.ok) {43      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!];44      throw errors.badRequest(msg, { field: "username" });45    }46    const passwordHash = await hashPassword(password);47    const recoveryCode = generateRecoveryCode();48    const codeHash = await hashRecoveryCode(recoveryCode);4950    let userId: string;51    try {52      userId = await db.transaction(async (tx) => {53        const [u] = await tx54          .insert(users)55          .values({ username, usernameNormalized: username, passwordHash, ageConfirmedAt: new Date(), lastLoginAt: new Date() })56          .returning({ id: users.id });57        await tx.insert(wallets).values({ userId: u.id, balance: 0 });58        await tx.insert(userSettings).values({ userId: u.id });59        await tx.insert(recoveryCodes).values({ userId: u.id, codeHash });60        await tx.insert(dailyRewards).values({ userId: u.id });61        const w = await lockWallet(tx, u.id);62        await applyCredit(tx, w, "INITIAL_GRANT", STARTING_BALANCE, "welcome", { reason: "Welcome to Spinza" });63        await saveWallet(tx, w);64        return u.id;65      });66    } catch (e) {67      if (pgCode(e) === PG_UNIQUE_VIOLATION) throw errors.conflict("USERNAME_TAKEN", "That username is already taken.");68      throw e;69    }70    await createSession(reply, req, userId);71    await logSecurity(req, "register", { userId });72    return {73      user: { id: userId, username, isNew: true },74      balance: STARTING_BALANCE,75      recoveryCode,76      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.",77    };78  });7980  app.post("/api/auth/login", async (req, reply) => {81    await limit(req, "login", 20, 300);82    const parsed = loginSchema.safeParse(req.body);83    if (!parsed.success) throw errors.unauthorized("Invalid username or password.");84    const { username, password } = parsed.data;85    const perUser = await rateLimit(`login-user:${username}`, 10, 300);86    if (perUser) throw errors.rateLimited(perUser);87    const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });88    const ok = u ? await verifyPassword(u.passwordHash, password) : await verifyPassword("$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password);89    if (!u || !ok) {90      await logSecurity(req, "login.failed", { userId: u?.id ?? null, meta: { username } });91      throw errors.unauthorized("Invalid username or password.");92    }93    if (u.status !== "active") throw errors.forbidden("This account is suspended.");94    await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, u.id));95    await createSession(reply, req, u.id);96    await logSecurity(req, "login.success", { userId: u.id });97    return { user: { id: u.id, username: u.username } };98  });99100  app.post("/api/auth/logout", async (req, reply) => {101    const uid = req.user?.id;102    await destroySession(reply, req);103    if (uid) await logSecurity(req, "logout", { userId: uid });104    return { ok: true };105  });106107  app.post("/api/auth/recover", async (req, reply) => {108    await limit(req, "recover", 8, 900);109    const parsed = recoverSchema.safeParse(req.body);110    if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());111    const { username, recoveryCode, newPassword } = parsed.data;112    const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });113    const rc = u ? await db.query.recoveryCodes.findFirst({ where: eq(recoveryCodes.userId, u.id) }) : null;114    const ok = rc ? await verifyRecoveryCode(rc.codeHash, recoveryCode) : false;115    if (!u || !rc || !ok) {116      await logSecurity(req, "recovery.failed", { userId: u?.id ?? null, severity: "warn", meta: { username } });117      throw errors.unauthorized("Invalid username or recovery code.");118    }119    const nextCode = generateRecoveryCode();120    const passwordHash = await hashPassword(newPassword);121    await db.transaction(async (tx) => {122      await tx.update(users).set({ passwordHash }).where(eq(users.id, u.id));123      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));124    });125    await destroyAllSessions(u.id);126    await createSession(reply, req, u.id);127    await logSecurity(req, "recovery.used", { userId: u.id, severity: "warn" });128    return { user: { id: u.id, username: u.username }, recoveryCode: nextCode, notice: "Your recovery code has been rotated. Save the new one." };129  });130131  app.post("/api/auth/password", async (req) => {132    const user = requireUser(req);133    const body = z.object({ currentPassword: z.string().min(1), newPassword: passwordSchema }).parse(req.body);134    const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });135    if (!u || !(await verifyPassword(u.passwordHash, body.currentPassword))) throw errors.unauthorized("Current password is incorrect.");136    await db.update(users).set({ passwordHash: await hashPassword(body.newPassword) }).where(eq(users.id, u.id));137    await logSecurity(req, "password.changed", { userId: u.id });138    return { ok: true };139  });140141  /** Rotate the recovery code (requires password). */142  app.post("/api/auth/recovery-code/rotate", async (req) => {143    const user = requireUser(req);144    const body = z.object({ password: z.string().min(1) }).parse(req.body);145    const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });146    if (!u || !(await verifyPassword(u.passwordHash, body.password))) throw errors.unauthorized("Password is incorrect.");147    const code = generateRecoveryCode();148    await db.insert(recoveryCodes).values({ userId: u.id, codeHash: await hashRecoveryCode(code) }).onConflictDoUpdate({ target: recoveryCodes.userId, set: { codeHash: await hashRecoveryCode(code), rotatedAt: new Date() } });149    return { recoveryCode: code };150  });151}152