feat(security): wire LoginThrottle into management Basic-Auth and /auth/login|token — password-spray guard (arch-review P1/UA5)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:13:59 -04:00
parent d4d1732a9e
commit 7af36fd5dc
5 changed files with 143 additions and 0 deletions
@@ -104,15 +104,30 @@ public static class ManagementAuthenticator
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
// 1a. Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5). Consulted BELOW the
// DisableLogin bypass — a login-disabled deployment never binds, so it never throttles.
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return new AuthOutcome(null, Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429));
}
// 2. LDAP authentication
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return new AuthOutcome(null, Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
}
throttle.RecordSuccess(username, remoteIp!);
// 3. Role resolution
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);