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:
@@ -33,17 +33,30 @@ public static class AuthEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
context.Response.Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
|
||||
context.Response.Redirect($"/login?error={errorMsg}");
|
||||
return;
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
|
||||
// (ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
|
||||
// The full RoleMappingResult — including PermittedSiteIds and the
|
||||
@@ -107,6 +120,17 @@ public static class AuthEndpoints
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
@@ -114,11 +138,14 @@ public static class AuthEndpoints
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user