0, 'path' => '/', 'domain' => '', 'secure' => auth_is_https_request(), 'httponly' => true, 'samesite' => 'Lax', ]); ini_set('session.use_only_cookies', '1'); ini_set('session.use_strict_mode', '1'); ini_set('session.use_trans_sid', '0'); session_start(); if (!isset($_SESSION[AUTH_CSRF_TOKEN_KEY]) || !is_string($_SESSION[AUTH_CSRF_TOKEN_KEY]) || $_SESSION[AUTH_CSRF_TOKEN_KEY] === '') { $_SESSION[AUTH_CSRF_TOKEN_KEY] = bin2hex(random_bytes(32)); } } function auth_csrf_token(): string { auth_bootstrap_session(); return (string) $_SESSION[AUTH_CSRF_TOKEN_KEY]; } function auth_validate_csrf_token(?string $token): bool { auth_bootstrap_session(); if (!is_string($token) || $token === '') { return false; } return hash_equals((string) $_SESSION[AUTH_CSRF_TOKEN_KEY], $token); } function auth_escape_html(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } function auth_normalize_identifier(string $identifier): string { return trim($identifier); } function auth_normalize_return_to(?string $returnTo): string { $candidate = trim((string) $returnTo); if ($candidate === '' || str_starts_with($candidate, '//') || str_contains($candidate, '://')) { return '/'; } if (!str_starts_with($candidate, '/')) { return '/'; } return $candidate; } function auth_set_return_to(string $returnTo): void { auth_bootstrap_session(); $_SESSION[AUTH_LOGIN_RETURN_TO_KEY] = auth_normalize_return_to($returnTo); } function auth_take_return_to(): string { auth_bootstrap_session(); $returnTo = auth_normalize_return_to($_SESSION[AUTH_LOGIN_RETURN_TO_KEY] ?? '/'); unset($_SESSION[AUTH_LOGIN_RETURN_TO_KEY]); return $returnTo; } function auth_ensure_schema(PDO $pdo): void { $requiredColumns = [ 'id', 'username', 'email', 'password_hash', 'role', 'is_active', 'failed_login_count', 'locked_until', 'last_login_at', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at', ]; $stmt = $pdo->query( "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'system_user'" ); $columns = $stmt->fetchAll(PDO::FETCH_COLUMN); $existing = array_map('strval', $columns ?: []); $missing = array_values(array_diff($requiredColumns, $existing)); if ($missing !== []) { throw new RuntimeException('DB schema not initialized. Missing auth columns: ' . implode(', ', $missing)); } } function auth_fetch_user_row_by_id(PDO $pdo, int $userId): ?array { $stmt = $pdo->prepare( 'SELECT id, username, email, role, is_active, locked_until, last_login_at, created_at, updated_at FROM public.system_user WHERE id = :id LIMIT 1' ); $stmt->execute([':id' => $userId]); $row = $stmt->fetch(); return is_array($row) ? $row : null; } function auth_current_user(PDO $pdo): ?array { auth_bootstrap_session(); $userId = $_SESSION[AUTH_SESSION_USER_ID_KEY] ?? null; if (!is_int($userId) && !ctype_digit((string) $userId)) { return null; } $user = auth_fetch_user_row_by_id($pdo, (int) $userId); if ($user === null) { unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); return null; } if (!((bool) $user['is_active'])) { unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); return null; } $lockedUntil = $user['locked_until'] ?? null; if ($lockedUntil !== null && $lockedUntil !== '') { try { $lockedUntilAt = new DateTimeImmutable((string) $lockedUntil); if ($lockedUntilAt > new DateTimeImmutable('now')) { unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); return null; } } catch (Throwable) { unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); return null; } } return $user; } function auth_require_user(PDO $pdo): array { $user = auth_current_user($pdo); if ($user !== null) { return $user; } $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); auth_set_return_to($requestUri); header('Location: /'); exit; } function auth_find_user_by_identifier(PDO $pdo, string $identifier): ?array { $stmt = $pdo->prepare( 'SELECT id, username, email, password_hash, role, is_active, failed_login_count, locked_until FROM public.system_user WHERE LOWER(username) = LOWER(:identifier_username) OR LOWER(email) = LOWER(:identifier_email) LIMIT 1 FOR UPDATE' ); $stmt->execute([ ':identifier_username' => $identifier, ':identifier_email' => $identifier, ]); $row = $stmt->fetch(); return is_array($row) ? $row : null; } function auth_login(PDO $pdo, string $identifier, string $password): array { auth_ensure_schema($pdo); auth_bootstrap_session(); $normalizedIdentifier = auth_normalize_identifier($identifier); $password = (string) $password; $errors = [ 'identifier' => null, 'password' => null, ]; if ($normalizedIdentifier === '') { $errors['identifier'] = 'Bitte Benutzername oder E-Mail eingeben.'; } if ($password === '') { $errors['password'] = 'Bitte Passwort eingeben.'; } if ($errors['identifier'] !== null || $errors['password'] !== null) { return [ 'ok' => false, 'errors' => $errors, ]; } $pdo->beginTransaction(); try { $user = auth_find_user_by_identifier($pdo, $normalizedIdentifier); if ($user === null) { $pdo->commit(); return [ 'ok' => false, 'errors' => [ 'identifier' => 'Benutzername oder E-Mail ist unbekannt.', 'password' => null, ], ]; } $userId = (int) $user['id']; if (!((bool) $user['is_active'])) { $pdo->commit(); return [ 'ok' => false, 'errors' => [ 'identifier' => 'Benutzerkonto ist deaktiviert.', 'password' => null, ], ]; } $lockedUntil = $user['locked_until'] ?? null; if ($lockedUntil !== null && $lockedUntil !== '') { try { $lockedUntilAt = new DateTimeImmutable((string) $lockedUntil); if ($lockedUntilAt > new DateTimeImmutable('now')) { $pdo->commit(); return [ 'ok' => false, 'errors' => [ 'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.', 'password' => null, ], ]; } } catch (Throwable) { $pdo->commit(); return [ 'ok' => false, 'errors' => [ 'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.', 'password' => null, ], ]; } } if (!password_verify($password, (string) $user['password_hash'])) { $failedCount = (int) ($user['failed_login_count'] ?? 0) + 1; $lockedUntilSql = null; if ($failedCount >= AUTH_FAILED_LOGIN_LIMIT) { $lockedUntilSql = 'NOW() + INTERVAL \'' . AUTH_LOCK_DURATION_SECONDS . ' seconds\''; } $updateSql = 'UPDATE public.system_user SET failed_login_count = :failed_login_count, locked_until = ' . ($lockedUntilSql !== null ? $lockedUntilSql : 'NULL') . ', updated_at = NOW(), updated_by_user_id = NULL WHERE id = :id'; $stmt = $pdo->prepare($updateSql); $stmt->execute([ ':failed_login_count' => $failedCount, ':id' => $userId, ]); $pdo->commit(); return [ 'ok' => false, 'errors' => [ 'identifier' => null, 'password' => 'Passwort ist falsch.', ], ]; } $stmt = $pdo->prepare( 'UPDATE public.system_user SET failed_login_count = 0, locked_until = NULL, last_login_at = NOW(), updated_at = NOW(), updated_by_user_id = NULL WHERE id = :id' ); $stmt->execute([':id' => $userId]); session_regenerate_id(true); $_SESSION[AUTH_SESSION_USER_ID_KEY] = $userId; $pdo->commit(); return [ 'ok' => true, 'errors' => [ 'identifier' => null, 'password' => null, ], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } function auth_logout(): void { auth_bootstrap_session(); $_SESSION = []; if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, [ 'path' => $params['path'] ?? '/', 'domain' => $params['domain'] ?? '', 'secure' => (bool) ($params['secure'] ?? false), 'httponly' => (bool) ($params['httponly'] ?? true), 'samesite' => $params['samesite'] ?? 'Lax', ]); } session_destroy(); }