spb/autoscholar
Public
1import {2 checkPasswordPolicy,3 csrfTokenFor,4 hashPassword,5 hashToken,6 lockoutDurationMs,7 newCsrfSecret,8 newSessionToken,9 SESSION_IDLE_MS,10 SESSION_TTL_MS,11 verifyCsrf,12 verifyPassword,13} from "@autoscholar/auth";14import { adminUsers, type Db, loginAttempts, passwordChanges, sessions } from "@autoscholar/db";15import { and, eq, gt, isNull, sql } from "drizzle-orm";16import { audit, securityEvent } from "./audit.js";17import { getEnv } from "./env.js";18import { AppError, invalid, unauthorized } from "./errors.js";1920export interface AuthenticatedSession {21 sessionId: string;22 userId: string;23 username: string;24 mustChangePassword: boolean;25 csrfToken: string;26 expiresAt: Date;27}2829/**30 * One-time bootstrap: when no admin exists, create the configured bootstrap user with the bootstrap password,31 * hashed immediately, flagged must_change_password. Never logs the password. Idempotent and race-safe.32 */33export async function ensureBootstrapAdmin(db: Db): Promise<{ created: boolean }> {34 const env = getEnv();35 const existing = await db.select({ id: adminUsers.id }).from(adminUsers).limit(1);36 if (existing.length > 0) return { created: false };37 const pw = env.ADMIN_BOOTSTRAP_PASSWORD;38 if (!pw)39 throw new AppError("bootstrap_missing", "No admin exists and ADMIN_BOOTSTRAP_PASSWORD is not set", 500);40 const passwordHash = await hashPassword(pw);41 const inserted = await db42 .insert(adminUsers)43 .values({44 username: env.ADMIN_BOOTSTRAP_USERNAME,45 passwordHash,46 mustChangePassword: true,47 bootstrapped: true,48 })49 .onConflictDoNothing()50 .returning({ id: adminUsers.id });51 if (inserted.length > 0) {52 await audit(53 db,54 { type: "system" },55 "admin.bootstrap",56 { type: "admin_user", id: inserted[0]!.id },57 { username: env.ADMIN_BOOTSTRAP_USERNAME, forcedRotation: true },58 );59 }60 return { created: inserted.length > 0 };61}6263export async function login(64 db: Db,65 username: string,66 password: string,67 ctx: { ip?: string | null; userAgent?: string | null },68): Promise<{ token: string; session: AuthenticatedSession }> {69 await ensureBootstrapAdmin(db);70 const [user] = await db.select().from(adminUsers).where(eq(adminUsers.username, username)).limit(1);71 const fail = async (reason: string) => {72 await db.insert(loginAttempts).values({ username, ip: ctx.ip ?? null, success: false });73 await securityEvent(db, "login.failed", { username, reason }, user?.id ?? null, ctx.ip ?? null);74 if (user) {75 const attempts = user.failedAttempts + 1;76 const lock = lockoutDurationMs(attempts);77 await db78 .update(adminUsers)79 .set({80 failedAttempts: attempts,81 lockedUntil: lock > 0 ? new Date(Date.now() + lock) : null,82 updatedAt: new Date(),83 })84 .where(eq(adminUsers.id, user.id));85 }86 throw unauthorized("Invalid username or password");87 };88 if (!user) {89 // constant-ish time: still hash something90 await verifyPassword(91 "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",92 password,93 );94 return fail("unknown_user");95 }96 if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {97 await securityEvent(db, "login.locked", { username }, user.id, ctx.ip ?? null);98 throw new AppError(99 "locked",100 `Account temporarily locked. Try again after ${user.lockedUntil.toISOString()}`,101 423,102 );103 }104 if (!(await verifyPassword(user.passwordHash, password))) return fail("bad_password");105 const { token, tokenHash } = newSessionToken();106 const csrfSecret = newCsrfSecret();107 const expiresAt = new Date(Date.now() + SESSION_TTL_MS);108 const [s] = await db109 .insert(sessions)110 .values({111 tokenHash,112 userId: user.id,113 csrfSecret,114 expiresAt,115 ip: ctx.ip ?? null,116 userAgent: ctx.userAgent?.slice(0, 300) ?? null,117 })118 .returning({ id: sessions.id });119 await db120 .update(adminUsers)121 .set({ failedAttempts: 0, lockedUntil: null, lastLoginAt: new Date(), updatedAt: new Date() })122 .where(eq(adminUsers.id, user.id));123 await db.insert(loginAttempts).values({ username, ip: ctx.ip ?? null, success: true });124 await audit(db, { type: "admin", id: user.id, ip: ctx.ip }, "admin.login", {125 type: "admin_user",126 id: user.id,127 });128 return {129 token,130 session: {131 sessionId: s!.id,132 userId: user.id,133 username: user.username,134 mustChangePassword: user.mustChangePassword,135 csrfToken: csrfTokenFor(csrfSecret, tokenHash),136 expiresAt,137 },138 };139}140141export async function resolveSession(142 db: Db,143 token: string | undefined | null,144): Promise<AuthenticatedSession | null> {145 if (!token) return null;146 const tokenHash = hashToken(token);147 const now = new Date();148 const [row] = await db149 .select({ s: sessions, u: adminUsers })150 .from(sessions)151 .innerJoin(adminUsers, eq(sessions.userId, adminUsers.id))152 .where(and(eq(sessions.tokenHash, tokenHash), isNull(sessions.revokedAt), gt(sessions.expiresAt, now)))153 .limit(1);154 if (!row) return null;155 if (now.getTime() - row.s.lastSeenAt.getTime() > SESSION_IDLE_MS) {156 await db.update(sessions).set({ revokedAt: now }).where(eq(sessions.id, row.s.id));157 return null;158 }159 if (now.getTime() - row.s.lastSeenAt.getTime() > 60_000) {160 await db.update(sessions).set({ lastSeenAt: now }).where(eq(sessions.id, row.s.id));161 }162 return {163 sessionId: row.s.id,164 userId: row.u.id,165 username: row.u.username,166 mustChangePassword: row.u.mustChangePassword,167 csrfToken: csrfTokenFor(row.s.csrfSecret, tokenHash),168 expiresAt: row.s.expiresAt,169 };170}171172export async function verifySessionCsrf(173 db: Db,174 token: string,175 presented: string | null | undefined,176): Promise<boolean> {177 const tokenHash = hashToken(token);178 const [row] = await db179 .select({ csrfSecret: sessions.csrfSecret })180 .from(sessions)181 .where(eq(sessions.tokenHash, tokenHash))182 .limit(1);183 if (!row) return false;184 return verifyCsrf(row.csrfSecret, tokenHash, presented);185}186187export async function logout(db: Db, token: string): Promise<void> {188 const tokenHash = hashToken(token);189 const [row] = await db190 .update(sessions)191 .set({ revokedAt: new Date() })192 .where(eq(sessions.tokenHash, tokenHash))193 .returning({ userId: sessions.userId });194 if (row) await audit(db, { type: "admin", id: row.userId }, "admin.logout");195}196197/** Changes the password, revokes every other session, rotates the current one. Returns a new token. */198export async function changePassword(199 db: Db,200 token: string,201 currentPassword: string,202 newPassword: string,203 ctx: { ip?: string | null },204): Promise<{ token: string; csrfToken: string }> {205 const sess = await resolveSession(db, token);206 if (!sess) throw unauthorized();207 const [user] = await db.select().from(adminUsers).where(eq(adminUsers.id, sess.userId)).limit(1);208 if (!user) throw unauthorized();209 if (!(await verifyPassword(user.passwordHash, currentPassword)))210 throw invalid("Current password is incorrect");211 const policy = checkPasswordPolicy(newPassword, user.username);212 if (!policy.ok) throw invalid(`Password policy: ${policy.problems.join("; ")}`, policy.problems);213 if (await verifyPassword(user.passwordHash, newPassword))214 throw invalid("New password must differ from the current password");215 const env = getEnv();216 if (env.ADMIN_BOOTSTRAP_PASSWORD && newPassword === env.ADMIN_BOOTSTRAP_PASSWORD)217 throw invalid("The bootstrap password cannot be reused");218 const previous = await db219 .select({ previousHash: passwordChanges.previousHash })220 .from(passwordChanges)221 .where(eq(passwordChanges.userId, user.id));222 for (const p of previous)223 if (await verifyPassword(p.previousHash, newPassword)) throw invalid("Password was used before");224 const newHash = await hashPassword(newPassword);225 const { token: newToken, tokenHash: newTokenHash } = newSessionToken();226 const csrfSecret = newCsrfSecret();227 await db.transaction(async (tx) => {228 await tx.insert(passwordChanges).values({229 userId: user.id,230 previousHash: user.passwordHash,231 reason: user.mustChangePassword ? "forced_rotation" : "user_change",232 ip: ctx.ip ?? null,233 });234 await tx235 .update(adminUsers)236 .set({ passwordHash: newHash, mustChangePassword: false, updatedAt: new Date() })237 .where(eq(adminUsers.id, user.id));238 await tx239 .update(sessions)240 .set({ revokedAt: new Date() })241 .where(and(eq(sessions.userId, user.id), isNull(sessions.revokedAt)));242 await tx.insert(sessions).values({243 tokenHash: newTokenHash,244 userId: user.id,245 csrfSecret,246 expiresAt: new Date(Date.now() + SESSION_TTL_MS),247 rotatedFrom: sess.sessionId,248 ip: ctx.ip ?? null,249 });250 await audit(251 tx,252 { type: "admin", id: user.id, ip: ctx.ip },253 "admin.password_changed",254 { type: "admin_user", id: user.id },255 { forced: user.mustChangePassword },256 );257 });258 return { token: newToken, csrfToken: csrfTokenFor(csrfSecret, newTokenHash) };259}260261export async function recentFailedLogins(db: Db, ip: string | null, windowMs = 15 * 60_000): Promise<number> {262 if (!ip) return 0;263 const since = new Date(Date.now() - windowMs);264 const [r] = await db265 .select({ n: sql<number>`count(*)::int` })266 .from(loginAttempts)267 .where(268 and(eq(loginAttempts.ip, ip), eq(loginAttempts.success, false), gt(loginAttempts.createdAt, since)),269 );270 return r?.n ?? 0;271}272