# BOHA Automation - Authentication System Documentation Complete reference documentation for the BOHA authentication system, covering PHP backend and React frontend implementation. --- ## Table of Contents 1. [Overview](#1-overview) 2. [Configuration](#2-configuration) 3. [Database Tables](#3-database-tables) 4. [Complete Code Listings](#4-complete-code-listings) 5. [Security Features](#5-security-features) 6. [Authentication Flows](#6-authentication-flows) 7. [API Endpoints](#7-api-endpoints) --- ## 1. Overview The BOHA authentication system is a session-based authentication mechanism with the following architecture: ``` Frontend (React) Backend (PHP) ----------------- --------------- AuthContext.jsx <-- HTTP --> Auth.php (Core logic) Login.jsx login.php (Endpoint) AdminLayout.jsx logout.php (Endpoint) api.js session.php (Endpoint) csrf.php (Endpoint) config.php (Configuration) RateLimiter.php (Rate limiting) ``` ### Key Features - Database-backed sessions stored in `user_sessions` table - Secure session configuration (HttpOnly, Secure, SameSite cookies) - Session regeneration on login to prevent session fixation - Remember me functionality with secure token rotation (selector:token pattern) - Brute force protection with account lockout - CSRF protection with token rotation on login - IP-based rate limiting per endpoint - Security headers on all responses - Sliding session expiration - Automatic cleanup of expired sessions and tokens --- ## 2. Configuration All authentication constants are defined in `api/config.php`: | Constant | Value | Description | |----------|-------|-------------| | `SESSION_NAME` | `'BOHA_ADMIN_SESSION'` | PHP session cookie name | | `SESSION_LIFETIME_MINUTES` | `60` | Session expires after 60 minutes of inactivity | | `REMEMBER_ME_DAYS` | `30` | Remember me token valid for 30 days | | `MAX_LOGIN_ATTEMPTS` | `5` | Lock account after 5 failed attempts | | `LOCKOUT_MINUTES` | `15` | Account lock duration in minutes | | `BCRYPT_COST` | `12` | Bcrypt cost factor for password hashing | | `RATE_LIMIT_STORAGE_PATH` | `__DIR__ . '/rate_limits'` | Directory for rate limit files | ### CORS Configuration ```php define('CORS_ALLOWED_ORIGINS', [ 'http://localhost:3000', 'http://localhost:3001', 'http://127.0.0.1:3000', 'http://www.boha-automation.cz', 'https://www.boha-automation.cz' ]); ``` --- ## 3. Database Tables ### users ```sql CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, first_name VARCHAR(100), last_name VARCHAR(100), role_id INT NOT NULL, is_active TINYINT(1) DEFAULT 1, failed_login_attempts INT DEFAULT 0, locked_until DATETIME NULL, last_login DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (role_id) REFERENCES roles(id) ); ``` ### roles ```sql CREATE TABLE roles ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, display_name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Default roles INSERT INTO roles (name, display_name) VALUES ('admin', 'Administrator'), ('user', 'User'); ``` ### user_sessions ```sql CREATE TABLE user_sessions ( id VARCHAR(128) PRIMARY KEY, -- PHP session ID user_id INT NOT NULL, ip_address VARCHAR(45), user_agent VARCHAR(255), expires_at DATETIME NOT NULL, is_active TINYINT(1) DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at); CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); ``` ### remember_me_tokens ```sql CREATE TABLE remember_me_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, selector VARCHAR(32) NOT NULL UNIQUE, -- Public identifier token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of token expires_at DATETIME NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_remember_selector ON remember_me_tokens(selector); CREATE INDEX idx_remember_expires ON remember_me_tokens(expires_at); ``` --- ## 4. Complete Code Listings ### 4.1 api/config.php ```php PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . DB_CHARSET ]; try { $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); } catch (PDOException $e) { if (DEBUG_MODE) { throw $e; } error_log("Database connection failed: " . $e->getMessage()); throw new PDOException("Database connection failed"); } } return $pdo; } /** * Set CORS headers for API responses */ function setCorsHeaders(): void { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (in_array($origin, CORS_ALLOWED_ORIGINS)) { header("Access-Control-Allow-Origin: $origin"); } else { // Allow localhost for development header("Access-Control-Allow-Origin: http://localhost:3000"); } header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); header('Access-Control-Max-Age: 86400'); // Handle preflight requests if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); } } /** * Send JSON response and exit * * @param mixed $data Data to send * @param int $statusCode HTTP status code */ function jsonResponse($data, int $statusCode = 200): void { http_response_code($statusCode); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit(); } /** * Send error response * * @param string $message Error message * @param int $statusCode HTTP status code */ function errorResponse(string $message, int $statusCode = 400): void { jsonResponse(['success' => false, 'error' => $message], $statusCode); } /** * Send success response * * @param mixed $data Data to include * @param string $message Optional message */ function successResponse($data = null, string $message = ''): void { $response = ['success' => true]; if ($message) { $response['message'] = $message; } if ($data !== null) { $response['data'] = $data; } jsonResponse($response); } /** * Get JSON request body * * @return array Decoded JSON data */ function getJsonInput(): array { $input = file_get_contents('php://input'); $data = json_decode($input, true); return is_array($data) ? $data : []; } /** * Sanitize string input * * @param string $input Input string * @return string Sanitized string */ function sanitize(string $input): string { return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); } /** * Validate email format * * @param string $email Email to validate * @return bool True if valid */ function isValidEmail(string $email): bool { return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; } /** * Generate CSRF token * * @return string CSRF token */ function generateCsrfToken(): string { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } /** * Verify CSRF token * * @param string $token Token to verify * @return bool True if valid */ function verifyCsrfToken(string $token): bool { return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); } /** * Get client IP address * * Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP). * If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future, * update this function to trust specific proxy headers only from known proxy IPs. * * @return string IP address */ function getClientIp(): string { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Set security headers for API responses * * Sets standard security headers to protect against common web vulnerabilities: * - X-Content-Type-Options: Prevents MIME type sniffing * - X-Frame-Options: Prevents clickjacking attacks * - X-XSS-Protection: Enables browser XSS filter * - Referrer-Policy: Controls referrer information sent with requests * * Note: Content-Security-Policy is not set here as it may interfere with the React frontend */ function setSecurityHeaders(): void { header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); } /** * Set no-cache headers * * Prevents browser caching for sensitive endpoints */ function setNoCacheHeaders(): void { header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Cache-Control: post-check=0, pre-check=0', false); header('Pragma: no-cache'); } ``` ### 4.2 api/includes/Auth.php ```php 0, 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax' ]); session_start(); } self::$initialized = true; // Cleanup expired sessions occasionally (1% of requests) if (rand(1, 100) === 1) { self::cleanupExpiredSessions(); } } /** * Attempt user login * * @param string $username Username or email * @param string $password Plain text password * @param bool $remember Create remember me token * @return array Result with success status and error code if failed */ public static function login(string $username, string $password, bool $remember = false): array { try { $pdo = db(); // Find user by username or email $stmt = $pdo->prepare(" SELECT u.*, r.name as role_name, r.display_name as role_display_name FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.username = ? OR u.email = ? "); $stmt->execute([$username, $username]); $user = $stmt->fetch(); if (!$user) { return ['success' => false, 'error' => 'invalid_credentials']; } // Check if account is locked if ($user['locked_until'] && strtotime($user['locked_until']) > time()) { $remaining = ceil((strtotime($user['locked_until']) - time()) / 60); return ['success' => false, 'error' => 'account_locked', 'minutes' => $remaining]; } // Verify password if (!password_verify($password, $user['password_hash'])) { self::incrementFailedAttempts($user['id'], $user['failed_login_attempts']); return ['success' => false, 'error' => 'invalid_credentials']; } // Check if account is active if (!$user['is_active']) { return ['success' => false, 'error' => 'account_deactivated']; } // Successful login - regenerate session ID session_regenerate_id(true); // Force new CSRF token generation post-login // This prevents Login CSRF attacks where an attacker could use a pre-login CSRF token for post-login actions unset($_SESSION['csrf_token']); // Create session data self::createUserSession($user); // Store session in database (with longer expiry if remember me) self::storeSessionInDatabase($user['id'], $remember); // Create remember me token if requested if ($remember) { self::createRememberMeToken($user['id']); } // Reset failed attempts and update last login $stmt = $pdo->prepare(" UPDATE users SET last_login = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE id = ? "); $stmt->execute([$user['id']]); return [ 'success' => true, 'user' => self::getUserData() ]; } catch (PDOException $e) { error_log("Auth login error: " . $e->getMessage()); return ['success' => false, 'error' => 'system_error']; } } /** * Increment failed login attempts */ private static function incrementFailedAttempts(int $userId, int $currentAttempts): void { try { $pdo = db(); $attempts = $currentAttempts + 1; $lockedUntil = null; if ($attempts >= MAX_LOGIN_ATTEMPTS) { $lockedUntil = date('Y-m-d H:i:s', time() + (LOCKOUT_MINUTES * 60)); } $stmt = $pdo->prepare(" UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ? "); $stmt->execute([$attempts, $lockedUntil, $userId]); } catch (PDOException $e) { error_log("Auth increment attempts error: " . $e->getMessage()); } } /** * Create user session data */ private static function createUserSession(array $user): void { $_SESSION['auth'] = [ 'user_id' => $user['id'], 'username' => $user['username'], 'email' => $user['email'], 'full_name' => trim($user['first_name'] . ' ' . $user['last_name']), 'role' => $user['role_name'], 'role_display' => $user['role_display_name'] ?? $user['role_name'], 'login_time' => time(), 'last_activity' => time(), 'ip' => getClientIp(), 'session_token' => bin2hex(random_bytes(16)) ]; self::$user = $user; } /** * Store session in database for tracking * * @param int $userId User ID * @param bool $remember If true, session expires in 30 days instead of 1 hour * @param string|null $expiresAt Optional specific expiry datetime */ private static function storeSessionInDatabase(int $userId, bool $remember = false, ?string $expiresAt = null): void { try { $pdo = db(); $sessionId = session_id(); // Use provided expiry or calculate based on remember flag if ($expiresAt === null) { $expirySeconds = $remember ? (REMEMBER_ME_DAYS * 86400) : (SESSION_LIFETIME_MINUTES * 60); $expiresAt = date('Y-m-d H:i:s', time() + $expirySeconds); } // Delete existing session with same ID $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); $stmt->execute([$sessionId]); // Insert new session $stmt = $pdo->prepare(" INSERT INTO user_sessions (id, user_id, ip_address, user_agent, expires_at, is_active) VALUES (?, ?, ?, ?, ?, 1) "); $stmt->execute([ $sessionId, $userId, getClientIp(), substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 255), $expiresAt ]); } catch (PDOException $e) { error_log("Auth store session error: " . $e->getMessage()); } } /** * Create remember me token */ private static function createRememberMeToken(int $userId): void { try { $pdo = db(); $selector = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); $expiresAt = date('Y-m-d H:i:s', time() + (REMEMBER_ME_DAYS * 86400)); // Each device gets its own token - expired tokens are cleaned up when used or periodically // Insert new token $stmt = $pdo->prepare(" INSERT INTO remember_me_tokens (user_id, selector, token_hash, expires_at) VALUES (?, ?, ?, ?) "); $stmt->execute([$userId, $selector, $tokenHash, $expiresAt]); // Set cookie $cookieValue = $selector . ':' . $token; $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; setcookie('boha_remember', $cookieValue, [ 'expires' => time() + (REMEMBER_ME_DAYS * 86400), 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax' ]); } catch (PDOException $e) { error_log("Auth remember me token error: " . $e->getMessage()); } } /** * Check if session is valid * * @return bool True if authenticated */ public static function check(): bool { $auth = $_SESSION['auth'] ?? null; if ($auth && isset($auth['user_id']) && self::validateDatabaseSession($auth['user_id'])) { self::updateSessionActivity($auth['user_id']); return true; } // Session invalid - try to restore from remember me cookie if (isset($_COOKIE['boha_remember']) && self::restoreFromRememberMe()) { return true; } // Both session and remember me invalid - clear everything self::clearSessionData(); return false; } /** * Validate session exists in database * * Also verifies that the User-Agent matches what was stored when the session * was created. This prevents session hijacking - if an attacker steals the * session cookie but uses a different browser, the session will be invalid. */ private static function validateDatabaseSession(int $userId): bool { try { $pdo = db(); $sessionId = session_id(); $stmt = $pdo->prepare(" SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_agent = ? AND is_active = 1 AND expires_at > NOW() "); $userAgent = substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 255); $stmt->execute([$sessionId, $userId, $userAgent]); return $stmt->fetch() !== false; } catch (PDOException $e) { error_log("Auth validate session error: " . $e->getMessage()); return false; } } /** * Update session activity timestamp */ private static function updateSessionActivity(int $userId): void { $_SESSION['auth']['last_activity'] = time(); // Update expiry (rate limited to once per request) static $updated = false; if ($updated) { return; } $updated = true; try { $pdo = db(); $hasRememberMe = isset($_COOKIE['boha_remember']); if ($hasRememberMe) { // With remember me: only update remember_me_tokens expiry, not user_sessions $parts = explode(':', $_COOKIE['boha_remember']); if (count($parts) === 2) { $selector = $parts[0]; $tokenExpiresAt = date('Y-m-d H:i:s', time() + (REMEMBER_ME_DAYS * 86400)); $stmt = $pdo->prepare("UPDATE remember_me_tokens SET expires_at = ? WHERE selector = ?"); $stmt->execute([$tokenExpiresAt, $selector]); } } else { // Without remember me: update user_sessions expiry (sliding 1-hour window) $expiresAt = date('Y-m-d H:i:s', time() + (SESSION_LIFETIME_MINUTES * 60)); $stmt = $pdo->prepare(" UPDATE user_sessions SET expires_at = ? WHERE id = ? AND user_id = ? "); $stmt->execute([$expiresAt, session_id(), $userId]); } } catch (PDOException $e) { error_log("Auth update activity error: " . $e->getMessage()); } } /** * Restore session from remember me cookie */ private static function restoreFromRememberMe(): bool { $cookie = $_COOKIE['boha_remember'] ?? ''; $parts = explode(':', $cookie); if (count($parts) !== 2) { self::deleteRememberMeCookie(); return false; } [$selector, $token] = $parts; try { $pdo = db(); $stmt = $pdo->prepare(" SELECT rt.*, u.*, r.name as role_name, r.display_name as role_display_name FROM remember_me_tokens rt JOIN users u ON rt.user_id = u.id LEFT JOIN roles r ON u.role_id = r.id WHERE rt.selector = ? AND rt.expires_at > NOW() "); $stmt->execute([$selector]); $data = $stmt->fetch(); if (!$data) { // Delete the expired token from database if it exists $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); $stmt->execute([$selector]); self::deleteRememberMeCookie(); return false; } // Verify token hash $tokenHash = hash('sha256', $token); if (!hash_equals($data['token_hash'], $tokenHash)) { // Token mismatch - possible theft, delete all tokens for user $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE user_id = ?"); $stmt->execute([$data['user_id']]); self::deleteRememberMeCookie(); return false; } // Check if user is still active if (!$data['is_active']) { self::deleteRememberMeCookie(); return false; } // Token rotation: Delete the old token $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); $stmt->execute([$selector]); // Create a new token for security (single-use tokens) self::createRememberMeToken($data['user_id']); // Update the expired session with new expiry from remember me token $sessionId = session_id(); $stmt = $pdo->prepare("UPDATE user_sessions SET expires_at = ? WHERE id = ? AND user_id = ?"); $stmt->execute([$data['expires_at'], $sessionId, $data['user_id']]); // Recreate session data self::createUserSession($data); return true; } catch (PDOException $e) { error_log("Auth restore remember me error: " . $e->getMessage()); return false; } } /** * Logout user */ public static function logout(): void { $auth = $_SESSION['auth'] ?? null; $sessionId = session_id(); if ($auth && isset($auth['user_id'])) { try { $pdo = db(); // Delete session from database $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); $stmt->execute([$sessionId]); // Delete remember me token if exists if (isset($_COOKIE['boha_remember'])) { $parts = explode(':', $_COOKIE['boha_remember']); if (count($parts) === 2) { $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); $stmt->execute([$parts[0]]); } } } catch (PDOException $e) { error_log("Auth logout error: " . $e->getMessage()); } } // Delete remember me cookie self::deleteRememberMeCookie(); // Clear session $_SESSION = []; // Delete session cookie if (isset($_COOKIE[session_name()])) { setcookie(session_name(), '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'httponly' => true, 'samesite' => 'Lax' ]); } session_destroy(); // Cleanup expired sessions self::cleanupExpiredSessions(); } /** * Clear session data and remember me token */ private static function clearSessionData(): void { $sessionId = session_id(); try { $pdo = db(); $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); $stmt->execute([$sessionId]); // Also delete remember me token if exists if (isset($_COOKIE['boha_remember'])) { $parts = explode(':', $_COOKIE['boha_remember']); if (count($parts) === 2) { $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); $stmt->execute([$parts[0]]); } } } catch (PDOException $e) { // Ignore cleanup errors } // Delete remember me cookie self::deleteRememberMeCookie(); unset($_SESSION['auth']); self::$user = null; } /** * Delete remember me cookie */ private static function deleteRememberMeCookie(): void { if (isset($_COOKIE['boha_remember'])) { setcookie('boha_remember', '', [ 'expires' => time() - 3600, 'path' => '/', 'domain' => '', 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'httponly' => true, 'samesite' => 'Lax' ]); unset($_COOKIE['boha_remember']); } } /** * Cleanup expired sessions and tokens */ public static function cleanupExpiredSessions(): void { try { $pdo = db(); $pdo->exec("DELETE FROM user_sessions WHERE expires_at < NOW()"); $pdo->exec("DELETE FROM remember_me_tokens WHERE expires_at < NOW()"); } catch (PDOException $e) { error_log("Auth cleanup error: " . $e->getMessage()); } } /** * Check if user is logged in */ public static function isLoggedIn(): bool { return isset($_SESSION['auth']['user_id']) && $_SESSION['auth']['user_id'] > 0; } /** * Get current user ID */ public static function getUserId(): int { return $_SESSION['auth']['user_id'] ?? 0; } /** * Get current username */ public static function getUsername(): string { return $_SESSION['auth']['username'] ?? ''; } /** * Get current user's full name */ public static function getFullName(): string { return $_SESSION['auth']['full_name'] ?? ''; } /** * Get current user's email */ public static function getEmail(): string { return $_SESSION['auth']['email'] ?? ''; } /** * Get current user's role */ public static function getRole(): string { return $_SESSION['auth']['role'] ?? ''; } /** * Get current user's role display name */ public static function getRoleDisplay(): string { return $_SESSION['auth']['role_display'] ?? ''; } /** * Check if current user is admin */ public static function isAdmin(): bool { return self::getRole() === 'admin'; } /** * Get user data for API response */ public static function getUserData(): ?array { if (!self::isLoggedIn()) { return null; } return [ 'id' => self::getUserId(), 'username' => self::getUsername(), 'email' => self::getEmail(), 'fullName' => self::getFullName(), 'role' => self::getRole(), 'roleDisplay' => self::getRoleDisplay(), 'isAdmin' => self::isAdmin() ]; } /** * Check if user has specific permission * * @param string $permission Permission name (e.g., 'projects.create') * @return bool True if user has permission */ public static function hasPermission(string $permission): bool { if (!self::isLoggedIn()) { return false; } // Admins have all permissions if (self::isAdmin()) { return true; } try { $pdo = db(); $stmt = $pdo->prepare(" SELECT 1 FROM role_permissions rp INNER JOIN permissions p ON rp.permission_id = p.id INNER JOIN roles r ON rp.role_id = r.id INNER JOIN users u ON u.role_id = r.id WHERE u.id = ? AND p.name = ? "); $stmt->execute([self::getUserId(), $permission]); return $stmt->fetch() !== false; } catch (PDOException $e) { error_log("Auth permission check error: " . $e->getMessage()); return false; } } /** * Require user to be logged in */ public static function requireLogin(): void { if (!self::check()) { errorResponse('Vyzadovano prihlaseni', 401); } } /** * Require specific permission * * @param string $permission Permission name */ public static function requirePermission(string $permission): void { self::requireLogin(); if (!self::hasPermission($permission)) { errorResponse('Pristup odepren', 403); } } /** * Get session time remaining in seconds */ public static function getSessionTimeRemaining(): int { if (!self::isLoggedIn()) { return 0; } $lastActivity = $_SESSION['auth']['last_activity'] ?? 0; $elapsed = time() - $lastActivity; $remaining = (SESSION_LIFETIME_MINUTES * 60) - $elapsed; return max(0, $remaining); } } ``` ### 4.3 api/includes/RateLimiter.php ```php storagePath = $storagePath ?? (defined('RATE_LIMIT_STORAGE_PATH') ? RATE_LIMIT_STORAGE_PATH : __DIR__ . '/../rate_limits'); // Ensure storage directory exists if (!is_dir($this->storagePath)) { mkdir($this->storagePath, 0755, true); } // Cleanup old files occasionally (1% of requests) if (rand(1, 100) === 1) { $this->cleanup(); } } /** * Check if the request should be rate limited * * @param string $endpoint Endpoint identifier (e.g., 'login', 'session') * @param int|null $limit Custom limit for this endpoint (requests per minute) * @return bool True if request is allowed, false if rate limited */ public function check(string $endpoint, ?int $limit = null): bool { $limit = $limit ?? $this->defaultLimit; $ip = $this->getClientIp(); $key = $this->getKey($ip, $endpoint); $data = $this->getData($key); $now = time(); // Check if we're still in the same time window if ($data && $data['window_start'] > ($now - $this->windowSeconds)) { // Same window - check count if ($data['count'] >= $limit) { return false; // Rate limited } // Increment counter $data['count']++; $this->saveData($key, $data); return true; } // New window - reset counter $this->saveData($key, [ 'window_start' => $now, 'count' => 1 ]); return true; } /** * Enforce rate limit and return 429 response if exceeded * * @param string $endpoint Endpoint identifier * @param int|null $limit Custom limit for this endpoint */ public function enforce(string $endpoint, ?int $limit = null): void { if (!$this->check($endpoint, $limit)) { $this->sendRateLimitResponse(); } } /** * Get remaining requests for current window * * @param string $endpoint Endpoint identifier * @param int|null $limit Custom limit for this endpoint * @return int Remaining requests */ public function getRemaining(string $endpoint, ?int $limit = null): int { $limit = $limit ?? $this->defaultLimit; $ip = $this->getClientIp(); $key = $this->getKey($ip, $endpoint); $data = $this->getData($key); $now = time(); if (!$data || $data['window_start'] <= ($now - $this->windowSeconds)) { return $limit; } return max(0, $limit - $data['count']); } /** * Get time until rate limit resets * * @param string $endpoint Endpoint identifier * @return int Seconds until reset */ public function getResetTime(string $endpoint): int { $ip = $this->getClientIp(); $key = $this->getKey($ip, $endpoint); $data = $this->getData($key); $now = time(); if (!$data) { return 0; } $windowEnd = $data['window_start'] + $this->windowSeconds; return max(0, $windowEnd - $now); } /** * Send 429 Too Many Requests response and exit */ private function sendRateLimitResponse(): void { http_response_code(429); header('Content-Type: application/json; charset=utf-8'); header('Retry-After: ' . $this->windowSeconds); echo json_encode([ 'success' => false, 'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.', 'retry_after' => $this->windowSeconds ], JSON_UNESCAPED_UNICODE); exit(); } /** * Get client IP address * * @return string IP address */ private function getClientIp(): string { // Use the global helper if available if (function_exists('getClientIp')) { return getClientIp(); } // Fallback: use only REMOTE_ADDR (cannot be spoofed) return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Generate storage key for IP and endpoint * * @param string $ip Client IP * @param string $endpoint Endpoint identifier * @return string Storage key (filename-safe) */ private function getKey(string $ip, string $endpoint): string { // Create a safe filename from IP and endpoint return md5($ip . ':' . $endpoint); } /** * Get rate limit data from storage * * @param string $key Storage key * @return array|null Data array or null if not found */ private function getData(string $key): ?array { $file = $this->storagePath . '/' . $key . '.json'; if (!file_exists($file)) { return null; } $content = @file_get_contents($file); if ($content === false) { return null; } $data = json_decode($content, true); return is_array($data) ? $data : null; } /** * Save rate limit data to storage * * @param string $key Storage key * @param array $data Data to save */ private function saveData(string $key, array $data): void { $file = $this->storagePath . '/' . $key . '.json'; @file_put_contents($file, json_encode($data), LOCK_EX); } /** * Cleanup expired rate limit files * * Removes files older than the time window to prevent disk space issues */ private function cleanup(): void { if (!is_dir($this->storagePath)) { return; } $files = glob($this->storagePath . '/*.json'); $expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe foreach ($files as $file) { if (filemtime($file) < $expireTime) { @unlink($file); } } } /** * Clear all rate limit data (useful for testing) */ public function clearAll(): void { if (!is_dir($this->storagePath)) { return; } $files = glob($this->storagePath . '/*.json'); foreach ($files as $file) { @unlink($file); } } } ``` ### 4.4 api/admin/login.php ```php enforce('login', 10); // Only accept POST if ($_SERVER['REQUEST_METHOD'] !== 'POST') { errorResponse('Metoda neni povolena', 405); } // Initialize auth Auth::initialize(); // Check if already logged in if (Auth::check()) { successResponse([ 'user' => Auth::getUserData(), 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() ], 'Jiz prihlasen'); } // Get input $input = getJsonInput(); // Verify CSRF token $csrfToken = $input['csrf_token'] ?? ''; if (empty($csrfToken) || !verifyCsrfToken($csrfToken)) { errorResponse('Neplatny CSRF token', 403); } $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; $remember = (bool) ($input['remember'] ?? false); // Validate input if (empty($username)) { errorResponse('Uzivatelske jmeno je povinne'); } if (empty($password)) { errorResponse('Heslo je povinne'); } // Attempt login $result = Auth::login($username, $password, $remember); if ($result['success']) { // Log successful login AuditLog::logLogin( Auth::getUserId(), Auth::getUsername() ); successResponse([ 'user' => $result['user'], 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() ], 'Prihlaseni uspesne'); } else { // Log failed login AuditLog::logLoginFailed($username, $result['error']); // Translate error codes to user-friendly messages $errorMessages = [ 'invalid_credentials' => 'Neplatne uzivatelske jmeno nebo heslo', 'account_locked' => 'Ucet je docasne uzamcen. Zkuste to prosim za ' . ($result['minutes'] ?? 15) . ' minut.', 'account_deactivated' => 'Tento ucet byl deaktivovan', 'system_error' => 'Doslo k systemove chybe. Zkuste to prosim pozdeji.' ]; $message = $errorMessages[$result['error']] ?? 'Prihlaseni se nezdarilo'; errorResponse($message, $result['error'] === 'account_locked' ? 429 : 401); } ``` ### 4.5 api/admin/logout.php ```php enforce('logout', 30); // Only accept POST if ($_SERVER['REQUEST_METHOD'] !== 'POST') { errorResponse('Metoda neni povolena', 405); } // Initialize auth Auth::initialize(); // Verify CSRF token (log warning but don't block - logout should always succeed) $input = getJsonInput(); $csrfToken = $input['csrf_token'] ?? ''; if (empty($csrfToken) || !verifyCsrfToken($csrfToken)) { error_log("Logout CSRF warning: Invalid or missing token for session " . session_id()); // Continue with logout anyway - trapping users in zombie sessions is worse } // Log logout before destroying session if (Auth::isLoggedIn()) { AuditLog::logLogout(); } // Perform logout Auth::logout(); successResponse(null, 'Odhlaseni uspesne'); ``` ### 4.6 api/admin/session.php ```php enforce('session', 60); // Accept GET and POST if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) { errorResponse('Metoda neni povolena', 405); } // Initialize auth Auth::initialize(); // Check session $authenticated = Auth::check(); if ($authenticated) { // Always fetch fresh user data from database $pdo = db(); $stmt = $pdo->prepare(" SELECT u.id, u.username, u.email, u.first_name, u.last_name, r.name as role_name, r.display_name as role_display_name FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.id = ? "); $stmt->execute([Auth::getUserId()]); $dbUser = $stmt->fetch(); $userData = [ 'id' => $dbUser['id'], 'username' => $dbUser['username'], 'email' => $dbUser['email'], 'fullName' => trim($dbUser['first_name'] . ' ' . $dbUser['last_name']), 'role' => $dbUser['role_name'], 'roleDisplay' => $dbUser['role_display_name'] ?? $dbUser['role_name'], 'isAdmin' => $dbUser['role_name'] === 'admin' ]; successResponse([ 'authenticated' => true, 'user' => $userData, 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() ]); } else { successResponse([ 'authenticated' => false, 'user' => null, 'sessionTimeRemaining' => 0 ]); } ``` ### 4.7 api/admin/csrf.php ```php enforce('csrf', 30); // Only accept GET if ($_SERVER['REQUEST_METHOD'] !== 'GET') { errorResponse('Metoda neni povolena', 405); } // Initialize auth (starts session) Auth::initialize(); // Generate CSRF token $token = generateCsrfToken(); successResponse([ 'csrf_token' => $token ]); ``` ### 4.8 src/admin/context/AuthContext.jsx ```jsx import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' import { setLogoutCallback } from '../utils/api' const API_BASE = '/api/admin' const AuthContext = createContext(null) let cachedUser = null let sessionFetched = false let lastSessionCheck = 0 let cachedCsrfToken = null // Export for AdminLayout to check timing export const getLastSessionCheck = () => lastSessionCheck export const setLastSessionCheck = (time) => { lastSessionCheck = time } export function AuthProvider({ children }) { const [user, setUser] = useState(cachedUser) const [loading, setLoading] = useState(!sessionFetched) const [error, setError] = useState(null) const [csrfToken, setCsrfToken] = useState(cachedCsrfToken) useEffect(() => { cachedUser = user }, [user]) useEffect(() => { cachedCsrfToken = csrfToken }, [csrfToken]) const fetchCsrfToken = useCallback(async () => { try { const response = await fetch(`${API_BASE}/csrf.php`, { credentials: 'include' }) const data = await response.json() if (data.success && data.data?.csrf_token) { setCsrfToken(data.data.csrf_token) cachedCsrfToken = data.data.csrf_token return data.data.csrf_token } return null } catch (err) { console.error('Failed to fetch CSRF token:', err) return null } }, []) const checkSession = useCallback(async () => { try { lastSessionCheck = Date.now() const response = await fetch(`${API_BASE}/session.php`, { credentials: 'include' }) const data = await response.json() if (data.success && data.data?.authenticated) { setUser(data.data.user) cachedUser = data.data.user return true } else { setUser(null) cachedUser = null return false } } catch (err) { console.error('Session check failed:', err) setUser(null) cachedUser = null return false } finally { setLoading(false) sessionFetched = true } }, []) useEffect(() => { if (!sessionFetched) { checkSession() } }, []) // Set up the logout callback for the global apiFetch useEffect(() => { setLogoutCallback(() => { setUser(null) cachedUser = null sessionFetched = false }) }, []) const login = async (username, password, remember = false) => { setError(null) try { // Always fetch a fresh CSRF token before login to avoid stale token issues const tokenToUse = await fetchCsrfToken() if (!tokenToUse) { const errorMsg = 'Nepodarilo se ziskat bezpecnostni token. Zkuste to prosim znovu.' setError(errorMsg) return { success: false, error: errorMsg } } const response = await fetch(`${API_BASE}/login.php`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password, remember, csrf_token: tokenToUse }) }) const data = await response.json() if (data.success) { setUser(data.data.user) lastSessionCheck = Date.now() // Mark session as just verified // Fetch a new CSRF token after successful login (token rotation) fetchCsrfToken() return { success: true } } else { setError(data.error) // Fetch a new CSRF token on failure (token may have been invalidated) fetchCsrfToken() return { success: false, error: data.error } } } catch (err) { const errorMsg = 'Chyba pripojeni. Zkuste to prosim znovu.' setError(errorMsg) return { success: false, error: errorMsg } } } const logout = async () => { try { // Use current token or fetch a fresh one const tokenToUse = csrfToken || await fetchCsrfToken() await fetch(`${API_BASE}/logout.php`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ csrf_token: tokenToUse }) }) } catch (err) { console.error('Logout error:', err) } finally { setUser(null) cachedUser = null sessionFetched = false setCsrfToken(null) cachedCsrfToken = null } } const updateUser = useCallback((updates) => { setUser(prev => prev ? { ...prev, ...updates } : null) }, []) const value = { user, loading, error, csrfToken, isAuthenticated: !!user, isAdmin: user?.isAdmin || false, login, logout, checkSession, updateUser, fetchCsrfToken } return ( {children} ) } export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth must be used within an AuthProvider') } return context } export default AuthContext ``` ### 4.9 src/admin/pages/Login.jsx ```jsx import { useState, useEffect } from 'react' import { Navigate } from 'react-router-dom' import { motion } from 'framer-motion' import { useAuth } from '../context/AuthContext' import { useAlert } from '../context/AlertContext' import { useTheme } from '../../context/ThemeContext' import { shouldShowSessionExpiredAlert } from '../utils/api' export default function Login() { const { login, isAuthenticated, loading: authLoading } = useAuth() const alert = useAlert() const { theme, toggleTheme } = useTheme() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [remember, setRemember] = useState(false) const [loading, setLoading] = useState(false) // Show session expired alert if redirected due to 401 useEffect(() => { if (shouldShowSessionExpiredAlert()) { alert.warning('Vase relace vyprsela. Prihlas se prosim znovu.') } }, []) if (authLoading) { return (
) } if (isAuthenticated) { return } const handleSubmit = async (e) => { e.preventDefault() setLoading(true) const result = await login(username, password, remember) if (!result.success) { alert.error(result.error) } setLoading(false) } return (
BOHA Automation

Interni system

Prihlas se ke svemu uctu

setUsername(e.target.value)} required autoComplete="username" className="admin-form-input" placeholder="Zadejte uzivatelske jmeno" />
setPassword(e.target.value)} required autoComplete="current-password" className="admin-form-input" placeholder="Zadejte heslo" />
← Zpet na web
) } ``` ### 4.10 src/admin/utils/api.js ```javascript /** * API utility with automatic 401 handling * Triggers logout and shows alert when session expires */ let logoutCallback = null let showSessionExpiredAlert = false // Set the logout callback (called from AuthContext) export const setLogoutCallback = (callback) => { logoutCallback = callback } // Check and clear the session expired flag (called from Login page) export const shouldShowSessionExpiredAlert = () => { if (showSessionExpiredAlert) { showSessionExpiredAlert = false return true } return false } // Wrapper for fetch that handles 401 responses export const apiFetch = async (url, options = {}) => { const response = await fetch(url, { ...options, credentials: 'include' }) // If we get a 401, the session is invalid - set flag and trigger logout if (response.status === 401) { showSessionExpiredAlert = true if (logoutCallback) { logoutCallback() } } return response } export default apiFetch ``` ### 4.11 src/admin/components/AdminLayout.jsx ```jsx import { useState, useEffect } from 'react' import { Outlet, Navigate, useLocation } from 'react-router-dom' import { useAuth, getLastSessionCheck } from '../context/AuthContext' import { useTheme } from '../../context/ThemeContext' import Sidebar from './Sidebar' const SESSION_CHECK_INTERVAL = 30000 // 30 seconds minimum between checks export default function AdminLayout() { const { isAuthenticated, loading, checkSession } = useAuth() const { theme, toggleTheme } = useTheme() const [sidebarOpen, setSidebarOpen] = useState(false) const location = useLocation() // Verify session on route changes, but not too frequently useEffect(() => { const now = Date.now() const lastCheck = getLastSessionCheck() // Only check if enough time has passed since last check if (now - lastCheck > SESSION_CHECK_INTERVAL) { checkSession() } }, [location.pathname]) useEffect(() => { if (sidebarOpen) { document.documentElement.style.overflow = 'hidden' document.body.style.overflow = 'hidden' } else { document.documentElement.style.overflow = '' document.body.style.overflow = '' } return () => { document.documentElement.style.overflow = '' document.body.style.overflow = '' } }, [sidebarOpen]) if (loading) { return (
) } if (!isAuthenticated) { return } return (
setSidebarOpen(false)} />
) } ``` --- ## 5. Security Features ### 5.1 CSRF Protection CSRF tokens protect against Cross-Site Request Forgery attacks. **Implementation:** - Tokens generated using `bin2hex(random_bytes(32))` (64 hex characters) - Stored in PHP session (`$_SESSION['csrf_token']`) - Required for state-changing requests (login, logout) - Verified using timing-safe `hash_equals()` comparison - Token rotated on successful login (prevents Login CSRF attacks) **Token Rotation on Login:** ```php // In Auth::login() after session_regenerate_id(true) unset($_SESSION['csrf_token']); // Force new CSRF token generation post-login ``` This prevents attackers from preparing CSRF tokens before the victim logs in. ### 5.2 Rate Limiting IP-based rate limiting protects against brute force and abuse. | Endpoint | Limit | Purpose | |----------|-------|---------| | `/api/admin/login.php` | 10/min | Strict - prevents brute force | | `/api/admin/logout.php` | 30/min | Moderate - logout operations | | `/api/admin/session.php` | 60/min | Higher - frequent session checks | | `/api/admin/csrf.php` | 30/min | Moderate - token generation | **Rate Limit Response (429):** ```json { "success": false, "error": "Prilis mnoho pozadavku. Zkuste to prosim pozdeji.", "retry_after": 60 } ``` The `Retry-After` header is also set. ### 5.3 Security Headers All API endpoints set these headers: | Header | Value | Purpose | |--------|-------|---------| | `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing | | `X-Frame-Options` | `DENY` | Prevents clickjacking | | `X-XSS-Protection` | `1; mode=block` | Enables browser XSS filter | | `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer info | | `Cache-Control` | `no-store, no-cache...` | Prevents caching of sensitive data | ### 5.4 Brute Force Protection Failed login attempts tracked per user with automatic lockout. **Configuration:** - `MAX_LOGIN_ATTEMPTS = 5` - Account locks after 5 failures - `LOCKOUT_MINUTES = 15` - Lock duration **Behavior:** 1. Each failed login increments `failed_login_attempts` in users table 2. After 5 failures, `locked_until` is set to current time + 15 minutes 3. Login attempts during lockout return `account_locked` error with remaining time 4. Successful login resets counter and clears lock ### 5.5 Session Security **Cookie Configuration:** | Attribute | Value | Purpose | |-----------|-------|---------| | `lifetime` | 0 | Session cookie - expires when browser closes | | `path` | `/` | Available across entire domain | | `secure` | `true` (HTTPS) | Only transmitted over HTTPS | | `httponly` | `true` | Not accessible via JavaScript | | `samesite` | `Lax` | Prevents CSRF in cross-site requests | **Additional Security:** - Session ID regenerated on login (`session_regenerate_id(true)`) - Sessions validated against database on each request - **User-Agent binding** - Each session is bound to the browser's User-Agent string. If an attacker steals a session cookie but uses a different browser, the session will be rejected - Client IP tracked for audit purposes - Sliding expiration extends session on activity - Periodic cleanup of expired sessions (1% of requests) **IP Address Handling:** - Uses only `REMOTE_ADDR` which represents the actual TCP connection IP - Does not trust proxy headers (`X-Forwarded-For`, `X-Real-IP`, etc.) which can be spoofed - If a reverse proxy is added in the future (Cloudflare, Nginx, etc.), the `getClientIp()` function should be updated to trust specific headers only from known proxy IP ranges ### 5.6 Remember Me Token Security Implements the selector:token pattern with SHA-256 hashing. **Token Structure:** - `selector`: 32 hex characters (public identifier for database lookup) - `token`: 64 hex characters (secret validator, only hash stored) - Cookie value: `selector:token` **Security Features:** 1. **Split Token Pattern**: Selector used for lookup, only token hash stored 2. **SHA-256 Hashing**: Token hashed before storage, prevents database leak exposure 3. **Single-Use Rotation**: Token deleted and new one issued after each use 4. **Theft Detection**: If hash doesn't match, all user tokens deleted (possible theft) 5. **Secure Cookie**: Same security attributes as session cookie **Token Rotation Flow:** ``` 1. User visits with remember_me cookie 2. Extract selector:token from cookie 3. Lookup token by selector in database 4. Verify SHA-256(token) matches stored hash 5. If match: delete old token, create new one, restore session 6. If mismatch: delete ALL user tokens (possible theft) ``` --- ## 6. Authentication Flows ### 6.1 Login Flow ``` Frontend Backend -------- ------- 1. User enters credentials 2. fetchCsrfToken() -----------------> GET /csrf.php - Start session - Generate token 3. <---------------------------------- Return csrf_token 4. login() --------------------------> POST /login.php {username, password, - Verify CSRF token remember, csrf_token} - Check rate limit - Find user by username/email - Check if locked - Verify password - Check if active - Regenerate session ID - Clear old CSRF token - Create session data - Store in user_sessions - Create remember_me_token (if remember) - Reset failed attempts 5. <---------------------------------- Return user data 6. Store user in state 7. fetchCsrfToken() -----------------> GET /csrf.php (token rotation) 8. <---------------------------------- Return new csrf_token ``` ### 6.2 Logout Flow ``` Frontend Backend -------- ------- 1. logout() -------------------------> POST /logout.php {csrf_token} - Verify CSRF (warn only) - Log logout event - Delete from user_sessions - Delete remember_me_token - Delete cookies - Destroy PHP session 2. <---------------------------------- Return success 3. Clear user state 4. Clear CSRF token 5. Redirect to login ``` ### 6.3 Session Validation Flow ``` Frontend Backend -------- ------- 1. checkSession() ------------------> GET /session.php - Initialize session - Check $_SESSION['auth'] - Validate against user_sessions DB - If valid: update activity, return user - If invalid: try remember me restore - If remember me works: return user - If both fail: clear all, return false 2. <--------------------------------- Return {authenticated, user} 3. Update user state ``` ### 6.4 Remember Me Restoration Flow ``` 1. Auth::check() called 2. Session data exists but DB validation fails 3. Check for boha_remember cookie 4. Parse selector:token from cookie 5. Lookup in remember_me_tokens by selector 6. Verify SHA-256(token) matches token_hash 7. If mismatch: DELETE ALL tokens for user (theft detection) 8. If match and user active: a. Delete old token from DB b. Create new token (rotation) c. Update session expiry d. Create session data e. Return true (authenticated) ``` --- ## 7. API Endpoints ### GET /api/admin/csrf.php Generates and returns a CSRF token. **Rate Limit:** 30 requests/minute **Request:** ```http GET /api/admin/csrf.php Cookie: BOHA_ADMIN_SESSION=xxx ``` **Response:** ```json { "success": true, "data": { "csrf_token": "a1b2c3d4e5f6g7h8i9j0..." } } ``` --- ### POST /api/admin/login.php Authenticates a user and creates a session. **Rate Limit:** 10 requests/minute **Request:** ```http POST /api/admin/login.php Content-Type: application/json Cookie: BOHA_ADMIN_SESSION=xxx { "username": "john.doe", "password": "secret123", "remember": true, "csrf_token": "a1b2c3d4e5f6..." } ``` **Success Response (200):** ```json { "success": true, "message": "Prihlaseni uspesne", "data": { "user": { "id": 1, "username": "john.doe", "email": "john@example.com", "fullName": "John Doe", "role": "admin", "roleDisplay": "Administrator", "isAdmin": true }, "sessionTimeRemaining": 3600 } } ``` **Error Responses:** Invalid credentials (401): ```json { "success": false, "error": "Neplatne uzivatelske jmeno nebo heslo" } ``` Account locked (429): ```json { "success": false, "error": "Ucet je docasne uzamcen. Zkuste to prosim za 15 minut." } ``` Invalid CSRF token (403): ```json { "success": false, "error": "Neplatny CSRF token" } ``` Rate limited (429): ```json { "success": false, "error": "Prilis mnoho pozadavku. Zkuste to prosim pozdeji.", "retry_after": 60 } ``` --- ### POST /api/admin/logout.php Logs out the current user. **Rate Limit:** 30 requests/minute **Request:** ```http POST /api/admin/logout.php Content-Type: application/json Cookie: BOHA_ADMIN_SESSION=xxx { "csrf_token": "a1b2c3d4e5f6..." } ``` **Response (200):** ```json { "success": true, "message": "Odhlaseni uspesne" } ``` Note: Logout proceeds even with invalid CSRF token (warning logged). This prevents users from being trapped in zombie sessions. --- ### GET /api/admin/session.php Validates the current session and returns user data. **Rate Limit:** 60 requests/minute **Request:** ```http GET /api/admin/session.php Cookie: BOHA_ADMIN_SESSION=xxx ``` **Authenticated Response (200):** ```json { "success": true, "data": { "authenticated": true, "user": { "id": 1, "username": "john.doe", "email": "john@example.com", "fullName": "John Doe", "role": "admin", "roleDisplay": "Administrator", "isAdmin": true }, "sessionTimeRemaining": 3540 } } ``` **Unauthenticated Response (200):** ```json { "success": true, "data": { "authenticated": false, "user": null, "sessionTimeRemaining": 0 } } ``` --- ## Summary The BOHA authentication system implements comprehensive security measures: 1. **Defense in Depth** - Multiple layers (CSRF, session validation, brute force protection, rate limiting) 2. **Secure Defaults** - HttpOnly, Secure, SameSite cookies; bcrypt hashing; security headers 3. **Database-Backed Sessions** - Server-side validation prevents session manipulation 4. **Remember Me Security** - Selector/validator pattern with single-use token rotation 5. **CSRF Token Rotation** - Tokens invalidated on login to prevent Login CSRF attacks 6. **Rate Limiting** - IP-based limits per endpoint protect against abuse 7. **Audit Logging** - All authentication events logged for security monitoring 8. **Clean Architecture** - Clear separation between React frontend and PHP backend