86 lines
3.7 KiB
C#
86 lines
3.7 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);
|
|
}
|