$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); } }