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:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user