|
|
@@ -0,0 +1,334 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Security;
|
|
|
+
|
|
|
+use App\App\Bootstrap;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Microsoft Entra (Azure AD) OpenID-Connect authorization-code login for the
|
|
|
+ * Jugendwart-Portal (portal/). Hand-rolled to match the project's zero-dependency
|
|
|
+ * convention (no Composer). The identity is authenticated by Entra; authorization is
|
|
|
+ * decided by the `entra.allowed_users` allowlist in config.
|
|
|
+ *
|
|
|
+ * Session key `portal_user` is intentionally distinct from the admin session
|
|
|
+ * (`admin_logged_in`) so the two portals never collide.
|
|
|
+ */
|
|
|
+final class EntraAuth
|
|
|
+{
|
|
|
+ private const SESSION_USER_KEY = 'portal_user';
|
|
|
+ private const SESSION_FLOW_KEY = '_portal_oauth';
|
|
|
+
|
|
|
+ /** @var array<string, mixed> */
|
|
|
+ private array $entra;
|
|
|
+
|
|
|
+ public function __construct()
|
|
|
+ {
|
|
|
+ $app = Bootstrap::config('app');
|
|
|
+ $this->entra = is_array($app['entra'] ?? null) ? $app['entra'] : [];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function isConfigured(): bool
|
|
|
+ {
|
|
|
+ return $this->tenantId() !== ''
|
|
|
+ && $this->clientId() !== ''
|
|
|
+ && (string) ($this->entra['client_secret'] ?? '') !== ''
|
|
|
+ && $this->redirectUri() !== '';
|
|
|
+ }
|
|
|
+
|
|
|
+ public function isLoggedIn(): bool
|
|
|
+ {
|
|
|
+ $user = $_SESSION[self::SESSION_USER_KEY] ?? null;
|
|
|
+ return is_array($user)
|
|
|
+ && is_string($user['email'] ?? null)
|
|
|
+ && ($user['email'] ?? '') !== '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return array{email: string, name: string, login_at: int}|null */
|
|
|
+ public function user(): ?array
|
|
|
+ {
|
|
|
+ if (!$this->isLoggedIn()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $user = (array) $_SESSION[self::SESSION_USER_KEY];
|
|
|
+ return [
|
|
|
+ 'email' => (string) ($user['email'] ?? ''),
|
|
|
+ 'name' => (string) ($user['name'] ?? ''),
|
|
|
+ 'login_at' => (int) ($user['login_at'] ?? 0),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build the Entra authorize URL and persist the anti-forgery state, the OIDC nonce
|
|
|
+ * and the PKCE verifier in the session for later verification in handleCallback().
|
|
|
+ */
|
|
|
+ public function getAuthorizationUrl(): string
|
|
|
+ {
|
|
|
+ $state = bin2hex(random_bytes(16));
|
|
|
+ $nonce = bin2hex(random_bytes(16));
|
|
|
+ $codeVerifier = self::base64UrlEncode(random_bytes(32));
|
|
|
+ $codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true));
|
|
|
+
|
|
|
+ $_SESSION[self::SESSION_FLOW_KEY] = [
|
|
|
+ 'state' => $state,
|
|
|
+ 'nonce' => $nonce,
|
|
|
+ 'code_verifier' => $codeVerifier,
|
|
|
+ 'created_at' => time(),
|
|
|
+ ];
|
|
|
+
|
|
|
+ $params = [
|
|
|
+ 'client_id' => $this->clientId(),
|
|
|
+ 'response_type' => 'code',
|
|
|
+ 'redirect_uri' => $this->redirectUri(),
|
|
|
+ 'response_mode' => 'query',
|
|
|
+ 'scope' => 'openid profile email',
|
|
|
+ 'state' => $state,
|
|
|
+ 'nonce' => $nonce,
|
|
|
+ 'code_challenge' => $codeChallenge,
|
|
|
+ 'code_challenge_method' => 'S256',
|
|
|
+ ];
|
|
|
+
|
|
|
+ return 'https://login.microsoftonline.com/' . rawurlencode($this->tenantId())
|
|
|
+ . '/oauth2/v2.0/authorize?' . http_build_query($params);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Exchange the authorization code, validate the id_token claims and, if the user is on
|
|
|
+ * the allowlist, establish the portal session. Returns false on any failure.
|
|
|
+ */
|
|
|
+ public function handleCallback(string $code, string $state): bool
|
|
|
+ {
|
|
|
+ $flow = $_SESSION[self::SESSION_FLOW_KEY] ?? null;
|
|
|
+ unset($_SESSION[self::SESSION_FLOW_KEY]);
|
|
|
+
|
|
|
+ if ($code === '' || $state === '' || !is_array($flow)) {
|
|
|
+ $this->log('Callback abgebrochen: fehlende Parameter oder kein Flow-State.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!is_string($flow['state'] ?? null) || !hash_equals($flow['state'], $state)) {
|
|
|
+ $this->log('Callback abgebrochen: state stimmt nicht überein.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $tokens = $this->exchangeCode($code, (string) ($flow['code_verifier'] ?? ''));
|
|
|
+ if ($tokens === null || !is_string($tokens['id_token'] ?? null)) {
|
|
|
+ $this->log('Callback abgebrochen: Token-Austausch fehlgeschlagen.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $claims = $this->decodeIdTokenClaims((string) $tokens['id_token']);
|
|
|
+ if ($claims === null) {
|
|
|
+ $this->log('Callback abgebrochen: id_token konnte nicht dekodiert werden.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$this->claimsAreValid($claims, (string) ($flow['nonce'] ?? ''))) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $email = strtolower(trim(
|
|
|
+ (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['upn'] ?? '')
|
|
|
+ ));
|
|
|
+ $name = trim((string) ($claims['name'] ?? $email));
|
|
|
+
|
|
|
+ if ($email === '' || !$this->isAllowed($email)) {
|
|
|
+ $this->log('Zugriff verweigert (nicht auf Allowlist): ' . ($email !== '' ? $email : 'unbekannt'));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $_SESSION[self::SESSION_USER_KEY] = [
|
|
|
+ 'email' => $email,
|
|
|
+ 'name' => $name,
|
|
|
+ 'login_at' => time(),
|
|
|
+ ];
|
|
|
+ session_regenerate_id(true);
|
|
|
+ $this->log('Portal-Login erfolgreich: ' . $email);
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function logout(): void
|
|
|
+ {
|
|
|
+ unset($_SESSION[self::SESSION_USER_KEY], $_SESSION[self::SESSION_FLOW_KEY]);
|
|
|
+ $_SESSION = [];
|
|
|
+ if (ini_get('session.use_cookies')) {
|
|
|
+ $params = session_get_cookie_params();
|
|
|
+ setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
|
|
|
+ }
|
|
|
+ session_destroy();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function requireLogin(): void
|
|
|
+ {
|
|
|
+ $timeout = (int) ($this->entra['session_timeout_seconds'] ?? 3600);
|
|
|
+ $loginAt = (int) ($_SESSION[self::SESSION_USER_KEY]['login_at'] ?? 0);
|
|
|
+
|
|
|
+ if ($this->isLoggedIn() && $timeout > 0 && $loginAt > 0 && (time() - $loginAt) > $timeout) {
|
|
|
+ $this->logout();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$this->isLoggedIn()) {
|
|
|
+ header('Location: ' . Bootstrap::url('portal/login.php'));
|
|
|
+ exit;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Internals
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ /** @return array<string, mixed>|null */
|
|
|
+ private function exchangeCode(string $code, string $codeVerifier): ?array
|
|
|
+ {
|
|
|
+ $url = 'https://login.microsoftonline.com/' . rawurlencode($this->tenantId()) . '/oauth2/v2.0/token';
|
|
|
+ $post = http_build_query([
|
|
|
+ 'client_id' => $this->clientId(),
|
|
|
+ 'client_secret' => (string) ($this->entra['client_secret'] ?? ''),
|
|
|
+ 'grant_type' => 'authorization_code',
|
|
|
+ 'code' => $code,
|
|
|
+ 'redirect_uri' => $this->redirectUri(),
|
|
|
+ 'scope' => 'openid profile email',
|
|
|
+ 'code_verifier' => $codeVerifier,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $ch = curl_init($url);
|
|
|
+ if ($ch === false) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ curl_setopt_array($ch, [
|
|
|
+ CURLOPT_POST => true,
|
|
|
+ CURLOPT_POSTFIELDS => $post,
|
|
|
+ CURLOPT_RETURNTRANSFER => true,
|
|
|
+ CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
|
|
+ CURLOPT_TIMEOUT => 15,
|
|
|
+ CURLOPT_SSL_VERIFYPEER => true,
|
|
|
+ CURLOPT_SSL_VERIFYHOST => 2,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $response = curl_exec($ch);
|
|
|
+ $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
+ $error = curl_error($ch);
|
|
|
+ curl_close($ch);
|
|
|
+
|
|
|
+ if (!is_string($response) || $response === '') {
|
|
|
+ $this->log('Token-Endpoint ohne Antwort: ' . $error);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $decoded = json_decode($response, true);
|
|
|
+ if (!is_array($decoded)) {
|
|
|
+ $this->log('Token-Endpoint lieferte kein JSON (HTTP ' . $status . ').');
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($status !== 200) {
|
|
|
+ $this->log('Token-Austausch HTTP ' . $status . ': ' . (string) ($decoded['error'] ?? 'unbekannt'));
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $decoded;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Decode the id_token payload. The token is fetched directly from Microsoft's token
|
|
|
+ * endpoint over server-to-server TLS (never through the browser), so validating the
|
|
|
+ * claims without JWKS signature verification is a deliberate, safe simplification that
|
|
|
+ * keeps the zero-dependency convention.
|
|
|
+ *
|
|
|
+ * @return array<string, mixed>|null
|
|
|
+ */
|
|
|
+ private function decodeIdTokenClaims(string $idToken): ?array
|
|
|
+ {
|
|
|
+ $parts = explode('.', $idToken);
|
|
|
+ if (count($parts) !== 3) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $payload = self::base64UrlDecode($parts[1]);
|
|
|
+ if ($payload === null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $claims = json_decode($payload, true);
|
|
|
+ return is_array($claims) ? $claims : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @param array<string, mixed> $claims */
|
|
|
+ private function claimsAreValid(array $claims, string $expectedNonce): bool
|
|
|
+ {
|
|
|
+ if ((string) ($claims['aud'] ?? '') !== $this->clientId()) {
|
|
|
+ $this->log('id_token abgelehnt: aud stimmt nicht.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $issuer = (string) ($claims['iss'] ?? '');
|
|
|
+ if ($this->tenantId() !== '' && strpos($issuer, $this->tenantId()) === false) {
|
|
|
+ $this->log('id_token abgelehnt: iss/tenant stimmt nicht.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $exp = (int) ($claims['exp'] ?? 0);
|
|
|
+ if ($exp > 0 && $exp < (time() - 60)) {
|
|
|
+ $this->log('id_token abgelehnt: abgelaufen.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($expectedNonce !== '' && !hash_equals($expectedNonce, (string) ($claims['nonce'] ?? ''))) {
|
|
|
+ $this->log('id_token abgelehnt: nonce stimmt nicht.');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function isAllowed(string $email): bool
|
|
|
+ {
|
|
|
+ $allowed = (array) ($this->entra['allowed_users'] ?? []);
|
|
|
+ foreach ($allowed as $entry) {
|
|
|
+ if (is_string($entry) && $entry !== '' && hash_equals(strtolower(trim($entry)), $email)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function tenantId(): string
|
|
|
+ {
|
|
|
+ return trim((string) ($this->entra['tenant_id'] ?? ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ private function clientId(): string
|
|
|
+ {
|
|
|
+ return trim((string) ($this->entra['client_id'] ?? ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ private function redirectUri(): string
|
|
|
+ {
|
|
|
+ return trim((string) ($this->entra['redirect_uri'] ?? ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ private function log(string $message): void
|
|
|
+ {
|
|
|
+ Bootstrap::log('portal', $message);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function base64UrlEncode(string $data): string
|
|
|
+ {
|
|
|
+ return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function base64UrlDecode(string $data): ?string
|
|
|
+ {
|
|
|
+ $remainder = strlen($data) % 4;
|
|
|
+ if ($remainder !== 0) {
|
|
|
+ $data .= str_repeat('=', 4 - $remainder);
|
|
|
+ }
|
|
|
+
|
|
|
+ $decoded = base64_decode(strtr($data, '-_', '+/'), true);
|
|
|
+ return $decoded === false ? null : $decoded;
|
|
|
+ }
|
|
|
+}
|