/* ===================================================================== auth.js — Login, Sessions, Rate-Limiting/Lockout Sicherheitsmodell: - Owner-Code kommt aus dem POSTFACH_CODE-Secret (Wrangler), Vergleich per Konstant-Zeit-String-Vergleich. - Team-Codes: SHA-256-Hash-Lookup (users.code_hash). - Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre — angelehnt an Cloudflares "Protect your login"-Standardregel. - Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout 30 Minuten, absolute Höchstdauer 12 Stunden. ===================================================================== */ import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js"; import { logAction } from "./audit.js"; const MAX_ATTEMPTS = 5; const LOCKOUT_MINUTES = 15; const INACTIVITY_MS = 30 * 60 * 1000; const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; export function clientKey(request) { return request.headers.get("CF-Connecting-IP") || "unknown"; } function timingSafeEqual(a, b) { if (a.length !== b.length) return false; let mismatch = 0; for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); return mismatch === 0; } export async function isLockedOut(env, key) { const row = await env.DB.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`) .bind(key) .first(); if (!row || !row.locked_until) return false; return new Date(row.locked_until) > new Date(); } export async function recordFailedAttempt(env, key) { const row = await env.DB.prepare(`SELECT count FROM login_attempts WHERE key = ?`).bind(key).first(); const count = (row?.count || 0) + 1; let lockedUntil = null; if (count >= MAX_ATTEMPTS) { lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString(); } await env.DB.prepare( `INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?` ) .bind(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso()) .run(); return { count, lockedUntil }; } export async function clearAttempts(env, key) { await env.DB.prepare(`DELETE FROM login_attempts WHERE key = ?`).bind(key).run(); } /** Prüft einen eingegebenen Code gegen Owner-Secret und Team-Datenbank. */ export async function verifyCode(env, code) { if (env.POSTFACH_CODE && timingSafeEqual(code, env.POSTFACH_CODE)) { return { ok: true, isOwner: true }; } const hash = await sha256Hex(code); const user = await env.DB.prepare( `SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?` ) .bind(hash) .first(); if (!user) return { ok: false }; if (user.status !== "active") return { ok: false, reason: "locked" }; if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" }; return { ok: true, isOwner: false, user }; } export async function createSession(env, { isOwner, userId }) { const token = generateSessionToken(); const tokenHash = await sha256Hex(token); const now = new Date(); const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString(); await env.DB.prepare( `INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)` ) .bind(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt) .run(); return token; } /** Löst ein Session-Token auf, prüft Ablauf/Inaktivität, aktualisiert last_active_at. */ export async function resolveSession(env, token) { if (!token) return null; const tokenHash = await sha256Hex(token); const session = await env.DB.prepare( `SELECT * FROM sessions WHERE token_hash = ?` ) .bind(tokenHash) .first(); if (!session) return null; const now = Date.now(); if (new Date(session.expires_at).getTime() < now) { await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); return null; } if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) { await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); return null; } await env.DB.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`) .bind(new Date().toISOString(), tokenHash) .run(); if (session.is_owner) { return { isOwner: true, actor: "owner" }; } const user = await env.DB.prepare( `SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?` ) .bind(session.user_id) .first(); if (!user || user.status !== "active") return null; const overridesRows = await env.DB.prepare( `SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?` ) .bind(user.id) .all(); const overrides = {}; for (const r of overridesRows.results || []) overrides[r.permission] = !!r.allowed; return { isOwner: false, actor: user.username, userId: user.id, name: user.name, roleId: user.role_id, roleName: user.role_name, permissions: user.permissions ? JSON.parse(user.permissions) : [], overrides, }; } export async function endSession(env, token) { if (!token) return; const tokenHash = await sha256Hex(token); await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run(); } export async function endAllSessionsForUser(env, userId) { await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId).run(); } export async function requireSession(request, env) { const auth = request.headers.get("Authorization") || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; return resolveSession(env, token); }