feat(security): place login throttle before CSRF and add visual CAPTCHA verification to login form
This commit is contained in:
parent
61b8bf6a71
commit
7547d00bb4
|
|
@ -79,6 +79,7 @@ class Filters extends BaseFilters
|
|||
*/
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'loginThrottle',
|
||||
'singleSession',
|
||||
'invalidchars',
|
||||
'csrf' => [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
88
app/Controllers/Captcha.php
Normal file
88
app/Controllers/Captcha.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user