# 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 (
Prihlas se ke svemu uctu