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,269 @@
|
||||
/* =====================================================================
|
||||
routes/users.js — Zugänge und Teammitglieder
|
||||
|
||||
Zentrale Regel aus dem Anforderungsdokument, überall durchgesetzt:
|
||||
AUSSCHLIESSLICH der Owner darf Codes sehen, erstellen, ändern,
|
||||
kopieren oder zurücksetzen. Keine Rolle und keine individuelle
|
||||
Berechtigung kann das jemals freischalten — die entsprechenden
|
||||
Funktionen prüfen `session.isOwner` direkt, nie eine Permission.
|
||||
===================================================================== */
|
||||
import { generateId, generateFriendlyCode, sha256Hex, encryptCode, decryptCode, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission, isValidPermissionKey } from "../lib/permissions.js";
|
||||
import { endAllSessionsForUser, verifyCode } from "../lib/auth.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
async function loadUser(env, id) {
|
||||
return env.DB.prepare(
|
||||
`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
}
|
||||
|
||||
function publicUser(u) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
roleId: u.role_id,
|
||||
roleName: u.role_name,
|
||||
status: u.status,
|
||||
note: u.note,
|
||||
expiresAt: u.expires_at,
|
||||
createdAt: u.created_at,
|
||||
createdBy: u.created_by,
|
||||
lastLoginAt: u.last_login_at,
|
||||
// Codes erscheinen hier bewusst NIE — auch nicht verschlüsselt/gehasht.
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUsers(env, session) {
|
||||
if (!can(session, "TEAM_VIEW") && !session?.isOwner) {
|
||||
return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.created_at DESC`
|
||||
).all();
|
||||
return json({ ok: true, users: (rows.results || []).map(publicUser) });
|
||||
}
|
||||
|
||||
/** Nicht-Owner mit TEAM_PREPARE_USER können einen Entwurf anlegen — noch ohne Code, inaktiv. */
|
||||
export async function prepareUser(request, env, session) {
|
||||
if (!can(session, "TEAM_PREPARE_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const name = String(body.name || "").trim().slice(0, 80);
|
||||
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
|
||||
if (!name || !username) return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
|
||||
|
||||
const id = generateId();
|
||||
try {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO users (id, name, username, email, role_id, status, note, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`
|
||||
)
|
||||
.bind(id, name, username, String(body.email || "").trim() || null, body.roleId || null, String(body.note || "").slice(0, 500) || null, nowIso(), session.actor)
|
||||
.run();
|
||||
} catch {
|
||||
return json({ ok: false, error: "Benutzername bereits vergeben." }, 400);
|
||||
}
|
||||
|
||||
await logAction(env, session.actor, "user.prepare", id, { username });
|
||||
return json({ ok: true, id });
|
||||
}
|
||||
|
||||
/** NUR Owner: legt einen Zugang final an (neu oder aus einem vorbereiteten Entwurf) inkl. Code. */
|
||||
export async function finalizeUser(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge anlegen/aktivieren." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
let id = body.pendingUserId ? String(body.pendingUserId) : null;
|
||||
const name = String(body.name || "").trim().slice(0, 80);
|
||||
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
|
||||
const email = String(body.email || "").trim() || null;
|
||||
const roleId = body.roleId || null;
|
||||
const note = String(body.note || "").slice(0, 500) || null;
|
||||
|
||||
if (!id && (!name || !username)) {
|
||||
return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
|
||||
}
|
||||
|
||||
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
const codeEnc = await encryptCode(env, code);
|
||||
|
||||
let expiresAt = null;
|
||||
if (body.expiresAt) expiresAt = new Date(body.expiresAt).toISOString();
|
||||
else if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
|
||||
|
||||
if (id) {
|
||||
const existing = await loadUser(env, id);
|
||||
if (!existing) return json({ ok: false, error: "Vorbereiteter Benutzer nicht gefunden." }, 404);
|
||||
await env.DB.prepare(
|
||||
`UPDATE users SET name = ?, username = ?, email = ?, role_id = ?, code_hash = ?, code_enc = ?,
|
||||
status = 'active', note = ?, expires_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(name || existing.name, username || existing.username, email, roleId, codeHash, codeEnc, note, expiresAt, id)
|
||||
.run();
|
||||
} else {
|
||||
id = generateId();
|
||||
try {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO users (id, name, username, email, role_id, code_hash, code_enc, status, note, expires_at, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(id, name, username, email, roleId, codeHash, codeEnc, note, expiresAt, nowIso(), session.actor)
|
||||
.run();
|
||||
} catch {
|
||||
return json({ ok: false, error: "Benutzername bereits vergeben." }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
await logAction(env, session.actor, "user.finalize", id, { username: username || undefined });
|
||||
// Der Klartext-Code wird JETZT einmalig zurückgegeben — danach nie wieder abrufbar,
|
||||
// außer über den geschützten "Code anzeigen"-Reveal-Flow (siehe revealCode()).
|
||||
return json({ ok: true, id, code });
|
||||
}
|
||||
|
||||
/** NUR Owner: neuen Code für bestehenden Benutzer setzen (manuell oder automatisch). */
|
||||
export async function setUserCode(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ändern." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
const user = await loadUser(env, id);
|
||||
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
|
||||
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
const codeEnc = await encryptCode(env, code);
|
||||
|
||||
await env.DB.prepare(`UPDATE users SET code_hash = ?, code_enc = ? WHERE id = ?`)
|
||||
.bind(codeHash, codeEnc, id)
|
||||
.run();
|
||||
await endAllSessionsForUser(env, id); // alter Code darf auf keinem Gerät mehr gültig bleiben
|
||||
|
||||
await logAction(env, session.actor, "user.code_reset", id, null);
|
||||
return json({ ok: true, code });
|
||||
}
|
||||
|
||||
/** NUR Owner, mit erneuter Re-Auth (ownerCode): zeigt einen bestehenden Code im Klartext. */
|
||||
export async function revealUserCode(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ansehen." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
const reAuth = await verifyCode(env, String(body.reAuthCode || ""));
|
||||
if (!reAuth.ok || !reAuth.isOwner) {
|
||||
return json({ ok: false, error: "Re-Authentifizierung fehlgeschlagen." }, 401);
|
||||
}
|
||||
|
||||
const id = String(body.id || "");
|
||||
const user = await loadUser(env, id);
|
||||
if (!user || !user.code_enc) return json({ ok: false, error: "Kein Code hinterlegt." }, 404);
|
||||
|
||||
const code = await decryptCode(env, user.code_enc);
|
||||
await logAction(env, session.actor, "user.code_reveal", id, null);
|
||||
return json({ ok: true, code });
|
||||
}
|
||||
|
||||
export async function lockUser(request, env, session) {
|
||||
if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
await env.DB.prepare(`UPDATE users SET status = 'locked' WHERE id = ?`).bind(id).run();
|
||||
await endAllSessionsForUser(env, id);
|
||||
await logAction(env, session.actor, "user.lock", id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function unlockUser(request, env, session) {
|
||||
if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
const user = await loadUser(env, id);
|
||||
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
if (user.expires_at && new Date(user.expires_at) < new Date()) {
|
||||
return json({ ok: false, error: "Zugang ist abgelaufen — bitte zuerst Gültigkeit verlängern." }, 400);
|
||||
}
|
||||
await env.DB.prepare(`UPDATE users SET status = 'active' WHERE id = ?`).bind(id).run();
|
||||
await logAction(env, session.actor, "user.unlock", id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/** NUR Owner: Zugang vollständig löschen. */
|
||||
export async function deleteUser(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge löschen." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run();
|
||||
await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(id).run();
|
||||
await env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(id).run();
|
||||
await logAction(env, session.actor, "user.delete", id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function setUserRole(request, env, session) {
|
||||
if (!can(session, "TEAM_ASSIGN_ROLE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
const roleId = body.roleId || null;
|
||||
await env.DB.prepare(`UPDATE users SET role_id = ? WHERE id = ?`).bind(roleId, id).run();
|
||||
await logAction(env, session.actor, "user.set_role", id, { roleId });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function setUserPermissions(request, env, session) {
|
||||
if (!can(session, "TEAM_EDIT_PERMISSIONS")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
const overrides = body.overrides && typeof body.overrides === "object" ? body.overrides : {};
|
||||
|
||||
await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run();
|
||||
for (const [key, allowed] of Object.entries(overrides)) {
|
||||
if (!isValidPermissionKey(key)) continue; // Owner-only-Aktionen können hier nie landen
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO user_permission_overrides (user_id, permission, allowed) VALUES (?, ?, ?)`
|
||||
)
|
||||
.bind(id, key, allowed ? 1 : 0)
|
||||
.run();
|
||||
}
|
||||
await logAction(env, session.actor, "user.set_permissions", id, { overrides });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/** NUR Owner: alle Sitzungen einer Person sofort beenden ("alle Geräte abmelden"). */
|
||||
export async function endUserSessions(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Sitzungen beenden." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
await endAllSessionsForUser(env, id);
|
||||
await logAction(env, session.actor, "user.end_sessions", id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function updateUserMeta(request, env, session) {
|
||||
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf diese Angaben ändern." }, 403);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || "");
|
||||
const user = await loadUser(env, id);
|
||||
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim().slice(0, 80) || user.name : user.name;
|
||||
const email = body.email !== undefined ? String(body.email).trim() || null : user.email;
|
||||
const note = body.note !== undefined ? String(body.note).slice(0, 500) || null : user.note;
|
||||
let expiresAt = user.expires_at;
|
||||
if (body.expiresAt !== undefined) expiresAt = body.expiresAt ? new Date(body.expiresAt).toISOString() : null;
|
||||
if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
|
||||
|
||||
await env.DB.prepare(`UPDATE users SET name = ?, email = ?, note = ?, expires_at = ? WHERE id = ?`)
|
||||
.bind(name, email, note, expiresAt, id)
|
||||
.run();
|
||||
await logAction(env, session.actor, "user.update_meta", id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user