Files
tinker_tickets/middleware/AuthMiddleware.php
jaredandClaude Opus 5.5 65deedc295
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
Add MCP identity mapping and read tools (#111, phase 4)
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
2026-09-24 18:58:30 -04:00

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();
}
}