Files
app/docs/AUTH_SYSTEM.md
2026-03-12 12:43:56 +01:00

74 KiB

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
  2. Configuration
  3. Database Tables
  4. Complete Code Listings
  5. Security Features
  6. Authentication Flows
  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

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

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

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

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

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
/**
 * BOHA Automation - API Configuration
 *
 * Database and application configuration
 */

// Load .env file
function loadEnv($path) {
    if (!file_exists($path)) {
        return false;
    }

    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        if (strpos(trim($line), '#') === 0) {
            continue;
        }

        $parts = explode('=', $line, 2);
        if (count($parts) !== 2) {
            continue;
        }

        $name = trim($parts[0]);
        $value = trim($parts[1]);

        // Remove quotes if present
        if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
            (substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
            $value = substr($value, 1, -1);
        }

        $_ENV[$name] = $value;
    }

    return true;
}

// Load .env from api directory
loadEnv(__DIR__ . '/.env');

// Helper function to get env value with default
function env($key, $default = null) {
    return $_ENV[$key] ?? $default;
}

// Environment
define('APP_ENV', env('APP_ENV', 'production'));
define('DEBUG_MODE', APP_ENV === 'local');

if (DEBUG_MODE) {
    error_reporting(E_ALL);
    ini_set('display_errors', 1);
} else {
    error_reporting(0);
    ini_set('display_errors', 0);
}

// Database configuration
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_NAME', env('DB_NAME', ''));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4');

// Security configuration
define('SESSION_NAME', 'BOHA_ADMIN_SESSION');
define('SESSION_LIFETIME_MINUTES', 60);
define('REMEMBER_ME_DAYS', 30);
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOCKOUT_MINUTES', 15);
define('BCRYPT_COST', 12);

// CORS configuration for API
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'
]);

// Paths
define('API_ROOT', __DIR__);
define('INCLUDES_PATH', API_ROOT . '/includes');

// Rate limiting
define('RATE_LIMIT_STORAGE_PATH', __DIR__ . '/rate_limits');

// reCAPTCHA configuration
define('RECAPTCHA_SITE_KEY', env('RECAPTCHA_SITE_KEY', ''));
define('RECAPTCHA_SECRET_KEY', env('RECAPTCHA_SECRET_KEY', ''));

// Email configuration
define('CONTACT_EMAIL_TO', env('CONTACT_EMAIL_TO', 'info@boha-automation.cz'));
define('CONTACT_EMAIL_FROM', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));

/**
 * Get PDO database connection (singleton pattern)
 *
 * @return PDO Database connection instance
 * @throws PDOException If connection fails
 */
