feat(auth): adds auth rate limiting
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace ReverseLlama.Server;
|
||||||
|
|
||||||
|
internal sealed class AuthRateLimiter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, AuthAttemptInfo> _attempts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ILogger<AuthRateLimiter> _logger;
|
||||||
|
|
||||||
|
public AuthRateLimiter(ILogger<AuthRateLimiter> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFailure(string ipAddress, string endpoint)
|
||||||
|
{
|
||||||
|
var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo());
|
||||||
|
|
||||||
|
lock (info)
|
||||||
|
{
|
||||||
|
info.Count++;
|
||||||
|
info.LastAttemptUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (info.Count >= 20)
|
||||||
|
{
|
||||||
|
info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})",
|
||||||
|
ipAddress, info.Count, endpoint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed auth attempt #{Count} from {IpAddress} on {Endpoint}",
|
||||||
|
info.Count, ipAddress, endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!_attempts.TryGetValue(ipAddress, out var info))
|
||||||
|
{
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (info)
|
||||||
|
{
|
||||||
|
if (info.BlockedUntilUtc is { } blockedUntil)
|
||||||
|
{
|
||||||
|
if (blockedUntil > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return (false, blockedUntil - DateTime.UtcNow, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Count = 0;
|
||||||
|
info.BlockedUntilUtc = null;
|
||||||
|
info.LastAttemptUtc = DateTime.MinValue;
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var waitTime = CalculateWaitTime(info.Count);
|
||||||
|
if (waitTime is { } wait)
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
|
||||||
|
if (elapsed < wait)
|
||||||
|
{
|
||||||
|
return (false, wait - elapsed, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetClientIp(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||||
|
{
|
||||||
|
var first = forwardedFor.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(first))
|
||||||
|
{
|
||||||
|
var commaIndex = first.IndexOf(',');
|
||||||
|
return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Headers.TryGetValue("X-Real-IP", out var realIp))
|
||||||
|
{
|
||||||
|
var first = realIp.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(first))
|
||||||
|
{
|
||||||
|
return first.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan? CalculateWaitTime(int attemptCount) =>
|
||||||
|
attemptCount switch
|
||||||
|
{
|
||||||
|
< 3 => null,
|
||||||
|
< 5 => TimeSpan.FromSeconds(5),
|
||||||
|
< 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5),
|
||||||
|
< 20 => TimeSpan.FromMinutes(attemptCount - 9),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class AuthAttemptInfo
|
||||||
|
{
|
||||||
|
public int Count;
|
||||||
|
public DateTime LastAttemptUtc;
|
||||||
|
public DateTime? BlockedUntilUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ builder.Services.AddSingleton(settings);
|
|||||||
builder.Services.AddSingleton<TunnelHub>();
|
builder.Services.AddSingleton<TunnelHub>();
|
||||||
builder.Services.AddSingleton<EmbeddingCache>();
|
builder.Services.AddSingleton<EmbeddingCache>();
|
||||||
builder.Services.AddSingleton<ManagementStore>();
|
builder.Services.AddSingleton<ManagementStore>();
|
||||||
|
builder.Services.AddSingleton<AuthRateLimiter>();
|
||||||
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
||||||
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
||||||
|
|
||||||
@@ -101,6 +102,67 @@ app.Use(async (context, next) =>
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var rateLimiter = app.Services.GetRequiredService<AuthRateLimiter>();
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (HttpMethods.IsOptions(context.Request.Method))
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ip = AuthRateLimiter.GetClientIp(context.Request);
|
||||||
|
var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip);
|
||||||
|
|
||||||
|
if (!allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString();
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var seconds = (int)retryAfter!.Value.TotalSeconds;
|
||||||
|
string retryMessage;
|
||||||
|
if (seconds >= 3600)
|
||||||
|
{
|
||||||
|
var hours = seconds / 3600;
|
||||||
|
retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
else if (seconds >= 60)
|
||||||
|
{
|
||||||
|
var minutes = seconds / 60;
|
||||||
|
retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.OnStarting(() =>
|
||||||
|
{
|
||||||
|
if (context.Response.StatusCode is StatusCodes.Status401Unauthorized)
|
||||||
|
{
|
||||||
|
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||||
|
}
|
||||||
|
else if (context.Response.StatusCode is StatusCodes.Status302Found
|
||||||
|
&& context.Request.Path.StartsWithSegments("/api/admin"))
|
||||||
|
{
|
||||||
|
var location = context.Response.Headers.Location.FirstOrDefault();
|
||||||
|
if (location is not null
|
||||||
|
&& location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseWebSockets(new WebSocketOptions
|
app.UseWebSockets(new WebSocketOptions
|
||||||
{
|
{
|
||||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||||
|
|||||||
Reference in New Issue
Block a user