Vollständiges Zugangs-/Rollen-/Rechtesystem gebaut (Anforderungsdokument): D1-Datenbank, AES-verschlüsselte Codes nur für Owner einsehbar, granulare Permissions, Session-Auth mit Lockout, Zugänge-&-Teammitglieder-Verwaltungsseite, Bewerbungen mit Status/Notizen/Entwürfen

This commit is contained in:
2026-07-31 00:37:04 +02:00
parent 96087f460e
commit 6a1c0e355c
18 changed files with 2283 additions and 539 deletions
+161
View File
@@ -0,0 +1,161 @@
/* =====================================================================
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);
}