feat(security): place login throttle before CSRF and add visual CAPTCHA verification to login form

This commit is contained in:
DevSoftTech Server 2026-10-22 14:45:04 +05:30
parent 61b8bf6a71
commit 7547d00bb4
5 changed files with 168 additions and 10 deletions

View File

@ -79,6 +79,7 @@ class Filters extends BaseFilters
*/
public array $globals = [
'before' => [
'loginThrottle',
'singleSession',
'invalidchars',
'csrf' => [

View File

@ -376,6 +376,7 @@ $routes->post('Finance/bank_pos_settlement_create', 'Finance::bankPosSettlementC
$routes->post('Finance/bank_pos_settlement_accept', 'Finance::bankPosSettlementAccept');
service('auth')->routes($routes);
$routes->get('captcha', 'Captcha::generate');
$routes->get('auth/session-status', 'AuthSession::status');
// Hospital Stock Management (Storestock)

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;
class Captcha extends BaseController
{
/**
* Generate an image CAPTCHA, save the code to session, and stream image.
*/
public function generate(): ResponseInterface
{
// 1. Generate random code (unambiguous uppercase characters)
$characters = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$code = '';
$length = 5;
$max = strlen($characters) - 1;
for ($i = 0; $i < $length; $i++) {
$code .= $characters[random_int(0, $max)];
}
// 2. Save code in session
session()->set('login_captcha', $code);
// 3. Create GD image
$width = 140;
$height = 40;
$image = imagecreatetruecolor($width, $height);
// Background color (#f8fafc)
$bgColor = imagecolorallocate($image, 248, 250, 252);
imagefilledrectangle($image, 0, 0, $width, $height, $bgColor);
// Add subtle background noise dots
for ($i = 0; $i < 60; $i++) {
$dotColor = imagecolorallocate($image, random_int(190, 230), random_int(190, 230), random_int(200, 240));
imagesetpixel($image, random_int(0, $width - 1), random_int(0, $height - 1), $dotColor);
}
// Add 3 subtle noise lines
for ($i = 0; $i < 3; $i++) {
$lineColor = imagecolorallocate($image, random_int(180, 220), random_int(180, 220), random_int(190, 230));
imageline($image, random_int(0, $width), random_int(0, $height), random_int(0, $width), random_int(0, $height), $lineColor);
}
// Color palette for characters
$colors = [
imagecolorallocate($image, 15, 23, 42), // slate-900
imagecolorallocate($image, 30, 58, 138), // blue-900
imagecolorallocate($image, 88, 28, 135), // purple-900
imagecolorallocate($image, 19, 78, 74), // teal-900
imagecolorallocate($image, 136, 19, 55), // rose-900
];
// Draw each character with spacing and slight vertical jitter
$spacing = (int) (($width - 24) / $length);
for ($i = 0; $i < $length; $i++) {
$char = $code[$i];
$charColor = $colors[$i % count($colors)];
$x = 12 + ($i * $spacing);
$y = random_int(10, 14);
imagestring($image, 5, $x, $y, $char, $charColor);
// Draw 1px horizontal offset for bold readability
imagestring($image, 5, $x + 1, $y, $char, $charColor);
}
// Add a thin border
$borderColor = imagecolorallocate($image, 203, 213, 225);
imagerectangle($image, 0, 0, $width - 1, $height - 1, $borderColor);
// Capture PNG stream
ob_start();
imagepng($image);
$imageData = (string) ob_get_clean();
imagedestroy($image);
return $this->response
->setContentType('image/png')
->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
->setHeader('Pragma', 'no-cache')
->setHeader('Expires', '0')
->setBody($imageData);
}
}

View File

@ -10,13 +10,15 @@ use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Authentication Endpoint Rate Limiting Filter (WASA CWE-307 Compliant)
* Authentication Endpoint Rate Limiting & CAPTCHA Filter (WASA CWE-307 Compliant)
*
* Enforces server-side rate limiting on authentication attempts:
* - Restricts requests to a maximum of 5 attempts per 60 seconds per IP address.
* - When threshold is exceeded, returns HTTP 429 Too Many Requests.
* - Emits standard Retry-After and X-RateLimit-* headers.
* - Logs violations for security monitoring and incident response.
* Enforces dual-layer defense on authentication endpoints:
* 1. Server-side Rate Limiting (Token Bucket):
* - Restricts login POST requests to a maximum of 5 attempts per 60 seconds per IP address.
* - When threshold is exceeded, returns HTTP 429 Too Many Requests with Retry-After header.
* - Logs security events for SIEM / audit monitoring.
* 2. Visual Security Verification (CAPTCHA):
* - Validates human presence to block automated credential brute-force, password spraying, and bots.
*/
class LoginThrottleFilter implements FilterInterface
{
@ -40,7 +42,16 @@ class LoginThrottleFilter implements FilterInterface
return;
}
// Only throttle state-changing authentication attempts (POST)
$uri = trim($request->getUri()->getPath(), '/');
// Only target authentication endpoints
$authRoutes = ['login', 'login/magic-link', 'register'];
$isAuthRoute = in_array($uri, $authRoutes, true) || str_starts_with($uri, 'login/') || str_starts_with($uri, 'register/');
if (! $isAuthRoute) {
return;
}
// Only throttle state-changing requests (POST)
if (strtoupper($request->getMethod()) !== 'POST') {
return;
}
@ -49,7 +60,7 @@ class LoginThrottleFilter implements FilterInterface
$ip = $request->getIPAddress();
$throttleKey = 'auth_throttle_' . md5($ip);
// Check if the request exceeds capacity
// 1. Check IP rate limit (Token Bucket)
if ($throttler->check($throttleKey, $this->maxAttempts, $this->decaySeconds, 1) === false) {
$retryAfter = max(1, $throttler->getTokenTime());
$resetTime = time() + $retryAfter;
@ -57,7 +68,7 @@ class LoginThrottleFilter implements FilterInterface
// Log security warning for SIEM / audit monitoring
log_message(
'warning',
"[WASA-CWE-307] Authentication rate limit exceeded. IP: {$ip}, Path: {$request->getUri()->getPath()}, Retry-After: {$retryAfter}s"
"[WASA-CWE-307] Authentication rate limit exceeded. IP: {$ip}, Path: {$uri}, Retry-After: {$retryAfter}s"
);
/** @var ResponseInterface $response */
@ -80,7 +91,7 @@ class LoginThrottleFilter implements FilterInterface
]);
}
// Return HTML response for standard browser form submissions
// Return HTML response for standard browser submissions
$body = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
. '<title>429 Too Many Requests</title>'
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
@ -104,6 +115,24 @@ class LoginThrottleFilter implements FilterInterface
return $response->setBody($body);
}
// 2. Validate CAPTCHA for standard login POST
if ($uri === 'login' || $uri === 'login/') {
$submittedCaptcha = trim((string) $request->getPost('captcha'));
$expectedCaptcha = (string) session('login_captcha');
if ($expectedCaptcha === '' || strcasecmp($submittedCaptcha, $expectedCaptcha) !== 0) {
// Invalidate session captcha so it cannot be retried
session()->remove('login_captcha');
return redirect()->route('login')
->withInput()
->with('error', 'Invalid Security Verification (CAPTCHA) code. Please enter the characters shown in the image.');
}
// Consume captcha to prevent replay attacks
session()->remove('login_captcha');
}
}
/**

View File

@ -92,6 +92,45 @@
<div class="invalid-feedback">Please enter your password!</div>
</div>
<!-- Security Verification CAPTCHA (WASA Brute-Force & Bot Defense) -->
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="captchaInput" class="form-label mb-0 small fw-semibold text-secondary">
<i class="bi bi-shield-check text-primary me-1"></i> Security Code
</label>
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none" id="btnRefreshCaptcha" style="font-size: 11px;">
<i class="bi bi-arrow-clockwise"></i> Change Code
</button>
</div>
<div class="input-group has-validation">
<span class="input-group-text p-1 bg-white border-end-0" style="cursor: pointer;" title="Click to change image code" onclick="refreshLoginCaptcha();">
<img src="<?= base_url('captcha') ?>" id="captchaImage" alt="Security CAPTCHA" style="height: 36px; border-radius: 4px;">
</span>
<input type="text" name="captcha" id="captchaInput" class="form-control form-control-lg text-uppercase fw-bold text-center border-start-0 fs-5" placeholder="Enter Code" maxlength="6" autocomplete="off" required style="letter-spacing: 2px;">
<div class="invalid-feedback">Please enter the security verification characters.</div>
</div>
</div>
<script>
function refreshLoginCaptcha() {
var img = document.getElementById('captchaImage');
if (img) {
img.src = '<?= base_url('captcha') ?>?' + Date.now();
}
var inp = document.getElementById('captchaInput');
if (inp) {
inp.value = '';
inp.focus();
}
}
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('btnRefreshCaptcha');
if (btn) {
btn.addEventListener('click', refreshLoginCaptcha);
}
});
</script>
<?php if (setting('Auth.sessionConfig')['allowRemembering']) : ?>
<div class="col-12">
<div class="form-check">