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
+15
View File
@@ -0,0 +1,15 @@
/* =====================================================================
audit.js — Aktivitätsprotokoll
WICHTIG: Codes dürfen hier NIEMALS im Klartext (oder auch nur
teilweise) auftauchen. `detail` ist für zusätzlichen, unkritischen
Kontext gedacht (z.B. "role: rechte-hand"), niemals für Geheimnisse.
===================================================================== */
import { nowIso } from "./crypto.js";
export async function logAction(env, actor, action, target, detail) {
await env.DB.prepare(
`INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)`
)
.bind(actor, action, target || null, detail ? JSON.stringify(detail) : null, nowIso())
.run();
}
+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);
}
+78
View File
@@ -0,0 +1,78 @@
/* =====================================================================
crypto.js — Verschlüsselung & Hashing für das Zugangssystem
Codes (Zugangscodes von Teammitgliedern) brauchen ZWEI Repräsentationen:
1) code_hash — SHA-256, nicht umkehrbar. Für den schnellen Login-
Vergleich ("stimmt der eingegebene Code mit dem gespeicherten
überein?"). Kann NIE zurück in den Klartext-Code verwandelt werden.
2) code_enc — AES-256-GCM, umkehrbar. Nur damit kann der
Hauptadministrator sich den Klartext-Code später wieder anzeigen
lassen ("Code anzeigen"-Funktion aus dem Anforderungsdokument).
Der Schlüssel dafür liegt NUR im ENCRYPTION_KEY-Secret (nie im
Quelltext, nie in der Datenbank).
Session-Tokens werden nur GEHASHT in der Datenbank gespeichert (wie ein
Passwort) — der Klartext-Token existiert nur kurz beim Login-Response
und im Browser des jeweiligen Geräts.
===================================================================== */
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1
export function generateFriendlyCode() {
const bytes = crypto.getRandomValues(new Uint8Array(12));
let raw = "";
for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length];
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
}
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export function generateId() {
return crypto.randomUUID();
}
export async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function getAesKey(env) {
const raw = Uint8Array.from(atob(env.ENCRYPTION_KEY), (c) => c.charCodeAt(0));
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
}
function bufToBase64(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function base64ToBuf(b64) {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */
export async function encryptCode(env, plaintext) {
const key = await getAesKey(env);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext)
);
return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`;
}
/** Entschlüsselt einen mit encryptCode() erzeugten Wert. Nur intern für den Owner-Reveal-Flow nutzen. */
export async function decryptCode(env, encoded) {
const [ivB64, ctB64] = encoded.split(".");
const key = await getAesKey(env);
const iv = base64ToBuf(ivB64);
const ciphertext = base64ToBuf(ctB64);
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plainBuf);
}
export function nowIso() {
return new Date().toISOString();
}
+19
View File
@@ -0,0 +1,19 @@
/* =====================================================================
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
===================================================================== */
export function corsHeaders() {
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders() },
});
}
+96
View File
@@ -0,0 +1,96 @@
/* =====================================================================
permissions.js — Vollständiger Permission-Katalog
Direkt aus dem Anforderungsdokument "Geschützter Zugang, Rollen und
Benutzerverwaltung" übernommen. Diese Keys sind die einzigen, die in
Rollen oder Personen-Ausnahmen aktiviert werden dürfen.
OWNER_ONLY_ACTIONS sind bewusst NICHT Teil von PERMISSIONS — sie
erscheinen nirgends in der Rechteverwaltung und können über keinen
Schalter an irgendeine Rolle oder Person vergeben werden. Jeder Ort im
Code, der eine dieser Aktionen ausführt, muss den Owner direkt prüfen
(isOwner === true), NIE eine Permission.
===================================================================== */
export const PERMISSIONS = {
// Bewerbungsbereiche
APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen",
APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen",
APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen",
APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen",
// Bearbeitung
APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen",
APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen",
APPLICATIONS_CHANGE_STATUS: "Status verändern",
APPLICATIONS_ARCHIVE: "Bewerbungen archivieren",
APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen",
APPLICATIONS_DELETE: "Bewerbungen löschen",
// Antworten
DRAFTS_CREATE: "Antwortentwürfe erstellen",
DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten",
DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten",
REPLIES_COPY: "Fertige Antworten kopieren",
EMAILS_COPY: "E-Mail-Adressen kopieren",
APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren",
// Interne Notizen
NOTES_READ: "Notizen lesen",
NOTES_WRITE: "Notizen schreiben",
NOTES_EDIT_OWN: "Eigene Notizen bearbeiten",
NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten",
NOTES_DELETE: "Notizen löschen",
// Teamverwaltung
TEAM_VIEW: "Andere Teammitglieder ansehen",
TEAM_PREPARE_USER: "Neue Benutzer vorbereiten",
TEAM_LOCK_USER: "Benutzer sperren",
TEAM_ASSIGN_ROLE: "Rollen vergeben",
TEAM_CREATE_ROLE: "Rollen erstellen",
TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern",
// Einstellungen
SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten",
SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern",
SETTINGS_VIEW_STATS: "Statistiken ansehen",
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
};
export const PERMISSION_GROUPS = [
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
{ label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] },
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
];
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
// wählbar und werden im Code immer direkt über isOwner geprüft:
export const OWNER_ONLY_ACTIONS = [
"CODE_VIEW",
"CODE_COPY",
"CODE_CREATE",
"CODE_EDIT",
"CODE_RESET",
"EDIT_OWNER_ACCOUNT",
"DELETE_OR_LIMIT_OWNER_ROLE",
"PROMOTE_TO_OWNER",
];
/**
* Prüft, ob eine Person (Rolle + individuelle Ausnahmen) ein Recht hat.
* Individuelle Ausnahmen (overrides) haben immer Vorrang vor der Rolle.
*/
export function hasPermission(rolePermissions, overrides, key) {
if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) {
return !!overrides[key];
}
return Array.isArray(rolePermissions) && rolePermissions.includes(key);
}
export function isValidPermissionKey(key) {
return Object.prototype.hasOwnProperty.call(PERMISSIONS, key);
}