diff --git a/config/config.php b/config/config.php index 80ab44e..c59b023 100644 --- a/config/config.php +++ b/config/config.php @@ -106,6 +106,11 @@ $GLOBALS['config'] = [ 'SESSION_TIMEOUT' => 18000, // 5 hours in seconds 'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes + // How often an already-logged-in session re-validates Remote-User/ + // Remote-Groups against current Authelia/LLDAP state (AuthMiddleware). + // Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT. + 'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes + // CSRF settings 'CSRF_LIFETIME' => 3600, // 1 hour in seconds diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index 3201e29..27686fb 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -92,6 +92,19 @@ class AuthMiddleware } else { // Update last activity time $_SESSION['last_activity'] = time(); + + // Periodically re-validate Remote-User/Remote-Groups against + // current Authelia/LLDAP state, so a revoked admin (or anyone + // dropped from the required groups) loses access promptly + // instead of keeping it for up to SESSION_TIMEOUT. Only the + // idle timer was checked above; nothing previously re-read + // these headers once a session already existed. + $resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300; + $lastSync = $_SESSION['last_privilege_sync'] ?? 0; + if (time() - $lastSync > $resyncInterval) { + $this->resyncPrivileges(); + } + return $_SESSION['user']; } } @@ -134,6 +147,7 @@ class AuthMiddleware // Store user in session $_SESSION['user'] = $user; $_SESSION['last_activity'] = time(); + $_SESSION['last_privilege_sync'] = time(); // Generate new CSRF token on login require_once __DIR__ . '/CsrfMiddleware.php'; @@ -142,6 +156,64 @@ class AuthMiddleware return $user; } + /** + * Re-validate the current session's Remote-User/Remote-Groups against + * this request's forward-auth headers, and re-sync or revoke access on + * mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an + * already-authenticated session — see authenticate(). + * + * Best-effort: if this particular request doesn't carry forward-auth + * headers at all (e.g. a proxy hiccup), the session is left as-is rather + * than force-logging the user out, and the check is simply retried on + * the next request past the interval. + */ + private function resyncPrivileges(): void + { + $username = $this->getHeader('HTTP_REMOTE_USER'); + $groups = $this->getHeader('HTTP_REMOTE_GROUPS'); + + if (empty($username)) { + return; + } + + $this->enforceTrustedProxy(); + + // A different Remote-User than the session's own means Authelia is + // now asserting a different identity entirely for this proxy path; + // don't silently relabel the session as that other user. + if ($username !== ($_SESSION['user']['username'] ?? null)) { + return; + } + + if (!$this->checkGroupAccess($groups)) { + $this->logSecurityEvent('privilege_resync_revoked', [ + 'username' => $username, + 'groups' => $groups ?: 'none', + ]); + session_unset(); + session_destroy(); + $this->redirectToAuth(); + exit; + } + + $displayName = $this->getHeader('HTTP_REMOTE_NAME'); + $email = $this->getHeader('HTTP_REMOTE_EMAIL'); + + // Bypass UserModel's 5-minute in-process cache — that cache key isn't + // group-aware, so a stale cached hit here would silently keep serving + // the pre-revocation is_admin value for the rest of the cache's TTL. + UserModel::invalidateCache(null, $username); + $user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups); + + $wasAdmin = !empty($_SESSION['user']['is_admin']); + if ($wasAdmin && empty($user['is_admin'])) { + $this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]); + } + + $_SESSION['user'] = $user; + $_SESSION['last_privilege_sync'] = time(); + } + /** * Reject forward-auth headers that did not arrive via a trusted proxy. *