iptv-stream-web/utils/Security.php
Vincent 41cd7a4fd8 Add comprehensive unit tests for Security, UserModel, and Validation utilities
- Implemented SecurityTest to validate token generation, CSRF protection, input sanitization, and rate limiting.
- Created UserModelTest to ensure correct database operations for user management, including creation, updating, banning, and fetching active users.
- Developed ValidationTest to verify input validation and sanitization for user IDs, nicknames, messages, and API requests.
- Introduced Security and Validation utility classes with methods for secure token generation, input sanitization, and comprehensive validation rules.
2025-09-30 21:22:28 -04:00

337 lines
9.5 KiB
PHP

<?php
/**
* Security Utilities Class
* Handles authentication, CSRF protection, and security-related functions
*/
class Security
{
/**
* Generate a cryptographically secure user ID
*/
public static function generateSecureUserId($length = 8)
{
return bin2hex(random_bytes($length));
}
/**
* Generate a CSRF token
*/
public static function generateCSRFToken()
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Validate CSRF token
*/
public static function validateCSRFToken($token = null)
{
$sessionToken = $_SESSION['csrf_token'] ?? '';
$providedToken = $token ?? ($_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '');
if (empty($sessionToken) || empty($providedToken)) {
return false;
}
return hash_equals($sessionToken, $providedToken);
}
/**
* Authenticate admin user
*/
public static function authenticateAdmin($username, $password)
{
$storedUsername = Config::get('admin.username', 'admin');
$storedHash = Config::get('admin.password_hash');
if (empty($storedHash)) {
error_log("Warning: Admin password hash not configured in .env file");
return false;
}
if ($username !== $storedUsername) {
return false;
}
if (!password_verify($password, $storedHash)) {
return false;
}
// Set admin session
$_SESSION['admin_authenticated'] = true;
$_SESSION['admin_username'] = $username;
$_SESSION['admin_login_time'] = time();
session_regenerate_id(true); // Prevent session fixation
return true;
}
/**
* Check if user is authenticated as admin
*/
public static function isAdminAuthenticated()
{
if (!isset($_SESSION['admin_authenticated']) || !$_SESSION['admin_authenticated']) {
return false;
}
// Check session timeout
$timeout = Config::get('admin.session_timeout', 3600);
$loginTime = $_SESSION['admin_login_time'] ?? 0;
if (time() - $loginTime > $timeout) {
self::logoutAdmin();
return false;
}
return true;
}
/**
* Logout admin user
*/
public static function logoutAdmin()
{
unset($_SESSION['admin_authenticated']);
unset($_SESSION['admin_username']);
unset($_SESSION['admin_login_time']);
session_regenerate_id(true);
}
/**
* Hash password using bcrypt
*/
public static function hashPassword($password)
{
$options = [
'cost' => 12, // Increase cost for production
];
return password_hash($password, PASSWORD_ARGON2I, $options);
}
/**
* Verify password hash needs rehash
*/
public static function passwordNeedsRehash($hash)
{
$options = [
'cost' => 12,
];
return password_needs_rehash($hash, PASSWORD_ARGON2I, $options);
}
/**
* Generate secure random string
*/
public static function generateSecureToken($length = 32)
{
return bin2hex(random_bytes($length));
}
/**
* Sanitize input data
*/
public static function sanitizeInput($input, $type = 'string')
{
switch ($type) {
case 'string':
$sanitized = trim($input);
$sanitized = filter_var($sanitized, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
return htmlspecialchars($sanitized, ENT_QUOTES, 'UTF-8');
case 'email':
$sanitized = filter_var(trim($input), FILTER_SANITIZE_EMAIL);
return filter_var($sanitized, FILTER_VALIDATE_EMAIL) ? $sanitized : '';
case 'url':
$sanitized = filter_var(trim($input), FILTER_SANITIZE_URL);
return filter_var($sanitized, FILTER_VALIDATE_URL, FILTER_FLAG_QUERY_REQUIRED) ? $sanitized : '';
case 'int':
return filter_var($input, FILTER_VALIDATE_INT);
case 'float':
return filter_var($input, FILTER_VALIDATE_FLOAT);
default:
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
}
/**
* Validate URL against whitelist
*/
public static function isValidStreamUrl($url)
{
if (empty($url)) {
return false;
}
$allowedDomains = Config::get('stream.allowed_domains', []);
if (empty($allowedDomains)) {
$allowedDomains = ['38.64.28.91:23456']; // Default fallback
}
if (!is_array($allowedDomains)) {
$allowedDomains = explode(',', $allowedDomains);
}
// Normalize domains
$allowedDomains = array_map(function($domain) {
return trim(strtolower($domain));
}, $allowedDomains);
// Parse URL
$parsedUrl = parse_url($url);
if (!$parsedUrl || !isset($parsedUrl['host']) || !isset($parsedUrl['scheme'])) {
return false;
}
$urlDomain = strtolower($parsedUrl['host']);
$urlPort = $parsedUrl['port'] ?? ($parsedUrl['scheme'] === 'https' ? 443 : 80);
$fullDomain = $urlDomain . ':' . $urlPort;
// Check if domain is allowed
foreach ($allowedDomains as $allowed) {
if ($fullDomain === $allowed || $urlDomain === $allowed) {
// Additional validation: only allow specific paths
$path = $parsedUrl['path'] ?? '';
if (preg_match('#^/.*\.(m3u8|ts)$#', $path) || $path === '/stream.m3u8') {
return true;
}
}
}
return false;
}
/**
* Generate rate limiting key
*/
public static function getRateLimitKey($identifier, $action = 'general')
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return "rate_limit:{$action}:{$ip}:{$identifier}";
}
/**
* Check if request is within rate limits
*/
public static function checkRateLimit($identifier, $action = 'general', $maxRequests = null, $timeWindow = 60)
{
if (!$maxRequests) {
$maxRequests = Config::get('rate_limit.requests_per_minute', 60);
}
$key = self::getRateLimitKey($identifier, $action);
// Simple file-based rate limiting (use Redis/APCu in production)
$cacheFile = sys_get_temp_dir() . '/rate_limit_' . md5($key);
$data = [];
if (file_exists($cacheFile)) {
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
}
$now = time();
$windowStart = $now - $timeWindow;
// Filter old requests
$data = array_filter($data, function($timestamp) use ($windowStart) {
return $timestamp > $windowStart;
});
if (count($data) >= $maxRequests) {
return false; // Rate limit exceeded
}
// Add current request
$data[] = $now;
// Keep only recent requests
$data = array_slice($data, -$maxRequests);
file_put_contents($cacheFile, json_encode($data));
return true;
}
/**
* Get client IP address
*/
public static function getClientIP()
{
$headers = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
'REMOTE_ADDR'
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// Handle comma-separated IPs (X-Forwarded-For)
$ip = trim(explode(',', $_SERVER[$header])[0]);
// Basic IP validation
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* Log security event
*/
public static function logSecurityEvent($event, $details = [])
{
$logData = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => $event,
'ip' => self::getClientIP(),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'details' => $details
];
error_log("SECURITY: " . json_encode($logData));
}
/**
* Check for suspicious request patterns
*/
public static function detectSuspiciousActivity()
{
$warnings = [];
// Check for multiple failed authentication attempts
if (isset($_SESSION['auth_attempts']) && $_SESSION['auth_attempts'] > 3) {
$warnings[] = 'Multiple authentication failures';
}
// Check for rapid requests (potential DDoS)
$requestCount = $_SESSION['recent_requests'] ?? 0;
if ($requestCount > 100) {
$warnings[] = 'High request frequency detected';
}
// Log warnings
foreach ($warnings as $warning) {
self::logSecurityEvent('suspicious_activity', ['warning' => $warning]);
}
return $warnings;
}
}