From bb2bbb8ff6e91df85a403b3b8f36d936486824de Mon Sep 17 00:00:00 2001
From: Simon
- Tato zpráva byla automaticky vygenerována systémem BOHA Automation.
+ Tato zpráva byla automaticky vygenerována systémem.
Datum: " . date('d.m.Y H:i:s') . '
diff --git a/api/includes/Mailer.php b/api/includes/Mailer.php
index ca53055..2584d47 100644
--- a/api/includes/Mailer.php
+++ b/api/includes/Mailer.php
@@ -1,7 +1,7 @@
/dev/null || echo "000")
-if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
- echo "=== Deploy OK (HTTP $HTTP_CODE) ==="
-else
- echo "WARNING: Health check returned HTTP $HTTP_CODE (Apache may not be running)"
-fi
diff --git a/docs/AUTH_SYSTEM.md b/docs/AUTH_SYSTEM.md
deleted file mode 100644
index d6f1d81..0000000
--- a/docs/AUTH_SYSTEM.md
+++ /dev/null
@@ -1,2621 +0,0 @@
-# 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 (
-
- )
- }
-
- if (isAuthenticated) {
- return
Prihlas se ke svemu uctu
-- ) -} -``` - -### 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
- ) -} -``` - ---- - -## 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 diff --git a/example_design/boha-spa.html b/example_design/boha-spa.html deleted file mode 100644 index 43c4737..0000000 --- a/example_design/boha-spa.html +++ /dev/null @@ -1,1519 +0,0 @@ - - -
- - -
- - - -
-