import { checkPasswordPolicy, csrfTokenFor, hashPassword, hashToken, lockoutDurationMs, newCsrfSecret, newSessionToken, SESSION_IDLE_MS, SESSION_TTL_MS, verifyCsrf, verifyPassword, } from "@autoscholar/auth"; import { adminUsers, type Db, loginAttempts, passwordChanges, sessions } from "@autoscholar/db"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { audit, securityEvent } from "./audit.js"; import { getEnv } from "./env.js"; import { AppError, invalid, unauthorized } from "./errors.js"; export interface AuthenticatedSession { sessionId: string; userId: string; username: string; mustChangePassword: boolean; csrfToken: string; expiresAt: Date; } /** * One-time bootstrap: when no admin exists, create the configured bootstrap user with the bootstrap password, * hashed immediately, flagged must_change_password. Never logs the password. Idempotent and race-safe. */ export async function ensureBootstrapAdmin(db: Db): Promise<{ created: boolean }> { const env = getEnv(); const existing = await db.select({ id: adminUsers.id }).from(adminUsers).limit(1); if (existing.length > 0) return { created: false }; const pw = env.ADMIN_BOOTSTRAP_PASSWORD; if (!pw) throw new AppError("bootstrap_missing", "No admin exists and ADMIN_BOOTSTRAP_PASSWORD is not set", 500); const passwordHash = await hashPassword(pw); const inserted = await db .insert(adminUsers) .values({ username: env.ADMIN_BOOTSTRAP_USERNAME, passwordHash, mustChangePassword: true, bootstrapped: true, }) .onConflictDoNothing() .returning({ id: adminUsers.id }); if (inserted.length > 0) { await audit( db, { type: "system" }, "admin.bootstrap", { type: "admin_user", id: inserted[0]!.id }, { username: env.ADMIN_BOOTSTRAP_USERNAME, forcedRotation: true }, ); } return { created: inserted.length > 0 }; } export async function login( db: Db, username: string, password: string, ctx: { ip?: string | null; userAgent?: string | null }, ): Promise<{ token: string; session: AuthenticatedSession }> { await ensureBootstrapAdmin(db); const [user] = await db.select().from(adminUsers).where(eq(adminUsers.username, username)).limit(1); const fail = async (reason: string) => { await db.insert(loginAttempts).values({ username, ip: ctx.ip ?? null, success: false }); await securityEvent(db, "login.failed", { username, reason }, user?.id ?? null, ctx.ip ?? null); if (user) { const attempts = user.failedAttempts + 1; const lock = lockoutDurationMs(attempts); await db .update(adminUsers) .set({ failedAttempts: attempts, lockedUntil: lock > 0 ? new Date(Date.now() + lock) : null, updatedAt: new Date(), }) .where(eq(adminUsers.id, user.id)); } throw unauthorized("Invalid username or password"); }; if (!user) { // constant-ish time: still hash something await verifyPassword( "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password, ); return fail("unknown_user"); } if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { await securityEvent(db, "login.locked", { username }, user.id, ctx.ip ?? null); throw new AppError( "locked", `Account temporarily locked. Try again after ${user.lockedUntil.toISOString()}`, 423, ); } if (!(await verifyPassword(user.passwordHash, password))) return fail("bad_password"); const { token, tokenHash } = newSessionToken(); const csrfSecret = newCsrfSecret(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); const [s] = await db .insert(sessions) .values({ tokenHash, userId: user.id, csrfSecret, expiresAt, ip: ctx.ip ?? null, userAgent: ctx.userAgent?.slice(0, 300) ?? null, }) .returning({ id: sessions.id }); await db .update(adminUsers) .set({ failedAttempts: 0, lockedUntil: null, lastLoginAt: new Date(), updatedAt: new Date() }) .where(eq(adminUsers.id, user.id)); await db.insert(loginAttempts).values({ username, ip: ctx.ip ?? null, success: true }); await audit(db, { type: "admin", id: user.id, ip: ctx.ip }, "admin.login", { type: "admin_user", id: user.id, }); return { token, session: { sessionId: s!.id, userId: user.id, username: user.username, mustChangePassword: user.mustChangePassword, csrfToken: csrfTokenFor(csrfSecret, tokenHash), expiresAt, }, }; } export async function resolveSession( db: Db, token: string | undefined | null, ): Promise { if (!token) return null; const tokenHash = hashToken(token); const now = new Date(); const [row] = await db .select({ s: sessions, u: adminUsers }) .from(sessions) .innerJoin(adminUsers, eq(sessions.userId, adminUsers.id)) .where(and(eq(sessions.tokenHash, tokenHash), isNull(sessions.revokedAt), gt(sessions.expiresAt, now))) .limit(1); if (!row) return null; if (now.getTime() - row.s.lastSeenAt.getTime() > SESSION_IDLE_MS) { await db.update(sessions).set({ revokedAt: now }).where(eq(sessions.id, row.s.id)); return null; } if (now.getTime() - row.s.lastSeenAt.getTime() > 60_000) { await db.update(sessions).set({ lastSeenAt: now }).where(eq(sessions.id, row.s.id)); } return { sessionId: row.s.id, userId: row.u.id, username: row.u.username, mustChangePassword: row.u.mustChangePassword, csrfToken: csrfTokenFor(row.s.csrfSecret, tokenHash), expiresAt: row.s.expiresAt, }; } export async function verifySessionCsrf( db: Db, token: string, presented: string | null | undefined, ): Promise { const tokenHash = hashToken(token); const [row] = await db .select({ csrfSecret: sessions.csrfSecret }) .from(sessions) .where(eq(sessions.tokenHash, tokenHash)) .limit(1); if (!row) return false; return verifyCsrf(row.csrfSecret, tokenHash, presented); } export async function logout(db: Db, token: string): Promise { const tokenHash = hashToken(token); const [row] = await db .update(sessions) .set({ revokedAt: new Date() }) .where(eq(sessions.tokenHash, tokenHash)) .returning({ userId: sessions.userId }); if (row) await audit(db, { type: "admin", id: row.userId }, "admin.logout"); } /** Changes the password, revokes every other session, rotates the current one. Returns a new token. */ export async function changePassword( db: Db, token: string, currentPassword: string, newPassword: string, ctx: { ip?: string | null }, ): Promise<{ token: string; csrfToken: string }> { const sess = await resolveSession(db, token); if (!sess) throw unauthorized(); const [user] = await db.select().from(adminUsers).where(eq(adminUsers.id, sess.userId)).limit(1); if (!user) throw unauthorized(); if (!(await verifyPassword(user.passwordHash, currentPassword))) throw invalid("Current password is incorrect"); const policy = checkPasswordPolicy(newPassword, user.username); if (!policy.ok) throw invalid(`Password policy: ${policy.problems.join("; ")}`, policy.problems); if (await verifyPassword(user.passwordHash, newPassword)) throw invalid("New password must differ from the current password"); const env = getEnv(); if (env.ADMIN_BOOTSTRAP_PASSWORD && newPassword === env.ADMIN_BOOTSTRAP_PASSWORD) throw invalid("The bootstrap password cannot be reused"); const previous = await db .select({ previousHash: passwordChanges.previousHash }) .from(passwordChanges) .where(eq(passwordChanges.userId, user.id)); for (const p of previous) if (await verifyPassword(p.previousHash, newPassword)) throw invalid("Password was used before"); const newHash = await hashPassword(newPassword); const { token: newToken, tokenHash: newTokenHash } = newSessionToken(); const csrfSecret = newCsrfSecret(); await db.transaction(async (tx) => { await tx.insert(passwordChanges).values({ userId: user.id, previousHash: user.passwordHash, reason: user.mustChangePassword ? "forced_rotation" : "user_change", ip: ctx.ip ?? null, }); await tx .update(adminUsers) .set({ passwordHash: newHash, mustChangePassword: false, updatedAt: new Date() }) .where(eq(adminUsers.id, user.id)); await tx .update(sessions) .set({ revokedAt: new Date() }) .where(and(eq(sessions.userId, user.id), isNull(sessions.revokedAt))); await tx.insert(sessions).values({ tokenHash: newTokenHash, userId: user.id, csrfSecret, expiresAt: new Date(Date.now() + SESSION_TTL_MS), rotatedFrom: sess.sessionId, ip: ctx.ip ?? null, }); await audit( tx, { type: "admin", id: user.id, ip: ctx.ip }, "admin.password_changed", { type: "admin_user", id: user.id }, { forced: user.mustChangePassword }, ); }); return { token: newToken, csrfToken: csrfTokenFor(csrfSecret, newTokenHash) }; } export async function recentFailedLogins(db: Db, ip: string | null, windowMs = 15 * 60_000): Promise { if (!ip) return 0; const since = new Date(Date.now() - windowMs); const [r] = await db .select({ n: sql`count(*)::int` }) .from(loginAttempts) .where( and(eq(loginAttempts.ip, ip), eq(loginAttempts.success, false), gt(loginAttempts.createdAt, since)), ); return r?.n ?? 0; }