Files
GameList/Infrastructure/AuthAttemptMonitor.cs
2026-02-08 18:51:01 +01:00

61 lines
3.5 KiB
C#

using System.Collections.Concurrent;
namespace GameList.Infrastructure;
public sealed class AuthAttemptMonitor(ILogger<AuthAttemptMonitor> logger)
{
private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10);
private const int AlertThreshold = 5;
private static readonly Action<ILogger, string, string, string, string, int, Exception?> LogAuthFailure = LoggerMessage.Define<string, string, string, string, int>(LogLevel.Warning, new EventId(2001, nameof(LogAuthFailure)), "Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}");
private static readonly Action<ILogger, string, string, string, int, double, Exception?> LogSecurityAlert = LoggerMessage.Define<string, string, string, int, double>(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<ILogger, string, string, string, Exception?> LogRateLimited = LoggerMessage.Define<string, string, string>(LogLevel.Warning, new EventId(2003, nameof(LogRateLimited)), "Rate limit rejection path={Path} ip={Ip} userId={UserId}");
private static readonly Action<ILogger, string, string, DateTimeOffset, Exception?> LogSessionExpired = LoggerMessage.Define<string, string, DateTimeOffset>(LogLevel.Warning, new EventId(2004, nameof(LogSessionExpired)), "Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}");
private readonly ConcurrentDictionary<string, AttemptState> _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);
}