Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
MCP requests now run as the signed-in Tinker Tickets user, under exactly
the same rules as the web login, and expose the first two tools:
search_tickets and get_ticket.
Identity:
- IdentityMiddleware maps the validated token to a user with the same
checks as AuthMiddleware: the admin/employee group rule, now extracted
into helpers/AccessPolicy.php so both entry points share one copy, then
UserModel::syncUserFromAuthelia(), which creates/updates the row and
derives is_admin from groups.
- Claims are read from the validated token's server-side PSR-7 request
attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware
is deliberately not used: it array_merges into client-writable _meta,
so only the keys the validator happens to set are overwritten and a
client could inject others.
Scopes (ToolScopeMiddleware), enforced before dispatch:
- lifecycle messages need only a valid token; write tools (listed in
ToolCatalog, the single registry) need tickets:write; everything else
needs tickets:read, which tickets:write implies.
- Denials are the spec's step-up challenge: 403 +
WWW-Authenticate: Bearer error="insufficient_scope", scope=...,
resource_metadata=...
Tools (read-only, annotated readOnlyHint):
- search_tickets: text/status/priority/category/assignee ("me",
"unassigned", or a username), paginated, via TicketModel::getAllTickets
with the user's visibility filter. Defaults to every non-Closed status.
- get_ticket: details + comments, gated by canUserAccessTicket. A missing
ticket and a non-visible one return the same "not found".
Verified locally against a real MariaDB fixture (public, confidential,
internal+group, and closed tickets across two users), driving the real
pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT
validation stubbed: 20/20 checks pass, including visibility parity per
user, confidential tickets hidden from non-owners, the group check
rejecting a user without admin/employee, a missing preferred_username
rejected, 403 insufficient_scope for a token without tickets:*, and
write implying read. Also exercised the stateless 2026-07-28 era (no
session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which
returns the same visibility-filtered results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
437 lines
14 KiB
PHP
437 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
|
|
|
class AuthMiddleware
|
|
{
|
|
private $userModel;
|
|
private $conn;
|
|
|
|
public function __construct($conn)
|
|
{
|
|
$this->conn = $conn;
|
|
$this->userModel = new UserModel($conn);
|
|
}
|
|
|
|
/**
|
|
* Log security event for authentication failures
|
|
*
|
|
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
|
* @param array $context Additional context data
|
|
*/
|
|
private function logSecurityEvent(string $event, array $context = []): void
|
|
{
|
|
$logData = [
|
|
'event' => $event,
|
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
|
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
|
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
|
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
|
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
|
'timestamp' => date('c')
|
|
];
|
|
|
|
// Merge additional context
|
|
$logData = array_merge($logData, $context);
|
|
|
|
// Remove null values for cleaner logs
|
|
$logData = array_filter($logData, fn($v) => $v !== null);
|
|
|
|
// Format log message
|
|
$message = sprintf(
|
|
"[SECURITY] %s: %s",
|
|
strtoupper($event),
|
|
json_encode($logData, JSON_UNESCAPED_SLASHES)
|
|
);
|
|
|
|
error_log($message);
|
|
}
|
|
|
|
/**
|
|
* Authenticate user from Authelia forward auth headers
|
|
*
|
|
* @return array User data array
|
|
* @throws Exception if authentication fails
|
|
*/
|
|
public function authenticate()
|
|
{
|
|
// Start session if not already started with secure settings
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
// Configure secure session settings
|
|
ini_set('session.cookie_httponly', 1);
|
|
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
|
ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
|
ini_set('session.use_strict_mode', 1);
|
|
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
|
ini_set('session.gc_maxlifetime', $sessionTimeout);
|
|
ini_set('session.cookie_lifetime', 0); // Until browser closes
|
|
|
|
session_start();
|
|
}
|
|
|
|
// Check if user is already authenticated in session
|
|
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
|
// Verify session hasn't expired
|
|
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
|
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) {
|
|
// Log session expiration
|
|
$this->logSecurityEvent('session_expired', [
|
|
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
|
'user_id' => $_SESSION['user']['user_id'] ?? null,
|
|
'session_age_seconds' => time() - $_SESSION['last_activity']
|
|
]);
|
|
|
|
// Session expired, clear it
|
|
session_unset();
|
|
session_destroy();
|
|
session_start();
|
|
} 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'];
|
|
}
|
|
}
|
|
|
|
// Only honor Authelia forward-auth headers from a trusted reverse proxy.
|
|
// Without this, anything that can reach PHP directly could spoof
|
|
// Remote-User / Remote-Groups and log in (as admin). No valid session
|
|
// exists at this point, so we are about to trust request headers.
|
|
$this->enforceTrustedProxy();
|
|
|
|
// Read Authelia forward auth headers
|
|
$username = $this->getHeader('HTTP_REMOTE_USER');
|
|
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
|
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
|
|
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
|
|
|
|
// Check if authentication headers are present
|
|
if (empty($username)) {
|
|
// No auth headers - user not authenticated
|
|
$this->redirectToAuth();
|
|
exit;
|
|
}
|
|
|
|
// Check if user has required group membership
|
|
if (!$this->checkGroupAccess($groups)) {
|
|
$this->showAccessDenied($username, $groups);
|
|
exit;
|
|
}
|
|
|
|
// Sync user to database (create or update)
|
|
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
|
|
|
|
if (!$user) {
|
|
throw new Exception("Failed to sync user from Authelia");
|
|
}
|
|
|
|
// Regenerate session ID to prevent session fixation attacks
|
|
session_regenerate_id(true);
|
|
|
|
// 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';
|
|
CsrfMiddleware::generateToken();
|
|
|
|
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.
|
|
*
|
|
* If TRUSTED_PROXIES is configured and the connecting REMOTE_ADDR is not in
|
|
* the allowlist, the Remote-* headers cannot be trusted, so we refuse rather
|
|
* than honor a potentially spoofed identity. Empty allowlist = disabled.
|
|
*/
|
|
private function enforceTrustedProxy(): void
|
|
{
|
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
|
if (empty($trusted)) {
|
|
return; // Enforcement disabled (no allowlist configured)
|
|
}
|
|
|
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
if (!in_array($remoteAddr, $trusted, true)) {
|
|
$this->logSecurityEvent('untrusted_proxy', [
|
|
'reason' => 'Remote-* auth headers from non-allowlisted source',
|
|
'remote_addr' => $remoteAddr ?: 'unknown'
|
|
]);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
echo 'Forbidden: authentication headers must arrive via a trusted proxy.';
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get header value from server variables
|
|
*
|
|
* @param string $header Header name
|
|
* @return string|null Header value or null if not set
|
|
*/
|
|
private function getHeader($header)
|
|
{
|
|
if (isset($_SERVER[$header])) {
|
|
return $_SERVER[$header];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if user has required group membership
|
|
*
|
|
* @param string $groups Comma-separated group names
|
|
* @return bool True if user has access
|
|
*/
|
|
private function checkGroupAccess($groups)
|
|
{
|
|
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
|
|
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
|
|
}
|
|
|
|
/**
|
|
* Redirect to Authelia login
|
|
*/
|
|
private function redirectToAuth()
|
|
{
|
|
// Log unauthenticated access attempt
|
|
$this->logSecurityEvent('auth_required', [
|
|
'reason' => 'no_auth_headers'
|
|
]);
|
|
|
|
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
|
header('HTTP/1.1 401 Unauthorized');
|
|
echo '<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Authentication Required</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
}
|
|
.auth-container {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
max-width: 400px;
|
|
}
|
|
.auth-container h1 {
|
|
color: #333;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.auth-container p {
|
|
color: #666;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.auth-container a {
|
|
display: inline-block;
|
|
background: #4285f4;
|
|
color: white;
|
|
padding: 0.75rem 2rem;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
transition: background 0.2s;
|
|
}
|
|
.auth-container a:hover {
|
|
background: #357ae8;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="auth-container">
|
|
<h1>Authentication Required</h1>
|
|
<p>You need to be logged in to access Tinker Tickets.</p>
|
|
<a href="/">Continue to Login</a>
|
|
</div>
|
|
</body>
|
|
</html>';
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Show access denied page
|
|
*
|
|
* @param string $username Username
|
|
* @param string $groups User groups
|
|
*/
|
|
private function showAccessDenied($username, $groups)
|
|
{
|
|
// Log access denied event with user details
|
|
$this->logSecurityEvent('access_denied', [
|
|
'username' => $username,
|
|
'groups' => $groups ?: 'none',
|
|
'required_groups' => 'admin,employee',
|
|
'reason' => 'insufficient_group_membership'
|
|
]);
|
|
|
|
header('HTTP/1.1 403 Forbidden');
|
|
echo '<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Access Denied</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
}
|
|
.denied-container {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
max-width: 500px;
|
|
}
|
|
.denied-container h1 {
|
|
color: #d32f2f;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.denied-container p {
|
|
color: #666;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.denied-container .user-info {
|
|
background: #f5f5f5;
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
margin: 1rem 0;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="denied-container">
|
|
<h1>Access Denied</h1>
|
|
<p>You do not have permission to access Tinker Tickets.</p>
|
|
<p>Required groups: <strong>admin</strong> or <strong>employee</strong></p>
|
|
<div class="user-info">
|
|
<div>Username: ' . htmlspecialchars($username) . '</div>
|
|
<div>Groups: ' . htmlspecialchars($groups ?: 'none') . '</div>
|
|
</div>
|
|
<p>Please contact your administrator if you believe this is an error.</p>
|
|
</div>
|
|
</body>
|
|
</html>';
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Get current authenticated user from session
|
|
*
|
|
* @return array|null User data or null if not authenticated
|
|
*/
|
|
public static function getCurrentUser()
|
|
{
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
return $_SESSION['user'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Logout current user
|
|
*/
|
|
public static function logout()
|
|
{
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
session_unset();
|
|
session_destroy();
|
|
}
|
|
}
|