using System.Collections.Concurrent; namespace GameList.Infrastructure; public sealed class AuthAttemptMonitor(ILogger logger) { private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10); private const int AlertThreshold = 5; private static readonly Action LogAuthFailure = LoggerMessage.Define( LogLevel.Warning, new EventId(2001, nameof(LogAuthFailure)), "Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}"); private static readonly Action LogSecurityAlert = LoggerMessage.Define( LogLevel.Error, new EventId(2002, nameof(LogSecurityAlert)), "Security alert: repeated auth failures scope={Scope} actor={Actor} ip={Ip} failuresInWindow={Count} windowMinutes={WindowMinutes}"); private static readonly Action LogRateLimited = LoggerMessage.Define( LogLevel.Warning, new EventId(2003, nameof(LogRateLimited)), "Rate limit rejection path={Path} ip={Ip} userId={UserId}"); private static readonly Action LogSessionExpired = LoggerMessage.Define( LogLevel.Warning, new EventId(2004, nameof(LogSessionExpired)), "Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}"); private readonly ConcurrentDictionary _failures = new(StringComparer.Ordinal); public void RecordFailure(HttpContext context, string scope, string actor, string reason) { var now = DateTimeOffset.UtcNow; var key = BuildKey(context, scope, actor); var state = _failures.AddOrUpdate( key, _ => new AttemptState(1, now, now), (_, previous) => previous.LastFailureAt + FailureWindow < now ? new AttemptState(1, now, now) : previous with { Count = previous.Count + 1, LastFailureAt = now }); LogAuthFailure(logger, scope, actor, GetRemoteIp(context), reason, state.Count, null); if (state.Count >= AlertThreshold && state.Count % AlertThreshold == 0) { LogSecurityAlert(logger, scope, actor, GetRemoteIp(context), state.Count, FailureWindow.TotalMinutes, null); } } public void RecordSuccess(HttpContext context, string scope, string actor) { _failures.TryRemove(BuildKey(context, scope, actor), out _); } public void RecordRateLimited(HttpContext context) { LogRateLimited( logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous", null); } public void RecordSessionExpired(HttpContext context, DateTimeOffset startedAt) { LogSessionExpired( logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), startedAt, null); } private static string BuildKey(HttpContext context, string scope, string actor) { return $"{scope}|{actor}|{GetRemoteIp(context)}"; } private static string GetRemoteIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; private readonly record struct AttemptState(int Count, DateTimeOffset FirstFailureAt, DateTimeOffset LastFailureAt); }