SPB Git

spb/autoscholar

Public
4.3 KB · 123 lines typescript
1import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";2import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";34/** Argon2id parameters (OWASP 2024+: m=64MiB, t=3, p=4). */5const ARGON_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 4, outputLen: 32 } as const;67export async function hashPassword(password: string): Promise<string> {8  return argonHash(password, ARGON_OPTS);9}1011export async function verifyPassword(hash: string, password: string): Promise<boolean> {12  try {13    return await argonVerify(hash, password);14  } catch {15    return false;16  }17}1819export interface PasswordPolicyResult {20  ok: boolean;21  problems: string[];22}2324const COMMON = new Set([25  "admin123",26  "password",27  "password123",28  "admin",29  "letmein",30  "qwerty123456",31  "autoscholar",32  "123456789012",33]);3435export function checkPasswordPolicy(pw: string, username?: string): PasswordPolicyResult {36  const problems: string[] = [];37  if (pw.length < 12) problems.push("at least 12 characters");38  if (pw.length > 512) problems.push("at most 512 characters");39  if (COMMON.has(pw.toLowerCase())) problems.push("not a well-known password");40  if (username && pw.toLowerCase().includes(username.toLowerCase()))41    problems.push("must not contain the username");42  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(pw)).length;43  if (classes < 3) problems.push("at least three character classes (lower, upper, digit, symbol)");44  return { ok: problems.length === 0, problems };45}4647/** Opaque session token: random 32 bytes (base64url). Only its SHA-256 is stored. */48export function newSessionToken(): { token: string; tokenHash: string } {49  const token = randomBytes(32).toString("base64url");50  return { token, tokenHash: hashToken(token) };51}5253export function hashToken(token: string): string {54  return createHash("sha256").update(token).digest("hex");55}5657export function newCsrfSecret(): string {58  return randomBytes(32).toString("hex");59}6061/** Double-submit CSRF: HMAC(csrfSecret, sessionTokenHash). The browser must echo it in `x-csrf-token`. */62export function csrfTokenFor(csrfSecret: string, sessionTokenHash: string): string {63  return createHmac("sha256", csrfSecret).update(sessionTokenHash).digest("base64url");64}6566export function verifyCsrf(67  csrfSecret: string,68  sessionTokenHash: string,69  presented: string | null | undefined,70): boolean {71  if (!presented) return false;72  const expected = Buffer.from(csrfTokenFor(csrfSecret, sessionTokenHash));73  const got = Buffer.from(presented);74  return expected.length === got.length && timingSafeEqual(expected, got);75}7677/** Worker bearer tokens: `asw_` prefix for easy redaction; SHA-256 stored. */78export function newWorkerToken(): { token: string; tokenHash: string } {79  const token = `asw_${randomBytes(32).toString("base64url")}`;80  return { token, tokenHash: hashToken(token) };81}8283export function constantTimeEqual(a: string, b: string): boolean {84  const ab = Buffer.from(a);85  const bb = Buffer.from(b);86  return ab.length === bb.length && timingSafeEqual(ab, bb);87}8889/** Sliding-window in-memory rate limiter (per process). Persisted lockout lives in the DB. */90export class RateLimiter {91  private hits = new Map<string, number[]>();92  constructor(93    private readonly limit: number,94    private readonly windowMs: number,95  ) {}96  check(key: string): { allowed: boolean; remaining: number; retryAfterMs: number } {97    const now = Date.now();98    const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);99    if (arr.length >= this.limit) {100      const retryAfterMs = this.windowMs - (now - (arr[0] ?? now));101      this.hits.set(key, arr);102      return { allowed: false, remaining: 0, retryAfterMs };103    }104    arr.push(now);105    this.hits.set(key, arr);106    if (this.hits.size > 50_000) this.hits.clear();107    return { allowed: true, remaining: this.limit - arr.length, retryAfterMs: 0 };108  }109  reset(key: string): void {110    this.hits.delete(key);111  }112}113114/** Exponential backoff for failed logins: 5 failures → 1 min, doubling, capped at 1 h. */115export function lockoutDurationMs(failedAttempts: number): number {116  if (failedAttempts < 5) return 0;117  return Math.min(60_000 * 2 ** (failedAttempts - 5), 3_600_000);118}119120export const SESSION_TTL_MS = 12 * 60 * 60 * 1000;121export const SESSION_IDLE_MS = 2 * 60 * 60 * 1000;122export const SESSION_COOKIE = "as_session";123
Files · cbf31e64fd1db62a1819edf166ff2d903b6d3510
Menu

Theme