Harden auth, headers, and container per OWASP review
- Add per-account lockout + IP rate limiter on local sign-in (A07) - Emit CSP and security headers on every response (A05) - Run container as non-root `app`, /data 0700 (A05/A02) - Stop reflecting raw token-endpoint body into redirect URL (A09) - Handle missing refresh_token in connect callback without a 500 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,21 +109,58 @@ public class UserService : IUserService
|
||||
return user;
|
||||
}
|
||||
|
||||
// Brute-force lockout: after this many consecutive failures the account is locked
|
||||
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
|
||||
// admin. The counter resets on any successful sign-in.
|
||||
private const int LockoutThreshold = 5;
|
||||
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
|
||||
|
||||
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
|
||||
{
|
||||
var user = await _repo.FindByEmailAsync(email);
|
||||
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
||||
return null;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Account currently locked → refuse without even checking the password, so a locked
|
||||
// account can't be probed. Returning null (generic failure) avoids account enumeration.
|
||||
if (user.LockoutEndUtc is { } until && until > now)
|
||||
{
|
||||
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lock window elapsed → clear it before re-evaluating.
|
||||
if (user.LockoutEndUtc is not null)
|
||||
{
|
||||
user.LockoutEndUtc = null;
|
||||
user.FailedLoginCount = 0;
|
||||
}
|
||||
|
||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
{
|
||||
user.FailedLoginCount++;
|
||||
if (user.FailedLoginCount >= LockoutThreshold)
|
||||
{
|
||||
user.LockoutEndUtc = now + LockoutWindow;
|
||||
_logger.LogWarning(
|
||||
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
|
||||
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
|
||||
}
|
||||
await _repo.UpsertAsync(user);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transparently upgrade the hash if the algorithm parameters changed
|
||||
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
user.PasswordHash = _hasher.HashPassword(user, password);
|
||||
|
||||
user.LastLogin = DateTimeOffset.UtcNow;
|
||||
// Success → clear the failure trail.
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockoutEndUtc = null;
|
||||
user.LastLogin = now;
|
||||
await _repo.UpsertAsync(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user