/* ===================================================================== auth.js — Login, Sessions, Rate-Limiting/Lockout für den internen Team-Bereich. 1:1 portiert aus cloudflare-worker/src/lib/auth.js, D1 (async) → better-sqlite3 (synchron). ===================================================================== */ import { db } from "../db.js"; import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js"; import { logAction } from "./audit.js"; const MAX_ATTEMPTS = 5; const LOCKOUT_MINUTES = 15; const INACTIVITY_MS = 24 * 60 * 60 * 1000; const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; /* Schlüssel für die Fehlversuchs-Zählung (Login-Sperre). SICHERHEITSKORREKTUR 05.08.2026: Vorher stand hier `req.headers["cf-connecting-ip"] || req.ip`. Bei Cloudflare war das sicher, weil Cloudflare diesen Header selbst setzt und einen vom Client mitgeschickten überschreibt. Auf dem eigenen Server gilt das NICHT mehr: der Ursprungsserver ist (nachgewiesen am 05.08.) auch direkt unter seiner IP erreichbar, also an Cloudflare vorbei — ein Angreifer konnte dort bei jedem Versuch einen anderen `CF-Connecting-IP`-Wert mitschicken und so die 5-Versuche-Sperre komplett aushebeln (unbegrenztes Durchprobieren des Owner-Codes). `req.ip` ist hier nicht fälschbar: `app.set("trust proxy", 1)` in index.js lässt Express genau eine Proxy-Ebene (den lokalen Caddy) vertrauen, und Caddy hängt die echte Verbindungs-IP hinten an `X-Forwarded-For` an — ein vom Client vorgetäuschter Wert landet weiter vorne und wird ignoriert. */ export function clientKey(req) { return req.ip || req.socket?.remoteAddress || "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 function isLockedOut(key) { const row = db.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`).get(key); if (!row || !row.locked_until) return false; return new Date(row.locked_until) > new Date(); } export function recordFailedAttempt(key) { const row = db.prepare(`SELECT count FROM login_attempts WHERE key = ?`).get(key); const count = (row?.count || 0) + 1; let lockedUntil = null; if (count >= MAX_ATTEMPTS) { lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString(); } db.prepare( `INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?` ).run(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso()); return { count, lockedUntil }; } export function clearAttempts(key) { db.prepare(`DELETE FROM login_attempts WHERE key = ?`).run(key); } /** Prüft einen eingegebenen Code gegen Owner-Secret/-Hash und Team-Datenbank. */ export async function verifyCode(code) { const hash = await sha256Hex(code); const ownerRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'owner_code_hash'`).get(); if (ownerRow && ownerRow.value) { if (timingSafeEqual(hash, ownerRow.value)) return { ok: true, isOwner: true }; } else if (process.env.POSTFACH_CODE && timingSafeEqual(code, process.env.POSTFACH_CODE)) { return { ok: true, isOwner: true }; } const user = db .prepare(`SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?`) .get(hash); 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({ 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(); db.prepare( `INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)` ).run(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt); return token; } export async function resolveSession(token) { if (!token) return null; const tokenHash = await sha256Hex(token); const session = db.prepare(`SELECT * FROM sessions WHERE token_hash = ?`).get(tokenHash); if (!session) return null; const now = Date.now(); if (new Date(session.expires_at).getTime() < now) { db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); return null; } if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) { db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); return null; } db.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`).run(new Date().toISOString(), tokenHash); if (session.is_owner) { return { isOwner: true, actor: "owner" }; } const user = 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 = ?` ) .get(session.user_id); if (!user || user.status !== "active") return null; const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(user.id); const overrides = {}; for (const r of overridesRows) 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(token) { if (!token) return; const tokenHash = await sha256Hex(token); db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); } export function endAllSessionsForUser(userId) { db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(userId); } export async function requireSession(req) { const auth = req.headers["authorization"] || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; return resolveSession(token); }