deploy
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-20 01:50:07 +02:00
parent 3f81f7a670
commit 2cdd14c400
210 changed files with 55853 additions and 36 deletions

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App;
final class AuthService
{
private const SESSION_TTL = 604800;
public function __construct(private App $app) {}
public function isEnabled(): bool
{
return $this->app->config()->authEnabled;
}
public function start(): void
{
$this->app->session()->start();
}
public function user(): ?array
{
$this->start();
$user = $_SESSION['auth_user'] ?? null;
if (!is_array($user)) {
return null;
}
$expiresAt = (int) ($_SESSION['auth_expires_at'] ?? 0);
if ($expiresAt > 0 && $expiresAt < time()) {
$this->clearLocalSession();
return null;
}
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
return $user;
}
public function isAuthenticated(): bool
{
return $this->user() !== null;
}
public function login(string $returnTo = '/'): void
{
if (!$this->isEnabled()) {
redirect($returnTo !== '' ? $returnTo : '/');
}
$this->start();
$_SESSION['oidc_return_to'] = $this->safeReturnTo($returnTo);
redirect('/auth/login');
}
public function callback(): void
{
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
redirect('/auth/callback' . ($query !== '' ? '?' . $query : ''));
}
public function logout(): void
{
redirect('/auth/logout');
}
public function storeUser(array $claims, array $groups, string $idToken): void
{
$username = (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? '');
$_SESSION['auth_user'] = [
'sub' => (string) ($claims['sub'] ?? ''),
'username' => $username,
'email' => (string) ($claims['email'] ?? ''),
'name' => (string) ($claims['name'] ?? $username),
'groups' => $groups,
'id_token' => $idToken,
];
$_SESSION['auth_id_token'] = $idToken;
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
$this->rememberKeycloakUser($claims, $groups);
}
public function canAccessModule(array $module): bool
{
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
$required = (bool) ($auth['required'] ?? false);
if (!$required || !$this->isEnabled()) {
return true;
}
$user = $this->user();
if ($user === null) {
return false;
}
$allowedUsers = $this->normalizeList($auth['users'] ?? []);
$allowedGroups = $this->normalizeList($auth['groups'] ?? []);
if ($allowedUsers === [] && $allowedGroups === []) {
return true;
}
$username = strtolower((string) ($user['username'] ?? ''));
$email = strtolower((string) ($user['email'] ?? ''));
$sub = strtolower((string) ($user['sub'] ?? ''));
foreach ($allowedUsers as $allowedUser) {
if ($allowedUser === $username || ($email !== '' && $allowedUser === $email) || ($sub !== '' && $allowedUser === $sub)) {
return true;
}
}
$userGroups = $this->normalizeList($user['groups'] ?? []);
return array_intersect($allowedGroups, $userGroups) !== [];
}
private function rememberKeycloakUser(array $claims, array $groups): void
{
$pdo = $this->app->basePdo();
$sub = trim((string)($claims['sub'] ?? ''));
if (!$pdo || $sub === '') {
return;
}
$groupsJson = json_encode(array_values(array_unique(array_map('strval', $groups))), JSON_UNESCAPED_UNICODE);
if (!is_string($groupsJson)) {
$groupsJson = '[]';
}
$stmt = $pdo->prepare(
"INSERT INTO nexus_auth_users (sub, username, email, name, groups, last_login_at)
VALUES (:sub, :username, :email, :name, :groups, CURRENT_TIMESTAMP)
ON CONFLICT(sub) DO UPDATE SET
username = excluded.username,
email = excluded.email,
name = excluded.name,
groups = excluded.groups,
last_login_at = CURRENT_TIMESTAMP"
);
$stmt->execute([
'sub' => $sub,
'username' => (string)($claims['preferred_username'] ?? ''),
'email' => (string)($claims['email'] ?? ''),
'name' => (string)($claims['name'] ?? ''),
'groups' => $groupsJson,
]);
}
public function requireModuleAccess(array $module): void
{
if ($this->canAccessModule($module)) {
return;
}
if (!$this->isAuthenticated()) {
$this->login($this->currentPath());
}
http_response_code(403);
exit('Forbidden');
}
public function filterModules(array $modules): array
{
return array_values(array_filter($modules, fn (array $module): bool => $this->canAccessModule($module)));
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = strtolower(trim((string) $value));
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
private function clearLocalSession(): void
{
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
}
private function currentPath(): string
{
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
return $this->safeReturnTo($uri);
}
private function safeReturnTo(string $returnTo): string
{
$returnTo = trim($returnTo);
if ($returnTo === '' || !str_starts_with($returnTo, '/') || str_starts_with($returnTo, '//')) {
return '/';
}
return $returnTo;
}
}