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.
This commit is contained in:
parent
5692874b10
commit
41cd7a4fd8
32 changed files with 5796 additions and 368 deletions
378
controllers/AuthController.php
Normal file
378
controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue