Every login attempt, including unknown users (via DUMMY_HASH), forces a full Argon2id verify with no rate limit, letting a flood take down the login endpoint / shared host via hashing-cost amplification. Add a pre-auth per-IP limiter (RateLimiterRepository, default 20/min) as the first check in AuthController::login, before any user lookup or password hashing.
74 lines
3.1 KiB
PHP
74 lines
3.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Auth;
|
|
|
|
use App\Http\{Request, Response};
|
|
use App\Security\Password;
|
|
use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
|
|
|
|
final class AuthController
|
|
{
|
|
// A valid Argon2id hash used only to equalize verification timing for unknown users (anti-enumeration).
|
|
private const DUMMY_HASH = '$argon2id$v=19$m=65536,t=4,p=1$S25iZnFxVGpGQWRnVFpYYg$8Lorit52NTt4MN/gXR/9xkr9hzFHZvpGWTzJvjqXnCk';
|
|
|
|
public function __construct(
|
|
private UserRepository $users,
|
|
private AuditRepository $audit,
|
|
private RateLimiterRepository $rateLimiter,
|
|
private int $maxAttempts = 5,
|
|
private int $lockSeconds = 900,
|
|
private int $loginIpLimitPerMin = 20,
|
|
) {}
|
|
|
|
public function login(Request $req, SessionStore $sess, int $now, string $clientIp): Response
|
|
{
|
|
// Pre-auth per-IP limit: fires before any user lookup / Argon2id verify,
|
|
// so a flood can't force repeated expensive hash work (DoS amplification).
|
|
if ($clientIp !== '' && $this->rateLimiter->hit('login:' . $clientIp, intdiv($now, 60)) > $this->loginIpLimitPerMin) {
|
|
return Response::json(['error' => 'rate_limited'], 429);
|
|
}
|
|
|
|
$username = (string)$req->post('username');
|
|
$password = (string)$req->post('password');
|
|
$u = $this->users->byUsername($username);
|
|
if ($u === null) {
|
|
Password::verify($password, self::DUMMY_HASH); // constant-time-ish: burn equivalent work
|
|
return Response::json(['error' => 'invalid_credentials'], 401);
|
|
}
|
|
|
|
if ((int)($u['locked_until'] ?? 0) > $now) {
|
|
$this->audit->append($username, 'login_locked', 'auth', [], []);
|
|
return Response::json(['error' => 'locked'], 423);
|
|
}
|
|
|
|
// lock window expired -> reset stale failure counter so the user gets a fresh set of attempts
|
|
if ((int)($u['locked_until'] ?? 0) !== 0 && (int)$u['locked_until'] <= $now) {
|
|
$this->users->clearFailures((int)$u['id']);
|
|
$u['failed_attempts'] = 0;
|
|
$u['locked_until'] = 0;
|
|
}
|
|
|
|
if (!Password::verify($password, (string)$u['password_hash'])) {
|
|
$attempts = (int)($u['failed_attempts'] ?? 0) + 1;
|
|
$lock = $attempts >= $this->maxAttempts ? $now + $this->lockSeconds : 0;
|
|
$this->users->recordFailure((int)$u['id'], $lock);
|
|
$this->audit->append($username, 'login_failed', 'auth', [], ['attempts' => $attempts]);
|
|
return Response::json(['error' => 'invalid_credentials'], 401);
|
|
}
|
|
$this->users->clearFailures((int)$u['id']);
|
|
$sess->regenerate();
|
|
$sess->set('user_id', (int)$u['id']);
|
|
$sess->set('username', $u['username']);
|
|
$sess->set('role', $u['role']);
|
|
$sess->set('site_scope', $u['site_scope'] ?? null);
|
|
$this->audit->append($username, 'login_ok', 'auth', [], []);
|
|
return Response::json(['ok' => true], 200);
|
|
}
|
|
|
|
public function logout(SessionStore $sess): Response
|
|
{
|
|
$sess->clear();
|
|
$sess->regenerate();
|
|
return Response::json(['ok' => true], 200);
|
|
}
|
|
}
|