Files
Adminator 182a87978e Auth-Umschalter: Session.php verzweigt auf AUTH_BACKEND (jwt = JWT-Access-Cookie + widerrufbare Redis-Session)
- teacher/admin Login/Read/Logout branchen auf AUTH_BACKEND; session-Pfad unveraendert (Live-Default, prod-sicher).
- jwt: loginTeacher/loginAdmin legen Redis-Session (SessionStore) an + setzen kurzlebiges JWT-Cookie (ggs_t/ggs_a) + langes Sid-Cookie (ggs_ts/ggs_as).
- Read: Fast-Path via signiertem JWT (zustandslos), Refresh-Path via Sid->Redis + frisches JWT; Widerruf = Redis DEL bei Logout.
- Teacher/Admin getrennte Cookies (koexistieren), typ-Check gegen Rollenverwechslung. Lazy-require von Jwt/SessionStore (jeder Einstiegspfad).
- Schueler unveraendert (UUID-Cookie + DB, schon pod-unabhaengig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 00:15:22 +02:00

249 lines
9.5 KiB
PHP

<?php
/**
* GeoGraSim — Session Management
* Schueler: UUID-Cookie (anonym, kein Login) — DB-gestuetzt, bereits pod-unabhaengig.
* Lehrkraefte/Admin: je nach AUTH_BACKEND
* 'session' (Default) → native PHP-Session ($_SESSION) wie bisher.
* 'jwt' → JWT-Access-Cookie + widerrufbare Redis-Session (pod-unabhaengig).
*
* Der jwt-Zweig liegt VOLLSTAENDIG hinter AUTH_BACKEND. Solange 'session' gilt
* (Live-Default), aendert sich nichts. Alle Endpoints sind ueber diese Klasse
* kanalisiert, deshalb greift der Umschalter an EINER Stelle.
*/
class Session {
const COOKIE_NAME = 'ggs_session';
const COOKIE_TTL = 86400 * 30; // 30 Tage
public static function start(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
// ── AUTH_BACKEND-Umschalter (JWT + Redis) ───────────────────────────
private static function jwtMode(): bool {
return defined('AUTH_BACKEND') && AUTH_BACKEND === 'jwt';
}
/** Laedt die jwt-Bibliotheken lazy — nur wenn der jwt-Zweig aktiv ist.
* So funktioniert jeder Einstiegspfad (Front-Controller ODER direkte
* PHP-Datei), ohne dass der Bootstrap sie kennen muss. Die vendor-
* Namespaces (Firebase\JWT, Predis) laedt der Composer-Autoloader,
* den app.php ueberall einbindet. */
private static function jwtBoot(): void {
if (!class_exists('Jwt')) require_once __DIR__ . '/Jwt.php';
if (!class_exists('SessionStore')) require_once __DIR__ . '/SessionStore.php';
}
/** [Access-Cookie, Sid-Cookie, typ] je Rolle. Teacher und Admin sind
* getrennt (wie bisher die getrennten $_SESSION-Keys), koennen also
* im selben Browser koexistieren. */
private static function roleCookies(string $role): array {
return $role === 'admin'
? ['ggs_a', 'ggs_as', 'admin']
: ['ggs_t', 'ggs_ts', 'teacher'];
}
private static function putCookie(string $name, string $val, int $expires): void {
setcookie($name, $val, [
'expires' => $expires,
'path' => (defined('BASE_PATH') ? BASE_PATH : '') . '/',
'samesite' => 'Lax',
'secure' => defined('IS_PRODUCTION') ? IS_PRODUCTION : false,
'httponly' => true,
]);
$_COOKIE[$name] = $val; // im selben Request sofort sichtbar
}
private static function dropCookie(string $name): void {
setcookie($name, '', [
'expires' => 1,
'path' => (defined('BASE_PATH') ? BASE_PATH : '') . '/',
'samesite' => 'Lax',
'secure' => defined('IS_PRODUCTION') ? IS_PRODUCTION : false,
'httponly' => true,
]);
unset($_COOKIE[$name]);
}
/** jwt-Login: Redis-Session anlegen + Sid-Cookie (lang) + Access-JWT (kurz). */
private static function jwtLogin(string $role, int $sub): void {
self::jwtBoot();
[$acc, $sidC, $typ] = self::roleCookies($role);
$sid = SessionStore::create(['typ' => $typ, 'sub' => $sub]);
self::putCookie($sidC, $sid, time() + (defined('JWT_REFRESH_TTL') ? (int)JWT_REFRESH_TTL : 2592000));
self::putCookie($acc, Jwt::issue(['sub' => $sub, 'typ' => $typ, 'sid' => $sid]),
time() + (defined('JWT_TTL') ? (int)JWT_TTL : 900));
}
/** jwt-Read: aktuelle User-ID fuer Rolle (oder null).
* Fast-Path (gueltiges Access-Token) ist zustandslos ohne Redis-Hit;
* erst wenn das kurzlebige Token abgelaufen ist, wird ueber das Sid-
* Cookie die widerrufbare Redis-Session geprueft und ein frisches
* Access-Token nachgelegt. */
private static function jwtUserId(string $role): ?int {
self::jwtBoot();
[$acc, $sidC, $typ] = self::roleCookies($role);
// Fast-Path: gueltiges, signiertes Access-Token → kein Redis-Hit.
$tok = $_COOKIE[$acc] ?? null;
if ($tok) {
$c = Jwt::verify($tok);
if ($c && ($c['typ'] ?? '') === $typ && isset($c['sub'])) {
return (int) $c['sub'];
}
}
// Refresh-Path: Access-Token fehlt/abgelaufen → Sid-Cookie → Redis.
$sid = $_COOKIE[$sidC] ?? null;
if (!$sid) return null;
try {
$data = SessionStore::get($sid); // slidet TTL; null = widerrufen/abgelaufen
} catch (\Throwable $e) {
return null; // Redis nicht erreichbar → wie „nicht eingeloggt"
}
if (!$data || ($data['typ'] ?? '') !== $typ || !isset($data['sub'])) return null;
// frisches Access-Token nachlegen (nur solange keine Header raus sind)
if (!headers_sent()) {
self::putCookie($acc, Jwt::issue(['sub' => (int)$data['sub'], 'typ' => $typ, 'sid' => $sid]),
time() + (defined('JWT_TTL') ? (int)JWT_TTL : 900));
}
return (int) $data['sub'];
}
/** jwt-Logout: Redis-Session widerrufen + Cookies loeschen. */
private static function jwtLogout(string $role): void {
self::jwtBoot();
[$acc, $sidC, ] = self::roleCookies($role);
$sid = $_COOKIE[$sidC] ?? null;
if ($sid) { try { SessionStore::revoke($sid); } catch (\Throwable $e) {} }
self::dropCookie($acc);
self::dropCookie($sidC);
}
/** Schueler-Session-ID aus Cookie */
public static function studentId(): ?string {
return $_COOKIE[self::COOKIE_NAME] ?? null;
}
/** Neue Schueler-Session erstellen. student_id wird direkt gesetzt (per FK
* statt fragilem display_name-Join) — falls nicht übergeben, aus
* (class_id, display_name) aufgelöst. */
public static function createStudent(int $classId, string $displayName = '', ?int $studentId = null): string {
$uuid = sprintf('%s-%s-%s-%s-%s',
bin2hex(random_bytes(4)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(6))
);
$db = Database::get();
if ($studentId === null && $displayName !== '') {
$row = $db->fetchOne(
'SELECT id FROM students WHERE class_id = ? AND display_name = ? AND deleted_at IS NULL',
[$classId, $displayName]
);
if ($row) $studentId = (int)$row['id'];
}
$db->execute(
'INSERT INTO student_sessions (id, class_id, display_name, student_id) VALUES (?, ?, ?, ?)',
[$uuid, $classId, $displayName, $studentId]
);
setcookie(self::COOKIE_NAME, $uuid, [
'expires' => time() + self::COOKIE_TTL,
'path' => BASE_PATH . '/',
'samesite' => 'Lax',
'secure' => IS_PRODUCTION,
'httponly' => true,
]);
return $uuid;
}
/** API-Guard: 401 wenn keine Session */
public static function requireStudent(): string {
$id = self::studentId();
if (!$id) {
http_response_code(401);
echo json_encode(['error' => 'Keine Session']);
exit;
}
return $id;
}
/** Lehrer eingeloggt? (startet die Session lazy — Aufrufer müssen nicht) */
public static function teacherId(): ?int {
if (self::jwtMode()) return self::jwtUserId('teacher');
self::start();
return $_SESSION['teacher_id'] ?? null;
}
/** Lehrer-Login */
public static function loginTeacher(int $teacherId): void {
if (self::jwtMode()) { self::jwtLogin('teacher', $teacherId); return; }
self::start();
$_SESSION['teacher_id'] = $teacherId;
}
/** Lehrer-Guard: 401 wenn nicht eingeloggt */
public static function requireTeacher(): int {
$id = self::teacherId();
if (!$id) {
http_response_code(401);
echo json_encode(['error' => 'Nicht eingeloggt']);
exit;
}
return $id;
}
// ── Admin (Super-Admin nach 2FA) ────────────────────────────────────
// Einheitlicher Zugang wie beim Lehrer, damit der JWT+Redis-Umschalter
// (AUTH_BACKEND) an EINER Stelle greift statt an jedem Endpoint.
/** Admin eingeloggt (2FA abgeschlossen)? */
public static function adminId(): ?int {
if (self::jwtMode()) return self::jwtUserId('admin');
self::start();
return $_SESSION['admin_id'] ?? null;
}
/** Admin-Login abschließen (nach erfolgreicher 2FA). */
public static function loginAdmin(int $adminId): void {
if (self::jwtMode()) { self::jwtLogin('admin', $adminId); return; }
self::start();
$_SESSION['admin_id'] = $adminId;
}
/** Admin-Logout — inkl. offener 2FA-Phase (admin_pending liegt immer nativ). */
public static function logoutAdmin(): void {
if (self::jwtMode()) self::jwtLogout('admin');
self::start();
unset($_SESSION['admin_id'], $_SESSION['admin_pending']);
}
/** Admin-Guard: 403 wenn nicht eingeloggt. */
public static function requireAdmin(): int {
$id = self::adminId();
if (!$id) {
http_response_code(403);
echo json_encode(['error' => 'Nur Admin']);
exit;
}
return $id;
}
public static function logout(): void {
if (self::jwtMode()) {
self::jwtLogout('teacher');
self::jwtLogout('admin');
}
self::start();
session_destroy();
self::dropCookie(self::COOKIE_NAME);
}
}