207 lines
6.0 KiB
PHP
207 lines
6.0 KiB
PHP
<?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;
|
|
}
|
|
}
|