Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to the MMCA.Common packages are documented here. The format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/)
and are derived from git tags by MinVer (see [the published versioning policy](https://ivanball.github.io/docs/guides/common-VERSIONING.html)).

## [Unreleased]

### Fixed

- `HttpResultExecutor` treats Polly's `ExecutionRejectedException` (so `TimeoutRejectedException` and `BrokenCircuitException`) as a transport fault, exactly like `HttpRequestException`: the UI service returns the `Http.TransportFailure` result instead of throwing, so a gateway outage no longer turns a prerender into a 500. Cancellation of the caller's own token still propagates.
- `LoginProtectionService` fails open when the cache throws (Redis down), as `CacheSettings` promises a cache outage never becomes an error: the lockout and registration-limit checks answer success, and the failed-attempt increment, the reset and the registration count do nothing, each with a Warning log naming the operation (never the email or IP). Sign-in and registration used to fail with a 500. Cancellation of the caller's own token still propagates. The constructor takes a trailing optional `ILogger<LoginProtectionService>` (resolved by DI; existing calls compile unchanged, recompile needed).
- The `/register` duplicate-email alert names the address the server rejected, not the live field, and disappears as soon as the email field no longer equals it; typing the same address back shows nothing until the next submit re-checks it. Editing the field used to print an unchecked address as "already registered".
- Spanish user-administration strings (`UserAdminListResources.es.resx`) carry their accents and opening question marks; seven values used the unaccented spellings of "correo electronico", "administracion", "sesion", "cerrara", "podra", "volvera" and "elimino".
- `SetStoredPermissionsAsync` (the role-administration `PUT`) answers `Authorization.RoleNotFound` for a role outside the role universe (compiled catalog roles, `KnownRoles`, roles with stored grants), the same error `GetRoleAsync` returns, and stores nothing, for an empty and a non-empty list alike. An empty list used to answer 200 for a nonexistent role, and a non-empty one created the typo as a new role.
- `AddCommonServerTokenStorage()` also composes a handler onto the `"APIClient"` pipeline that stamps `X-Forwarded-For` with the visitor's remote IP (the page request during prerender, the circuit's connection afterwards; the same value the cookie-session refresh forwards), replacing any value already on the request. Blazor Server API calls used to carry no client address, so per-IP limits such as the registration rate limit keyed every visitor on the UI host's address. Nothing is sent when no request is in scope; the WebAssembly path is unchanged.

## [1.224.0] - 2026-10-03

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,7 @@ public async Task<Result<RolePermissionsResponse>> GetRoleAsync(
var stored = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false);
var compiled = CompiledPermissions(role);

// A role the host has never named, never compiled a permission for and never granted one to
// does not exist as far as this surface is concerned. Reporting it as an empty role instead
// would make every typo look like a real role with nothing granted.
if (stored.Count == 0
&& compiled.Count == 0
&& !settings.Value.KnownRoles.Contains(role, StringComparer.OrdinalIgnoreCase)
&& !catalog.Roles.Contains(role, StringComparer.OrdinalIgnoreCase))
if (!IsKnownRole(role, stored, compiled))
{
return Result.Failure<RolePermissionsResponse>(RoleNotFound(role));
}
Expand All @@ -130,6 +124,15 @@ public async Task<Result<RolePermissionsResponse>> SetStoredPermissionsAsync(
return Result.Failure<RolePermissionsResponse>(RoleNotFound(role));
}

// The same existence rule GetRoleAsync applies, checked before anything is validated or
// written: a set must not be the route by which a typo becomes a role, with an empty list
// (answering success for a role that does not exist) or with a non-empty one (creating it).
var current = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false);
if (!IsKnownRole(role, current, CompiledPermissions(role)))
{
return Result.Failure<RolePermissionsResponse>(RoleNotFound(role));
}

var desired = new HashSet<string>(
permissions.Where(permission => !string.IsNullOrWhiteSpace(permission)).Select(permission => permission.Trim()),
StringComparer.Ordinal);
Expand Down Expand Up @@ -159,7 +162,6 @@ public async Task<Result<RolePermissionsResponse>> SetStoredPermissionsAsync(
role));
}

var current = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false);
var existing = new HashSet<string>(current, StringComparer.Ordinal);

