Files
app/api/includes/JWTAuth.php
Simon 758be819c3 feat: P4 backend kvalita - SELECT * fix, overdue konsolidace, Validator
- 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>
2026-03-12 18:42:42 +01:00

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