harden(api-login): remove account-state leak and equalize failure timing

This commit is contained in:
2026-04-25 21:37:09 +02:00
parent e775ed2800
commit 61c1d18e95
3 changed files with 20 additions and 26 deletions

View File

@@ -20,6 +20,7 @@ $apiTokenEndpointService = app(ApiTokenEndpointService::class);
// ---- Login-specific rate limits (stricter than the general API rate limit in ApiBootstrap) ----
$rateLimiter = (app(SecurityServicesFactory::class))->createRateLimiterService();
$ip = $requestRuntime->ip();
$dummyPasswordHash = '$2y$12$cPhOFmglmOpMfs28m7KwMuM58w1W7STb2xtVDsj8.WZCmOCuEtN4a';
$ipResult = $rateLimiter->isBlocked('api.auth.login.ip', $ip);
if (!($ipResult['allowed'] ?? true)) {
@@ -50,30 +51,20 @@ if (!($emailResult['allowed'] ?? true)) {
ApiResponse::tooManyRequests((int) ($emailResult['retry_after'] ?? 60));
}
// ---- User lookup ----
// ---- User lookup + credential verification ----
$userReadRepository = (app(UserServicesFactory::class))->createUserReadRepository();
$user = $userReadRepository->findByEmail($email);
if (!$user) {
$rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300);
$rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900);
ApiResponse::error('invalid_credentials', 401);
}
// ---- Account checks ----
if (!(int) ($user['active'] ?? 0)) {
$rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300);
$rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900);
ApiResponse::error('account_inactive', 401);
}
// Use a fixed bcrypt hash when the account does not exist to keep verification timing closer.
$userPasswordHash = $user ? (string) ($user['password'] ?? '') : $dummyPasswordHash;
$passwordMatches = password_verify($password, $userPasswordHash);
if (empty($user['email_verified_at'])) {
$rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300);
$rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900);
ApiResponse::error('invalid_credentials', 401);
}
// ---- Password verification ----
if (!password_verify($password, (string) ($user['password'] ?? ''))) {
if (
!$user
|| !$passwordMatches
|| !(int) ($user['active'] ?? 0)
|| empty($user['email_verified_at'])
) {
$rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300);
$rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900);
ApiResponse::error('invalid_credentials', 401);