function db(): PDO {
    static $pdo = null;

    if ($pdo === null) {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=%s',
            DB_HOST,
            DB_NAME,
            DB_CHARSET
        );

        $options = [
            PDO::ATTR_ERRMODE => 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
/**
 * BOHA Automation - Authentication System
 *
 * Best practices implemented:
 * - Database-backed sessions (user_sessions table)
 * - Secure session configuration (httponly, secure, samesite)
 * - Session regeneration on login
 * - Max 3 concurrent sessions per user
 * - Remember me with secure token rotation
 * - Proper cleanup of expired sessions
 * - Failed login attempt tracking with lockout
 */

require_once dirname(__DIR__) . '/config.php';

class Auth {
    private static bool $initialized = false;
    private static ?array $user = null;

    /**
     * Initialize session with secure settings
     */
    public static function initialize(): void {
        if (self::$initialized) {
            return;
        }

        if (session_status() === PHP_SESSION_NONE) {
            $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';

            session_name(SESSION_NAME);
            session_set_cookie_params([
                'lifetime' => 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
/**
 * BOHA Automation - IP-based Rate Limiter
 *
 * Implements rate limiting using file-based storage to prevent abuse
 * and protect API endpoints from excessive requests.
 *
 * Features:
 * - IP-based rate limiting
 * - Configurable limits per endpoint
 * - File-based storage (no database dependency)
 * - Automatic cleanup of expired entries
 */

class RateLimiter {
    /** @var string Directory for storing rate limit data */
    private string $storagePath;

    /** @var int Default requests per minute */
    private int $defaultLimit = 60;

    /** @var int Time window in seconds (1 minute) */
    private int $windowSeconds = 60;

    /**
     * Initialize the rate limiter
     *
     * @param string|null $storagePath Path to store rate limit files
     */
    public function __construct(?string $storagePath = null) {
        $this->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
/**
 * BOHA Automation - Admin Login API
 *
 * POST /api/admin/login.php
 *
 * Request body:
 * {
 *   "username": "string",
 *   "password": "string",
 *   "remember": boolean (optional)
 * }
 *
 * Response:
 * {
 *   "success": boolean,
 *   "user": { ... } | null,
 *   "error": "string" | null
 * }
 */

require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/Auth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/RateLimiter.php';

// Set headers
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');

// Rate limiting - stricter for login endpoint (10 requests/minute)
$rateLimiter = new RateLimiter();
$rateLimiter->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
/**
 * BOHA Automation - Admin Logout API
 *
 * POST /api/admin/logout.php
 *
 * Response:
 * {
 *   "success": true,
 *   "message": "Logged out successfully"
 * }
 */

require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/Auth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/RateLimiter.php';

// Set headers
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');

// Rate limiting (30 requests/minute)
$rateLimiter = new RateLimiter();
$rateLimiter->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
/**
 * BOHA Automation - Session Check API
 *
 * GET /api/admin/session.php
 *
 * Response:
 * {
 *   "success": true,
 *   "authenticated": boolean,
 *   "user": { ... } | null,
 *   "sessionTimeRemaining": int (seconds)
 * }
 */

require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/Auth.php';
require_once dirname(__DIR__) . '/includes/RateLimiter.php';

// Set headers
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');

// Rate limiting (60 requests/minute)
$rateLimiter = new RateLimiter();
$rateLimiter->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
/**
 * BOHA Automation - CSRF Token API
 *
 * GET /api/admin/csrf.php
 *
 * Generates and returns a CSRF token for use in subsequent requests.
 * The token is stored in the session and must be included in
 * state-changing requests (login, logout, etc.)
 *
 * Response:
 * {
 *   "success": true,
 *   "data": {
 *     "csrf_token": "string"
 *   }
 * }
 */

require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/Auth.php';
require_once dirname(__DIR__) . '/includes/RateLimiter.php';

// Set headers
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');

// Rate limiting (30 requests/minute)
$rateLimiter = new RateLimiter();
$rateLimiter->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

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 (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

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

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 (
      <div className="admin-login">
        <div className="admin-loading">
          <div className="admin-spinner" />
        </div>
      </div>
    )
  }

  if (isAuthenticated) {
    return <Navigate to="/boha" replace />
  }

  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 (
    <div className="admin-login">
      <div className="bg-orb bg-orb-1" />
      <div className="bg-orb bg-orb-2" />

      <button
        onClick={toggleTheme}
        className="admin-login-theme-btn"
        title={theme === 'dark' ? 'Svetly rezim' : 'Tmavy rezim'}
      >
        <span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <circle cx="12" cy="12" r="5" />
            <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
          </svg>
        </span>
        <span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
          </svg>
        </span>
      </button>

      <motion.div
        className="admin-login-card"
        initial={{ opacity: 0, y: 30 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5 }}
      >
        <div className="admin-login-header">
          <img
            src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
            alt="BOHA Automation"
            className="admin-login-logo"
          />
          <h1 className="admin-login-title">Interni system</h1>
          <p className="admin-login-subtitle">Prihlas se ke svemu uctu</p>
        </div>

        <form onSubmit={handleSubmit} className="admin-form">
          <div className="admin-form-group">
            <label htmlFor="username" className="admin-form-label">
              Uzivatelske jmeno nebo e-mail
            </label>
            <input
              id="username"
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              required
              autoComplete="username"
              className="admin-form-input"
              placeholder="Zadejte uzivatelske jmeno"
            />
          </div>

          <div className="admin-form-group">
            <label htmlFor="password" className="admin-form-label">
              Heslo
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              autoComplete="current-password"
              className="admin-form-input"
              placeholder="Zadejte heslo"
            />
          </div>

          <label className="admin-form-checkbox">
            <input
              type="checkbox"
              checked={remember}
              onChange={(e) => setRemember(e.target.checked)}
            />
            <span>Zapamatovat si me na 30 dni</span>
          </label>

          <button
            type="submit"
            disabled={loading}
            className="admin-btn admin-btn-primary"
            style={{ width: '100%' }}
          >
            {loading ? (
              <>
                <div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
                Prihlasovani...
              </>
            ) : (
              'Prihlasit se'
            )}
          </button>
        </form>

        <a href="/" className="admin-back-link">
          &larr; Zpet na web
        </a>
      </motion.div>
    </div>
  )
}

4.10 src/admin/utils/api.js

/**
 * 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

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 (
      <div className="admin-layout">
        <div className="admin-loading" style={{ width: '100%' }}>
          <div className="admin-spinner" />
        </div>
      </div>
    )
  }

  if (!isAuthenticated) {
    return <Navigate to="/boha/login" replace />
  }

  return (
    <div className="admin-layout">
      <Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />

      <div className="admin-main">
        <header className="admin-header">
          <button
            onClick={() => setSidebarOpen(true)}
            className="admin-menu-btn"
          >
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <line x1="3" y1="12" x2="21" y2="12" />
              <line x1="3" y1="6" x2="21" y2="6" />
              <line x1="3" y1="18" x2="21" y2="18" />
            </svg>
          </button>

          <div style={{ flex: 1 }} />

          <button
            onClick={toggleTheme}
            className="admin-header-theme-btn"
            title={theme === 'dark' ? 'Svetly rezim' : 'Tmavy rezim'}
          >
            <span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                <circle cx="12" cy="12" r="5" />
                <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
              </svg>
            </span>
            <span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
              </svg>
            </span>
          </button>

          <a
            href="/"
            target="_blank"
            rel="noopener noreferrer"
            className="admin-header-link"
          >
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
              <polyline points="15 3 21 3 21 9" />
              <line x1="10" y1="14" x2="21" y2="3" />
            </svg>
            Zobrazit web
          </a>
        </header>

        <main className="admin-content">
          <Outlet />
        </main>
      </div>
    </div>
  )
}

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:

// 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):

{
  "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:

GET /api/admin/csrf.php
Cookie: BOHA_ADMIN_SESSION=xxx

Response:

{
  "success": true,
  "data": {
    "csrf_token": "a1b2c3d4e5f6g7h8i9j0..."
  }
}

POST /api/admin/login.php

Authenticates a user and creates a session.

Rate Limit: 10 requests/minute

Request:

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):

{
  "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):

{
  "success": false,
  "error": "Neplatne uzivatelske jmeno nebo heslo"
}

Account locked (429):

{
  "success": false,
  "error": "Ucet je docasne uzamcen. Zkuste to prosim za 15 minut."
}

Invalid CSRF token (403):

{
  "success": false,
  "error": "Neplatny CSRF token"
}

Rate limited (429):

{
  "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:

POST /api/admin/logout.php
Content-Type: application/json
Cookie: BOHA_ADMIN_SESSION=xxx

{
  "csrf_token": "a1b2c3d4e5f6..."
}

Response (200):

{
  "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:

GET /api/admin/session.php
Cookie: BOHA_ADMIN_SESSION=xxx

Authenticated Response (200):

{
  "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):

{
  "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