Periodically re-sync session privileges from Authelia (#56)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 3s
AuthMiddleware::authenticate() only re-read Remote-User/Remote-Groups (and thus is_admin, via UserModel::syncUserFromAuthelia) when $_SESSION['user'] didn't exist yet. Once a session existed, every subsequent request only checked the idle timer — never re-validating against current Authelia/LLDAP state. An admin's group membership revoked in LLDAP, or a logout at the Authelia proxy, left their already-open session with full access for up to SESSION_TIMEOUT (5h default), with no way to force early revocation short of clearing the server-side session store. Added PRIVILEGE_RESYNC_INTERVAL (default 5 min, matching UserModel's own cache TTL) and a resyncPrivileges() check on every already-authenticated request past that interval: re-reads the current request's forward-auth headers (enforcing the trusted-proxy check again, same as a fresh login), and either destroys the session and redirects to re-auth if the user no longer has any required group, or re-syncs is_admin/groups/display_name/email if they do. Best-effort if this particular request doesn't carry forward-auth headers at all (skips silently rather than force-logging out, retried next interval). UserModel::syncUserFromAuthelia() has its own 5-minute in-process cache keyed only by username (not by the groups being synced), so a naive re-call during a resync would have kept returning the pre-revocation cached result for up to 5 more minutes — invalidated that cache entry immediately beforehand to guarantee a real re-sync. Verified against real MariaDB across a fresh login, a same-interval request confirming no premature resync, an admin-privilege-revocation mid-session (is_admin flips to false in both session and DB, verified via a direct query), and a full group-membership revocation (session destroyed, redirected to re-auth, confirmed the request never reaches past that point). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user