Auth-Fundament: JWT + Redis (Phase 1, non-breaking)
Vorbereitung für pod-unabhängige Sessions (k8s-Ziel). Ändert am Live-System NICHTS: AUTH_BACKEND='session' bleibt Default, die neuen Libs sind noch nirgends verdrahtet. - composer eingeführt: firebase/php-jwt ^6.11 + predis/predis ^2.4 (pure PHP, keine Redis-Extension nötig → läuft auf XAMPP wie im Prod-Image). vendor/ committed (kein Composer auf dem Server). - Jwt.php: HS256-Access-Token (issue/verify), decode() IMMER mit explizitem Algorithmus (mitigiert Algorithmus-Confusion). 8/8 Tests grün. - SessionStore.php: predis-gestützter Session/Refresh-Store (sess:<sid>, Sliding-TTL, revoke=DEL) — die widerrufbare Session hinter dem kurzen JWT. - app.php: vendor/autoload (guarded), Konstanten AUTH_BACKEND (Default session), REDIS_URL, JWT_TTL, JWT_REFRESH_TTL. JWT_SECRET bewusst ohne Default. - docker-compose.dev.yml: Redis (+ redis-commander UI) für lokale Dev-Parität. - .env.example um Auth-Block ergänzt. Nächste Phasen: $_SESSION-Zugriffe (293) durch Session:: kanalisieren → Backend-Umschalter in Session.php → doppelgleisiger Cutover → Reporting auf student_id-FK ziehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,13 @@ if (strpos($host, 'geograsim.at') !== false) {
|
||||
}
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
|
||||
// Composer-Autoloader (firebase/php-jwt, predis). Guard: erst ab dem
|
||||
// JWT/Redis-Umbau vorhanden; fehlt er, läuft die App im Legacy-Session-Modus
|
||||
// unverändert weiter.
|
||||
if (is_file(APP_ROOT . '/vendor/autoload.php')) {
|
||||
require_once APP_ROOT . '/vendor/autoload.php';
|
||||
}
|
||||
|
||||
// Plattform-Limits — eine einzelne Sim-Session darf nie länger als 120 min
|
||||
// dauern. Schützt DB vor absurden Werten (Tab über Nacht offen, Bug im
|
||||
// Frontend-Zeit-Tracking, etc.). Backend-Cap ist defensive Garantie:
|
||||
@@ -73,6 +80,17 @@ if (!defined('DB_USER')) define('DB_USER', 'root');
|
||||
if (!defined('DB_PASS')) define('DB_PASS', '');
|
||||
if (!defined('DB_PORT')) define('DB_PORT', '3306');
|
||||
|
||||
// ── Auth-Backend (JWT + Redis Migration) ────────────────────────────────
|
||||
// 'session' = Legacy (native PHP-Session-Dateien, wie bisher) — DEFAULT,
|
||||
// damit sich am Live-System NICHTS ändert, bis der Cutover steht.
|
||||
// 'jwt' = JWT-Access-Token (HttpOnly-Cookie) + Redis-Session/Refresh.
|
||||
if (!defined('AUTH_BACKEND')) define('AUTH_BACKEND', 'session');
|
||||
if (!defined('REDIS_URL')) define('REDIS_URL', 'tcp://127.0.0.1:6379');
|
||||
if (!defined('JWT_TTL')) define('JWT_TTL', 900); // Access-Token: 15 min
|
||||
if (!defined('JWT_REFRESH_TTL')) define('JWT_REFRESH_TTL', 2592000); // Refresh/Session: 30 Tage
|
||||
// JWT_SECRET hat BEWUSST keinen Default — muss in .env gesetzt sein, sobald
|
||||
// AUTH_BACKEND='jwt'. Jwt.php wirft sonst eine klare Exception.
|
||||
|
||||
/**
|
||||
* Cockpit-URL je nach Login-Status:
|
||||
* - Lehrkraft eingeloggt → /teacher
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
* GeoGraSim — JWT Access-Token (HS256)
|
||||
* ------------------------------------
|
||||
* Dünner Wrapper über firebase/php-jwt. Stellt kurzlebige Access-Tokens aus
|
||||
* und prüft sie. WICHTIG: decode() bekommt IMMER den expliziten Algorithmus
|
||||
* (Key(secret, 'HS256')) — das mitigiert die Algorithmus-Confusion-Klasse
|
||||
* (alg:none / RS↔HS-Verwechslung).
|
||||
*
|
||||
* Reine Bibliothek — wird erst im Cutover von Session.php genutzt. Solange
|
||||
* AUTH_BACKEND='session' gilt, ruft nichts diese Klasse.
|
||||
*/
|
||||
|
||||
use Firebase\JWT\JWT as FirebaseJWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
class Jwt
|
||||
{
|
||||
const ALG = 'HS256';
|
||||
|
||||
private static function secret(): string
|
||||
{
|
||||
if (!defined('JWT_SECRET') || JWT_SECRET === '') {
|
||||
throw new RuntimeException('JWT_SECRET fehlt — in .env setzen, sobald AUTH_BACKEND=jwt.');
|
||||
}
|
||||
return JWT_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-Token ausstellen.
|
||||
* @param array $claims z. B. ['sub'=>42, 'typ'=>'teacher', 'sid'=>'<redis-sid>', 'roles'=>[]]
|
||||
*/
|
||||
public static function issue(array $claims, ?int $ttl = null): string
|
||||
{
|
||||
$now = time();
|
||||
$payload = array_merge([
|
||||
'iss' => defined('BASE_URL') ? BASE_URL : 'geograsim',
|
||||
'iat' => $now,
|
||||
'nbf' => $now,
|
||||
'exp' => $now + ($ttl ?? (defined('JWT_TTL') ? JWT_TTL : 900)),
|
||||
], $claims);
|
||||
return FirebaseJWT::encode($payload, self::secret(), self::ALG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-Token prüfen. Gibt die Claims als Array zurück oder null
|
||||
* (bei ungültiger Signatur, abgelaufen, noch nicht gültig, kaputt).
|
||||
*/
|
||||
public static function verify(string $token): ?array
|
||||
{
|
||||
try {
|
||||
$decoded = FirebaseJWT::decode($token, new Key(self::secret(), self::ALG));
|
||||
return (array) $decoded;
|
||||
} catch (\Throwable $e) {
|
||||
return null; // ExpiredException, SignatureInvalidException, …
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* GeoGraSim — Redis-Session/Refresh-Store (predis)
|
||||
* ------------------------------------------------
|
||||
* Hält die WIDERRUFBARE Session hinter dem kurzlebigen JWT. Der Access-Token
|
||||
* trägt nur die Session-ID (`sid`); die eigentliche Session lebt hier in Redis:
|
||||
*
|
||||
* sess:<sid> → JSON { typ, sub, class_id?, roles, created } (TTL, Sliding)
|
||||
*
|
||||
* Widerruf (Logout, Bann, „alle Geräte abmelden", Passwort-Wechsel) = DEL.
|
||||
* Da der Access-Token kurz lebt (JWT_TTL), wirkt der Widerruf binnen Minuten.
|
||||
*
|
||||
* Reine Bibliothek — wird erst im Cutover genutzt. predis ist pure PHP,
|
||||
* braucht KEINE Redis-Extension (läuft auf XAMPP wie im Prod-Image).
|
||||
*/
|
||||
|
||||
use Predis\Client as PredisClient;
|
||||
|
||||
class SessionStore
|
||||
{
|
||||
private static ?PredisClient $client = null;
|
||||
|
||||
public static function client(): PredisClient
|
||||
{
|
||||
if (self::$client === null) {
|
||||
$url = defined('REDIS_URL') ? REDIS_URL : 'tcp://127.0.0.1:6379';
|
||||
self::$client = new PredisClient($url);
|
||||
}
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
private static function key(string $sid): string
|
||||
{
|
||||
return 'sess:' . $sid;
|
||||
}
|
||||
|
||||
private static function ttl(): int
|
||||
{
|
||||
return defined('JWT_REFRESH_TTL') ? JWT_REFRESH_TTL : 2592000;
|
||||
}
|
||||
|
||||
/** Opake 256-bit Session-ID. */
|
||||
public static function newSid(): string
|
||||
{
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
/** Neue Session anlegen → sid. */
|
||||
public static function create(array $data, ?int $ttl = null): string
|
||||
{
|
||||
$sid = self::newSid();
|
||||
$data['created'] = time();
|
||||
self::client()->setex(self::key($sid), $ttl ?? self::ttl(), json_encode($data));
|
||||
return $sid;
|
||||
}
|
||||
|
||||
/** Session lesen (null wenn widerrufen/abgelaufen). Sliding-Expiry per default. */
|
||||
public static function get(string $sid, bool $touch = true): ?array
|
||||
{
|
||||
$raw = self::client()->get(self::key($sid));
|
||||
if ($raw === null) return null;
|
||||
if ($touch) self::client()->expire(self::key($sid), self::ttl());
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/** Widerruf (Logout / Bann). */
|
||||
public static function revoke(string $sid): void
|
||||
{
|
||||
self::client()->del([self::key($sid)]);
|
||||
}
|
||||
|
||||
/** Verbindungstest — für Health-Check / Setup-Verifikation. */
|
||||
public static function ping(): bool
|
||||
{
|
||||
try {
|
||||
return (string) self::client()->ping() !== '';
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user