SPB Git

spb/autoscholar

Public
9.4 KB · 272 lines typescript
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
Files · 87b4e55c3b9fbcc2cd191a1e469b33de0a24ccc8
Menu

Theme