- Login-Sperre war umgehbar: clientKey() vertraute dem Header CF-Connecting-IP. Bei Cloudflare war das sicher (CF ueberschreibt ihn), auf dem eigenen Server nicht: der Ursprungsserver ist auch direkt unter seiner IP erreichbar, dort konnte der Header frei gesetzt und die 5-Versuche-Sperre komplett ausgehebelt werden (nachgewiesen). Jetzt req.ip hinter trust proxy. - Absturzsicherheit: Express 4 faengt Fehler aus async-Handlern nicht ab, eine einzige fehlerhafte Anfrage konnte den ganzen Dienst beenden. wrap() um alle Handler, zentraler Fehler-Handler, unhandledRejection/uncaughtException-Netz. - Sicherheits-Header (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS) wurden bisher nur ueber die Datei _headers gesetzt, die auf dem eigenen Server wirkungslos ist. Jetzt im Express-Server. - x-powered-by abgeschaltet. - Datenschutzerklaerung/AGB: nannten Cloudflare als Hoster und eine Cloudflare-D1- Datenbank. Jetzt korrekt netcup (Rechenzentrum Nuernberg) als Hoster, Cloudflare als vorgeschaltetes CDN mit Drittlandhinweis.
156 lines
6.2 KiB
JavaScript
156 lines
6.2 KiB
JavaScript
/* =====================================================================
|
|
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);
|
|
}
|