- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu) - users-handlers.php: password_hash explicitne vyloucen z dotazu - Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech) - Validator.php: validacni helper s pravidly required, string, int, email, in, numeric - PaginationHelper: PHPStan typy opraveny Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
664 lines
23 KiB
PHP
664 lines
23 KiB
PHP
<?php
|
|
|
|
/**
|
|
* JWT Authentication Handler
|
|
*
|
|
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
|
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
|
* Refresh tokens: Long-lived, stored in httpOnly cookie
|
|
*
|
|
* Without "remember me": Session cookie + 1 hour DB expiry (sliding window on activity)
|
|
* With "remember me": Persistent cookie + 30 day expiry
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Firebase\JWT\JWT;
|
|
use Firebase\JWT\Key;
|
|
use Firebase\JWT\ExpiredException;
|
|
|
|
class JWTAuth
|
|
{
|
|
private const ALGORITHM = 'HS256';
|
|
|
|
// Cache for config values
|
|
private static ?int $accessTokenExpiry = null;
|
|
private static ?int $refreshTokenExpirySession = null;
|
|
private static ?int $refreshTokenExpiryDays = null;
|
|
|
|
private static ?string $secretKey = null;
|
|
|
|
/**
|
|
* Get the secret key from environment
|
|
*/
|
|
private static function getSecretKey(): string
|
|
{
|
|
if (self::$secretKey === null) {
|
|
self::$secretKey = env('JWT_SECRET');
|
|
if (empty(self::$secretKey)) {
|
|
throw new Exception('JWT_SECRET not configured in environment');
|
|
}
|
|
if (strlen(self::$secretKey) < 32) {
|
|
throw new Exception('JWT_SECRET must be at least 32 characters');
|
|
}
|
|
}
|
|
return self::$secretKey;
|
|
}
|
|
|
|
/**
|
|
* Get access token expiry in seconds (from env or default 900 = 15 min)
|
|
*/
|
|
public static function getAccessTokenExpiry(): int
|
|
{
|
|
if (self::$accessTokenExpiry === null) {
|
|
self::$accessTokenExpiry = (int) env('JWT_ACCESS_TOKEN_EXPIRY', 900);
|
|
}
|
|
return self::$accessTokenExpiry;
|
|
}
|
|
|
|
/**
|
|
* Get refresh token session expiry in seconds (from env or default 3600 = 1 hour)
|
|
* Used when "remember me" is NOT checked
|
|
*/
|
|
private static function getRefreshTokenExpirySession(): int
|
|
{
|
|
if (self::$refreshTokenExpirySession === null) {
|
|
self::$refreshTokenExpirySession = (int) env('JWT_REFRESH_TOKEN_EXPIRY_SESSION', 3600);
|
|
}
|
|
return self::$refreshTokenExpirySession;
|
|
}
|
|
|
|
/**
|
|
* Get refresh token expiry in days (from env or default 30)
|
|
* Used when "remember me" IS checked
|
|
*/
|
|
private static function getRefreshTokenExpiryDays(): int
|
|
{
|
|
if (self::$refreshTokenExpiryDays === null) {
|
|
self::$refreshTokenExpiryDays = (int) env('JWT_REFRESH_TOKEN_EXPIRY_DAYS', 30);
|
|
}
|
|
return self::$refreshTokenExpiryDays;
|
|
}
|
|
|
|
/**
|
|
* Generate an access token (short-lived, for API requests)
|
|
*
|
|
* @param array<string, mixed> $userData
|
|
*/
|
|
public static function generateAccessToken(array $userData): string
|
|
{
|
|
$issuedAt = time();
|
|
$expiry = $issuedAt + self::getAccessTokenExpiry();
|
|
|
|
$payload = [
|
|
'iss' => 'boha-automation', // Issuer
|
|
'iat' => $issuedAt, // Issued at
|
|
'exp' => $expiry, // Expiry
|
|
'type' => 'access', // Token type
|
|
'sub' => $userData['id'], // Subject (user ID)
|
|
'user' => [
|
|
'id' => $userData['id'],
|
|
'username' => $userData['username'],
|
|
'email' => $userData['email'],
|
|
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
|
'role' => $userData['role'] ?? null,
|
|
'role_display' => $userData['role_display'] ?? $userData['role'] ?? null,
|
|
'is_admin' => $userData['is_admin'] ?? ($userData['role'] === 'admin'),
|
|
],
|
|
];
|
|
|
|
return JWT::encode($payload, self::getSecretKey(), self::ALGORITHM);
|
|
}
|
|
|
|
/**
|
|
* Generate a refresh token (stored in httpOnly cookie)
|
|
*
|
|
* @param int $userId User ID
|
|
* @param bool $remember If true: 30 day persistent cookie. If false: session cookie (1 hour DB expiry)
|
|
*/
|
|
public static function generateRefreshToken(int $userId, bool $remember = false): string
|
|
{
|
|
$token = bin2hex(random_bytes(32)); // 64 character random string
|
|
$hashedToken = hash('sha256', $token);
|
|
|
|
// Calculate expiry based on remember me
|
|
if ($remember) {
|
|
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400); // 30 days default
|
|
$cookieExpiry = $dbExpiry; // Persistent cookie
|
|
} else {
|
|
$dbExpiry = time() + self::getRefreshTokenExpirySession(); // 1 hour default
|
|
$cookieExpiry = 0; // Session cookie (deleted on browser close)
|
|
}
|
|
|
|
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
|
|
|
try {
|
|
$pdo = db();
|
|
|
|
// Pročistit replaced tokeny (po grace period uz nepotřebné)
|
|
$stmt = $pdo->prepare(
|
|
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL'
|
|
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
|
|
// Limit aktivních sessions per user (max 5 devices)
|
|
$stmt = $pdo->prepare(
|
|
'SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NULL'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$count = $stmt->fetchColumn();
|
|
|
|
if ($count >= 5) {
|
|
$stmt = $pdo->prepare('
|
|
DELETE FROM refresh_tokens
|
|
WHERE user_id = ? AND replaced_at IS NULL
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
');
|
|
$stmt->execute([$userId]);
|
|
}
|
|
|
|
// Store new refresh token
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
');
|
|
$stmt->execute([
|
|
$userId,
|
|
$hashedToken,
|
|
$expiresAt,
|
|
getClientIp(),
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
|
$remember ? 1 : 0,
|
|
]);
|
|
|
|
// Set httpOnly cookie
|
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
|
|
|
setcookie('refresh_token', $token, [
|
|
'expires' => $cookieExpiry,
|
|
'path' => '/api/',
|
|
'domain' => '',
|
|
'secure' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
]);
|
|
|
|
return $token;
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth refresh token error: ' . $e->getMessage());
|
|
throw new Exception('Failed to create refresh token');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify and decode an access token
|
|
*
|
|
* @return array{user_id: mixed, user: array<string, mixed>}|null
|
|
*/
|
|
public static function verifyAccessToken(string $token): ?array
|
|
{
|
|
try {
|
|
$decoded = JWT::decode($token, new Key(self::getSecretKey(), self::ALGORITHM));
|
|
$payload = (array) $decoded;
|
|
|
|
// Verify it's an access token
|
|
if (($payload['type'] ?? '') !== 'access') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'user_id' => $payload['sub'],
|
|
'user' => (array) $payload['user'],
|
|
];
|
|
} catch (ExpiredException $e) {
|
|
// Token expired - client should use refresh token
|
|
return null;
|
|
} catch (Exception $e) {
|
|
error_log('JWT verification error: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify refresh token and return user data if valid
|
|
* Returns array with 'user' data and 'remember_me' flag
|
|
* Deletes expired tokens from database when found
|
|
*
|
|
* @return array{user: array<string, mixed>, remember_me: bool, in_grace_period?: bool}|null
|
|
*/
|
|
public static function verifyRefreshToken(?string $token = null): ?array
|
|
{
|
|
// Get token from cookie if not provided
|
|
if ($token === null) {
|
|
$token = $_COOKIE['refresh_token'] ?? null;
|
|
}
|
|
|
|
if (empty($token)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$pdo = db();
|
|
$hashedToken = hash('sha256', $token);
|
|
|
|
// First check if token exists (regardless of expiry)
|
|
$stmt = $pdo->prepare('
|
|
SELECT rt.id, rt.user_id, rt.token_hash, rt.expires_at,
|
|
rt.replaced_at, rt.remember_me,
|
|
u.id as user_id, u.username, u.email,
|
|
u.first_name, u.last_name, u.is_active,
|
|
r.name as role_name, r.display_name as role_display_name
|
|
FROM refresh_tokens rt
|
|
JOIN users u ON rt.user_id = u.id
|
|
LEFT JOIN roles r ON u.role_id = r.id
|
|
WHERE rt.token_hash = ?
|
|
');
|
|
$stmt->execute([$hashedToken]);
|
|
$data = $stmt->fetch();
|
|
|
|
if (!$data) {
|
|
self::clearRefreshCookie();
|
|
return null;
|
|
}
|
|
|
|
// Token byl rotovan - zkontrolovat grace period
|
|
if ($data['replaced_at'] !== null) {
|
|
$replacedAt = strtotime($data['replaced_at']);
|
|
if ((time() - $replacedAt) <= self::ROTATION_GRACE_PERIOD) {
|
|
// Grace period - token jeste plati (souběžny request)
|
|
if (!$data['is_active']) {
|
|
return null;
|
|
}
|
|
return [
|
|
'user' => [
|
|
'id' => $data['user_id'],
|
|
'username' => $data['username'],
|
|
'email' => $data['email'],
|
|
'first_name' => $data['first_name'],
|
|
'last_name' => $data['last_name'],
|
|
'role' => $data['role_name'],
|
|
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
|
'is_admin' => $data['role_name'] === 'admin',
|
|
'permissions' => self::getUserPermissions($data['user_id']),
|
|
],
|
|
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
|
'in_grace_period' => true,
|
|
];
|
|
}
|
|
|
|
// Po grace period - stary token uz neni platny, smazat jen tento token
|
|
$uid = $data['user_id'];
|
|
error_log("Refresh token reuse after grace period for user {$uid}");
|
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
|
$stmt->execute([$hashedToken]);
|
|
self::clearRefreshCookie();
|
|
return null;
|
|
}
|
|
|
|
// Check if token is expired
|
|
if (strtotime($data['expires_at']) < time()) {
|
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
|
$stmt->execute([$hashedToken]);
|
|
self::clearRefreshCookie();
|
|
return null;
|
|
}
|
|
|
|
// Check user is still active
|
|
if (!$data['is_active']) {
|
|
self::revokeRefreshToken($token);
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'user' => [
|
|
'id' => $data['user_id'],
|
|
'username' => $data['username'],
|
|
'email' => $data['email'],
|
|
'first_name' => $data['first_name'],
|
|
'last_name' => $data['last_name'],
|
|
'role' => $data['role_name'],
|
|
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
|
'is_admin' => $data['role_name'] === 'admin',
|
|
'permissions' => self::getUserPermissions($data['user_id']),
|
|
],
|
|
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
|
];
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth verify refresh error: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Grace period pro rotovane tokeny (sekundy) */
|
|
private const ROTATION_GRACE_PERIOD = 30;
|
|
|
|
public static function getGracePeriod(): int
|
|
{
|
|
return self::ROTATION_GRACE_PERIOD;
|
|
}
|
|
|
|
/**
|
|
* Refresh tokens - issue new access token + rotate refresh token
|
|
* Grace period 30s pro souběžné requesty
|
|
*
|
|
* @return array{access_token: string, user: array<string, mixed>, expires_in: int}|null
|
|
*/
|
|
public static function refreshTokens(): ?array
|
|
{
|
|
$token = $_COOKIE['refresh_token'] ?? null;
|
|
|
|
$tokenData = self::verifyRefreshToken($token);
|
|
if (!$tokenData) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$userData = $tokenData['user'];
|
|
$accessToken = self::generateAccessToken($userData);
|
|
|
|
// Rotace: pokud token nebyl jiz nahrazen (grace period request), rotovat
|
|
if (!($tokenData['in_grace_period'] ?? false)) {
|
|
self::rotateRefreshToken(
|
|
$token,
|
|
$userData['id'],
|
|
(bool) $tokenData['remember_me']
|
|
);
|
|
}
|
|
|
|
return [
|
|
'access_token' => $accessToken,
|
|
'user' => [
|
|
'id' => $userData['id'],
|
|
'username' => $userData['username'],
|
|
'email' => $userData['email'],
|
|
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
|
'role' => $userData['role'],
|
|
'role_display' => $userData['role_display'],
|
|
'is_admin' => $userData['is_admin'],
|
|
'permissions' => $userData['permissions'] ?? self::getUserPermissions($userData['id']),
|
|
],
|
|
'expires_in' => self::getAccessTokenExpiry(),
|
|
];
|
|
} catch (Exception $e) {
|
|
error_log('JWTAuth refresh error: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotace refresh tokenu - vygeneruje novy, stary oznaci jako replaced
|
|
*/
|
|
private static function rotateRefreshToken(string $oldToken, int $userId, bool $remember): void
|
|
{
|
|
$pdo = db();
|
|
$oldHash = hash('sha256', $oldToken);
|
|
|
|
$newToken = bin2hex(random_bytes(32));
|
|
$newHash = hash('sha256', $newToken);
|
|
|
|
if ($remember) {
|
|
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400);
|
|
$cookieExpiry = $dbExpiry;
|
|
} else {
|
|
$dbExpiry = time() + self::getRefreshTokenExpirySession();
|
|
$cookieExpiry = 0;
|
|
}
|
|
|
|
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
|
|
|
// Oznacit stary token jako replaced (atomicky - race condition ochrana)
|
|
$stmt = $pdo->prepare('
|
|
UPDATE refresh_tokens SET replaced_at = NOW(), replaced_by_hash = ?
|
|
WHERE token_hash = ? AND replaced_at IS NULL
|
|
');
|
|
$stmt->execute([$newHash, $oldHash]);
|
|
|
|
// Jiny request uz token rotoval - nepokracovat
|
|
if ($stmt->rowCount() === 0) {
|
|
return;
|
|
}
|
|
|
|
// Procistit drive replaced tokeny (az po uspesne rotaci, respektovat grace period)
|
|
$pdo->prepare(
|
|
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL AND token_hash != ?'
|
|
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
|
)->execute([$userId, $oldHash]);
|
|
|
|
// Vlozit novy token
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
');
|
|
$stmt->execute([
|
|
$userId,
|
|
$newHash,
|
|
$expiresAt,
|
|
getClientIp(),
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
|
$remember ? 1 : 0,
|
|
]);
|
|
|
|
// Novy cookie
|
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
|
setcookie('refresh_token', $newToken, [
|
|
'expires' => $cookieExpiry,
|
|
'path' => '/api/',
|
|
'domain' => '',
|
|
'secure' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Revoke a specific refresh token
|
|
*/
|
|
public static function revokeRefreshToken(string $token): bool
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
$hashedToken = hash('sha256', $token);
|
|
|
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
|
$stmt->execute([$hashedToken]);
|
|
|
|
self::clearRefreshCookie();
|
|
|
|
return true;
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth revoke error: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revoke all refresh tokens for a user (logout from all devices)
|
|
*/
|
|
public static function revokeAllUserTokens(int $userId): bool
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
|
$stmt->execute([$userId]);
|
|
|
|
self::clearRefreshCookie();
|
|
|
|
return true;
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth revoke all error: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the refresh token cookie
|
|
*/
|
|
private static function clearRefreshCookie(): void
|
|
{
|
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
|
setcookie('refresh_token', '', [
|
|
'expires' => time() - 3600,
|
|
'path' => '/api/',
|
|
'domain' => '',
|
|
'secure' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
]);
|
|
unset($_COOKIE['refresh_token']);
|
|
}
|
|
|
|
/**
|
|
* Get access token from Authorization header
|
|
*/
|
|
public static function getTokenFromHeader(): ?string
|
|
{
|
|
$headers = getallheaders();
|
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
|
|
|
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require valid access token
|
|
* Also verifies refresh token still exists in database (session not revoked)
|
|
* Extends session expiry only when less than 50% of time remaining (smart extend)
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function requireAuth(): array
|
|
{
|
|
$token = self::getTokenFromHeader();
|
|
|
|
if (!$token) {
|
|
errorResponse('Access token required', 401);
|
|
}
|
|
|
|
$payload = self::verifyAccessToken($token);
|
|
|
|
if (!$payload) {
|
|
errorResponse('Invalid or expired token', 401);
|
|
}
|
|
|
|
// Verify refresh token exists + smart extend in a single query
|
|
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
|
if ($refreshToken) {
|
|
$hashedToken = hash('sha256', $refreshToken);
|
|
try {
|
|
$pdo = db();
|
|
// Verify session - tolerovat replaced tokeny v grace period
|
|
$stmt = $pdo->prepare('
|
|
SELECT id, remember_me, expires_at, replaced_at
|
|
FROM refresh_tokens
|
|
WHERE token_hash = ? AND expires_at > NOW()
|
|
');
|
|
$stmt->execute([$hashedToken]);
|
|
$tokenData = $stmt->fetch();
|
|
|
|
if (!$tokenData) {
|
|
self::clearRefreshCookie();
|
|
errorResponse('Session revoked', 401);
|
|
}
|
|
|
|
// Replaced token v grace period - jen validovat, neextendovat
|
|
if ($tokenData['replaced_at'] !== null) {
|
|
$replacedAt = strtotime($tokenData['replaced_at']);
|
|
if ((time() - $replacedAt) > self::ROTATION_GRACE_PERIOD) {
|
|
self::clearRefreshCookie();
|
|
errorResponse('Session revoked', 401);
|
|
}
|
|
// V grace period - skip extend, access token jeste plati
|
|
return $payload;
|
|
}
|
|
|
|
// Smart extend: only UPDATE when less than 50% of session time remaining
|
|
$expiresAt = strtotime($tokenData['expires_at']);
|
|
$now = time();
|
|
$remaining = $expiresAt - $now;
|
|
|
|
if ($tokenData['remember_me']) {
|
|
$totalWindow = self::getRefreshTokenExpiryDays() * 86400;
|
|
} else {
|
|
$totalWindow = self::getRefreshTokenExpirySession();
|
|
}
|
|
|
|
// Only extend if less than 50% remaining
|
|
if ($remaining < ($totalWindow * 0.5)) {
|
|
$newExpiry = date('Y-m-d H:i:s', $now + $totalWindow);
|
|
$stmt = $pdo->prepare('UPDATE refresh_tokens SET expires_at = ? WHERE id = ?');
|
|
$stmt->execute([$newExpiry, $tokenData['id']]);
|
|
|
|
// Refresh cookie expiry for remember-me sessions
|
|
if ($tokenData['remember_me']) {
|
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
|
setcookie('refresh_token', $refreshToken, [
|
|
'expires' => $now + $totalWindow,
|
|
'path' => '/api/',
|
|
'domain' => '',
|
|
'secure' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
]);
|
|
}
|
|
}
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth session check error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Middleware: Optional auth - returns user data if valid token, null otherwise
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function optionalAuth(): ?array
|
|
{
|
|
$token = self::getTokenFromHeader();
|
|
|
|
if (!$token) {
|
|
return null;
|
|
}
|
|
|
|
return self::verifyAccessToken($token);
|
|
}
|
|
|
|
/**
|
|
* Get permission names for a user
|
|
* Admin role returns all permissions.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function getUserPermissions(int $userId): array
|
|
{
|
|
return getUserPermissions($userId);
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired and replaced refresh tokens
|
|
*/
|
|
public static function cleanupExpiredTokens(): int
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare(
|
|
'DELETE FROM refresh_tokens WHERE expires_at < NOW()'
|
|
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
|
. self::ROTATION_GRACE_PERIOD . ' SECOND))'
|
|
);
|
|
$stmt->execute();
|
|
return $stmt->rowCount();
|
|
} catch (PDOException $e) {
|
|
error_log('JWTAuth cleanup error: ' . $e->getMessage());
|
|
return 0;
|
|
}
|
|
}
|
|
}
|