// Each grant and revoke commits on its own, so a failure or a throw part-way through leaves
Expand Down Expand Up @@ -214,6 +216,24 @@ private SortedSet<string> RoleUniverse(IEnumerable<string> rolesWithGrants)
return roles;
}

/// <summary>
/// Whether a role belongs to the universe <see cref="RoleUniverse"/> lists: a catalog role, a
/// configured <c>KnownRoles</c> entry, or a role that already carries a stored grant (plus a role
/// the registry compiles permissions for).
/// </summary>
/// <remarks>
/// A role the host has never named, never compiled a permission for and never granted one to
/// does not exist as far as this surface is concerned. Reporting it as an empty role instead
/// would make every typo look like a real role with nothing granted.
/// </remarks>
/// <param name="role">The role name.</param>
/// <param name="stored">The role's stored grants.</param>
/// <param name="compiled">The role's compiled permissions.</param>
/// <returns><see langword="true"/> when the role exists for this surface.</returns>
private bool IsKnownRole(string role, IReadOnlyList<string> stored, IReadOnlyList<string> compiled) =>
compiled.Count > 0
|| RoleUniverse(stored.Count > 0 ? [role] : []).Contains(role);

private static Error RoleNotFound(string? role) => Error.NotFoundError(
"Authorization.RoleNotFound",
"The role was not found.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MMCA.Common.Application.Auth;
using MMCA.Common.Application.Interfaces;
Expand All @@ -15,12 +17,21 @@ namespace MMCA.Common.Infrastructure.Auth;
/// <item><b>Registration rate limit</b>: limits registrations per IP address within a
/// configurable time window.</item>
/// </list>
/// <para>
/// <b>Fails open on a cache outage.</b> The counters live in the cache, and the cache is an
/// optimization that never turns its own outage into an error (<c>CacheSettings</c>). When the cache
/// throws, a check answers success and an increment or reset does nothing, each with a warning log,
/// so an unreachable cache suspends the limits instead of failing every sign-in and registration.
/// Cancellation of the caller's own token still propagates.
/// </para>
/// </summary>
public sealed class LoginProtectionService(
public sealed partial class LoginProtectionService(
ICacheService cacheService,
IOptions<LoginProtectionSettings> settings) : ILoginProtectionService
IOptions<LoginProtectionSettings> settings,
ILogger<LoginProtectionService>? logger = null) : ILoginProtectionService
{
private readonly LoginProtectionSettings _settings = settings.Value;
private readonly ILogger _logger = logger ?? NullLogger<LoginProtectionService>.Instance;

/// <summary>
/// Normalizes the supplied address the same way <see cref="Email"/> does before it is used in a
Expand All @@ -39,7 +50,16 @@ public sealed class LoginProtectionService(
public async Task<Result> CheckLockoutAsync(string email, CancellationToken cancellationToken = default)
{
var lockoutKey = LockoutKey(email);
var isLockedOut = await cacheService.GetAsync<bool?>(lockoutKey, cancellationToken).ConfigureAwait(false) ?? false;
bool isLockedOut;
try
{
isLockedOut = await cacheService.GetAsync<bool?>(lockoutKey, cancellationToken).ConfigureAwait(false) ?? false;
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
LogCacheUnavailable(_logger, nameof(CheckLockoutAsync), ex);
return Result.Success();
}

return isLockedOut
? Result.Failure(Error.TooManyRequests(
Expand All @@ -61,29 +81,43 @@ public async Task IncrementFailedAttemptsAsync(string email, CancellationToken c
// is what a credential-stuffing run against one account looks like, still trips the lockout.
// Closing the gap needs the increment made atomic again WITHIN the hash layout (a Lua
// script) or counters moved off IDistributedCache so both sides speak Redis strings.
var newCount = await cacheService.IncrementAsync(
AttemptsKey(email),
TimeSpan.FromMinutes(_settings.FailedAttemptWindowMinutes),
cancellationToken).ConfigureAwait(false);

if (newCount >= _settings.MaxFailedAttempts)
try
{
var excessAttempts = (int)Math.Min(newCount - _settings.MaxFailedAttempts, int.MaxValue);

// Clamp the shift exponent: C# masks int shift counts to 5 bits, so 1 << 31 is negative
// and 1 << 32 wraps back to 1, silently shrinking (or negating) the lockout TTL for a
// sufficiently persistent attacker. 1 << 30 already exceeds any permitted
// MaxLockoutSeconds (range caps at 3600), so deep excess always lands on the cap.
var lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), _settings.MaxLockoutSeconds);
await cacheService.SetAsync(LockoutKey(email), true, TimeSpan.FromSeconds(lockoutSeconds), cancellationToken).ConfigureAwait(false);
var newCount = await cacheService.IncrementAsync(
AttemptsKey(email),
TimeSpan.FromMinutes(_settings.FailedAttemptWindowMinutes),
cancellationToken).ConfigureAwait(false);

if (newCount >= _settings.MaxFailedAttempts)
{
var excessAttempts = (int)Math.Min(newCount - _settings.MaxFailedAttempts, int.MaxValue);

// Clamp the shift exponent: C# masks int shift counts to 5 bits, so 1 << 31 is negative
// and 1 << 32 wraps back to 1, silently shrinking (or negating) the lockout TTL for a
// sufficiently persistent attacker. 1 << 30 already exceeds any permitted
// MaxLockoutSeconds (range caps at 3600), so deep excess always lands on the cap.
var lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), _settings.MaxLockoutSeconds);
await cacheService.SetAsync(LockoutKey(email), true, TimeSpan.FromSeconds(lockoutSeconds), cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
LogCacheUnavailable(_logger, nameof(IncrementFailedAttemptsAsync), ex);
}
}

/// <inheritdoc />
public async Task ResetFailedAttemptsAsync(string email, CancellationToken cancellationToken = default)
{
await cacheService.RemoveAsync(AttemptsKey(email), cancellationToken).ConfigureAwait(false);
await cacheService.RemoveAsync(LockoutKey(email), cancellationToken).ConfigureAwait(false);
try
{
await cacheService.RemoveAsync(AttemptsKey(email), cancellationToken).ConfigureAwait(false);
await cacheService.RemoveAsync(LockoutKey(email), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
LogCacheUnavailable(_logger, nameof(ResetFailedAttemptsAsync), ex);
}
}

/// <inheritdoc />
Expand All @@ -95,7 +129,16 @@ public async Task<Result> CheckRegistrationRateLimitAsync(string? ipAddress, Can
}

var key = RegistrationKey(ipAddress);
var registrationCount = await cacheService.GetAsync<long?>(key, cancellationToken).ConfigureAwait(false) ?? 0;
long registrationCount;
try
{
registrationCount = await cacheService.GetAsync<long?>(key, cancellationToken).ConfigureAwait(false) ?? 0;
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
LogCacheUnavailable(_logger, nameof(CheckRegistrationRateLimitAsync), ex);
return Result.Success();
}

return registrationCount >= _settings.MaxRegistrationsPerIpPerHour
? Result.Failure(Error.Unauthorized(
Expand All @@ -116,11 +159,29 @@ public async Task IncrementRegistrationCountAsync(string? ipAddress, Cancellatio
// Read-modify-write (see IncrementFailedAttemptsAsync for why the native-counter path was
// removed), so the TTL is refreshed on every write. That makes the window slide rather than
// stay anchored to the first registration, which only ever tightens the limit.
await cacheService.IncrementAsync(
RegistrationKey(ipAddress),
TimeSpan.FromMinutes(_settings.RegistrationRateLimitWindowMinutes),
cancellationToken).ConfigureAwait(false);
try
{
await cacheService.IncrementAsync(
RegistrationKey(ipAddress),
TimeSpan.FromMinutes(_settings.RegistrationRateLimitWindowMinutes),
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
LogCacheUnavailable(_logger, nameof(IncrementRegistrationCountAsync), ex);
}
}

private static string RegistrationKey(string ipAddress) => $"registration:ip:{ipAddress}";

/// <summary>
/// Every cache fault counts as an outage except the caller's own cancellation, which keeps
/// propagating. A cancellation the caller did not request (a store-side timeout) is an outage.
/// </summary>
private static bool IsCacheOutage(Exception exception, CancellationToken cancellationToken) =>
exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested;

// The operation name is logged, never the key: the keys carry the email address or client IP.
[LoggerMessage(Level = LogLevel.Warning, Message = "Login protection could not reach the cache during {Operation}; failing open, so this call applies no lockout and no registration limit.")]
private static partial void LogCacheUnavailable(ILogger logger, string operation, Exception exception);
}
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
*REMOVED*MMCA.Common.Infrastructure.Auth.LoginProtectionService.LoginProtectionService(MMCA.Common.Application.Interfaces.ICacheService! cacheService, Microsoft.Extensions.Options.IOptions<MMCA.Common.Infrastructure.Auth.LoginProtectionSettings!>! settings) -> void
MMCA.Common.Infrastructure.Auth.LoginProtectionService.LoginProtectionService(MMCA.Common.Application.Interfaces.ICacheService! cacheService, Microsoft.Extensions.Options.IOptions<MMCA.Common.Infrastructure.Auth.LoginProtectionSettings!>! settings, Microsoft.Extensions.Logging.ILogger<MMCA.Common.Infrastructure.Auth.LoginProtectionService!>? logger = null) -> void
14 changes: 14 additions & 0 deletions Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,24 @@ public static class DependencyInjection
/// same-origin refresh endpoint on the interactive circuit (ADR-022). Pair with the session
/// cookie plumbing from MMCA.Common.API (<c>AddServerAuthSessionCookie</c> /
/// <c>UseCookieSessionRefresh</c>) and a registered <c>ITokenRefresher</c>.
/// <para>
/// Also forwards the visitor's address on this host's server-side <c>"APIClient"</c> calls:
/// each request carries <c>X-Forwarded-For</c> set to the remote IP of the HTTP request behind
/// the render (the page request during prerender, the circuit's connection afterwards), the
/// same value the cookie-session refresh already forwards, so per-client limits such as the
/// registration rate limit key on the visitor instead of on this host. Nothing is sent when no
/// request is in scope.
/// </para>
/// </summary>
public IServiceCollection AddCommonServerTokenStorage()
{
services.AddHttpContextAccessor();

// The UI services' named client (AddUIShared); a second AddHttpClient call with the same
// name appends to that client's pipeline, whichever of the two registrations runs first.
services.AddTransient<BrowserForwardedForHandler>();
services.AddHttpClient("APIClient").AddHttpMessageHandler<BrowserForwardedForHandler>();

return services.AddScoped<ITokenStorageService, ServerTokenStorageService>();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Http;

namespace MMCA.Common.UI.Web.Services;

/// <summary>
/// Stamps <c>X-Forwarded-For</c> with the browser's address on the server-side <c>"APIClient"</c>
/// calls a Blazor Server host makes for a visitor (the SSR prerender and the interactive circuit), so
/// the API keys per-client policy (the registration rate limit, the session's recorded IP) on the
/// visitor rather than on this host's own address, which every visitor shares.
/// </summary>
/// <remarks>
/// <para>
/// <b>Same source and semantics as the cookie-session refresh.</b> The address is the
/// <see cref="ConnectionInfo.RemoteIpAddress"/> of the HTTP request that carries this work: the page
/// request during prerender, the connection the circuit was established on afterwards. That value
/// has already been through this host's forwarded-headers middleware, so it is only as far back as
/// this host trusts its own proxies; a client-supplied <c>X-Forwarded-For</c> is never copied
/// verbatim. The header is single-valued and replaces any value already on the request.
/// </para>
/// <para>
/// <b>Server only.</b> Registered by <c>AddCommonServerTokenStorage()</c>, which only a Blazor
/// Server host calls; the WebAssembly client's calls reach the API through the same-origin proxy,
/// which stamps the header itself. When no request is in scope (a background call with no visitor
/// behind it) nothing is sent.
/// </para>
/// </remarks>
/// <param name="httpContextAccessor">Reads the request in scope; an async-local, so it crosses the
/// handler's own DI scope.</param>
internal sealed class BrowserForwardedForHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
/// <summary>The header the API's forwarded-headers configuration reads.</summary>
internal const string HeaderName = "X-Forwarded-For";

/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);

var remoteIpAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
if (remoteIpAddress is not null)
{
request.Headers.Remove(HeaderName);
request.Headers.TryAddWithoutValidation(HeaderName, remoteIpAddress);
}

return base.SendAsync(request, cancellationToken);
}
}
Loading
Loading