iptv-stream-web/controllers/AuthController.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

378 lines
12 KiB
PHP

<?php
/**
* Authentication Controller
* Handles user authentication, login/logout, and admin access
*/
class AuthController
{
private $userModel;
public function __construct()
{
$this->userModel = new UserModel();
}
/**
* Handle login request
*/
public function login()
{
// Check if already authenticated
if (Security::isAdminAuthenticated()) {
$this->redirectWithMessage('/', 'Already logged in as admin');
return;
}
// Handle POST login request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
return $this->processLogin();
}
// Show login form
$this->showLoginForm();
}
/**
* Process login form submission
*/
private function processLogin()
{
// Validate required fields
if (!isset($_POST['username']) || !isset($_POST['password'])) {
$this->showLoginForm('Please provide username and password');
return;
}
// Validate input format
$validation = Validation::validateAdminLogin($_POST['username'], $_POST['password']);
if (!$validation['valid']) {
Security::logSecurityEvent('login_validation_failed', [
'username' => substr($_POST['username'], 0, 50),
'error' => $validation['message']
]);
$this->showLoginForm($validation['message']);
return;
}
$username = $validation['data']['username'];
$password = $_POST['password'];
// Track login attempts for rate limiting
$failedAttempts = $_SESSION['login_attempts'] ?? 0;
if ($failedAttempts >= 5) {
Security::logSecurityEvent('login_brute_force_attempt', [
'username' => $username,
'ip' => Security::getClientIP()
]);
$this->showLoginForm('Too many failed attempts. Please try again later.', true);
return;
}
// Attempt authentication
if (Security::authenticateAdmin($username, $password)) {
// Success - clear failed attempts and log
unset($_SESSION['login_attempts']);
Security::logSecurityEvent('admin_login_success', ['username' => $username]);
// Update user record if using database tracking
$userId = $_SESSION['user_id'] ?? Security::generateSecureUserId();
$_SESSION['user_id'] = $userId;
$this->userModel->createOrUpdate($userId, [
'nickname' => 'Admin',
'is_admin' => true
]);
// Redirect to dashboard or referer
$redirectUrl = $_GET['redirect'] ?? '/';
header("Location: {$redirectUrl}");
exit;
} else {
// Failed login
$_SESSION['login_attempts'] = $failedAttempts + 1;
Security::logSecurityEvent('admin_login_failed', [
'username' => $username,
'attempts' => $_SESSION['login_attempts']
]);
$remaining = 5 - $_SESSION['login_attempts'];
$message = "Invalid credentials. {$remaining} attempts remaining.";
$this->showLoginForm($message, true);
}
}
/**
* Handle logout
*/
public function logout()
{
// Get username before logout for logging
$username = $_SESSION['admin_username'] ?? 'unknown';
Security::logoutAdmin();
Security::logSecurityEvent('admin_logout_success', ['username' => $username]);
// Destroy session completely
session_destroy();
$this->redirectWithMessage('/login', 'Successfully logged out');
}
/**
* Check current admin authentication status (API endpoint)
*/
public function status()
{
header('Content-Type: application/json');
$isAuthenticated = Security::isAdminAuthenticated();
$response = [
'authenticated' => $isAuthenticated,
'user_id' => $_SESSION['user_id'] ?? null,
'username' => $isAuthenticated ? ($_SESSION['admin_username'] ?? 'admin') : null,
'login_time' => $isAuthenticated ? ($_SESSION['admin_login_time'] ?? null) : null,
'session_expires' => $isAuthenticated ? $this->calculateSessionExpiry() : null
];
echo json_encode($response);
}
/**
* Calculate when current session expires
*/
private function calculateSessionExpiry()
{
$timeout = Config::get('admin.session_timeout', 3600);
$loginTime = $_SESSION['admin_login_time'] ?? 0;
return $loginTime + $timeout;
}
/**
* Show login form
*/
private function showLoginForm($error = null, $isWarning = false)
{
// Generate new CSRF token
$csrfToken = Security::generateCSRFToken();
// Get branding/styling from config
$appName = Config::get('app.name', 'Application');
$loginTitle = Config::get('admin.login_title', 'Admin Login');
$primaryColor = Config::get('ui.primary_color', '#2d572c');
// Determine redirect URL
$redirect = isset($_GET['redirect']) ? htmlspecialchars($_GET['redirect'], ENT_QUOTES) : '/';
$errorHtml = '';
if ($error) {
$alertClass = $isWarning ? 'alert-warning' : 'alert-danger';
$errorHtml = "
<div class='alert {$alertClass}' role='alert'>
<strong>" . ($isWarning ? 'Warning' : 'Error') . ":</strong> {$error}
</div>";
}
echo "<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>{$loginTitle} - {$appName}</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, {$primaryColor}, #2d572c);
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
padding: 40px;
width: 100%;
max-width: 400px;
text-align: center;
}
.login-logo {
font-size: 24px;
font-weight: 600;
color: {$primaryColor};
margin-bottom: 8px;
}
.login-subtitle {
color: #666;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-label {
display: block;
margin-bottom: 5px;
color: #333;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 12px 15px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 16px;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: {$primaryColor};
box-shadow: 0 0 0 3px rgba(45, 87, 44, 0.1);
}
.btn {
width: 100%;
padding: 14px;
background: {$primaryColor};
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.3s ease;
}
.btn:hover {
background: #24502a;
}
.btn:disabled {
background: #cccccc;
cursor: not-allowed;
}
.alert {
padding: 12px 15px;
border-radius: 6px;
margin-bottom: 20px;
text-align: left;
}
.alert-danger {
background-color: #fee;
border-left: 4px solid #e74c3c;
color: #c0392b;
}
.alert-warning {
background-color: #fff8e1;
border-left: 4px solid #f39c12;
color: #d68910;
}
.back-link {
display: inline-block;
margin-top: 20px;
color: {$primaryColor};
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.login-container {
margin: 20px;
padding: 30px 20px;
}
}
</style>
</head>
<body>
<div class='login-container'>
<h1 class='login-logo'>{$appName}</h1>
<p class='login-subtitle'>{$loginTitle}</p>
{$errorHtml}
<form method='POST' action=''>
<input type='hidden' name='csrf_token' value='{$csrfToken}'>
<input type='hidden' name='redirect' value='{$redirect}'>
<div class='form-group'>
<label for='username' class='form-label'>Username</label>
<input type='text' id='username' name='username' class='form-control'
value='" . htmlspecialchars($_POST['username'] ?? '', ENT_QUOTES) . "'
required autocomplete='username'>
</div>
<div class='form-group'>
<label for='password' class='form-label'>Password</label>
<input type='password' id='password' name='password' class='form-control'
required autocomplete='current-password'>
</div>
<button type='submit' class='btn'>Sign In</button>
</form>
<a href='{$redirect}' class='back-link'>← Back to application</a>
</div>
<script>
// Focus management
document.addEventListener('DOMContentLoaded', function() {
const usernameField = document.getElementById('username');
const passwordField = document.getElementById('password');
// Focus username field if empty, otherwise password
if (usernameField.value.trim() === '') {
usernameField.focus();
} else {
passwordField.focus();
}
// Form validation
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
const username = usernameField.value.trim();
const password = passwordField.value.trim();
if (username.length < 3 || password.length < 6) {
e.preventDefault();
alert('Please ensure username is at least 3 characters and password is at least 6 characters.');
return false;
}
});
});
</script>
</body>
</html>";
}
/**
* Redirect with flash message via session
*/
private function redirectWithMessage($url, $message, $type = 'info')
{
$_SESSION['flash_message'] = ['text' => $message, 'type' => $type];
header("Location: {$url}");
exit;
}
}