2622 lines
74 KiB
Markdown
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">
|
|
← 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
|