$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}|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, 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, 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 */ 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|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 */ 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; } } }