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

2622 lines
74 KiB
Markdown

# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
```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
```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
```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 (
<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:**
```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