- 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.
378 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|