83e072bc27
- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht - CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller - Bao-Content-Änderungen triggern task_content_changed-Notification an Iris - Bao-Status-Änderungen triggern task_status_changed-Notification an Iris - Iris-Status-Änderungen triggern task_status_changed-Notification an Bao - Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string) - Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview - Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter - Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body - Volume-Name: nexus-postgres → postgres-data (Standardisierung)
85 lines
2.5 KiB
C#
85 lines
2.5 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace Nexus.Api.RateLimiting;
|
|
|
|
/// <summary>
|
|
/// Simple in-memory tracking of login attempts per IP,
|
|
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
|
|
///
|
|
/// Provides remaining-attempt count that can be passed back to the frontend.
|
|
/// </summary>
|
|
public sealed class LoginAttemptTracker
|
|
{
|
|
private const int MaxAttempts = 5;
|
|
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
|
|
|
// IP → (count, windowStartTicks)
|
|
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
|
|
|
|
/// <summary>
|
|
/// Registers a failed attempt for the given IP.
|
|
/// Returns remaining attempts (0 = locked out until reset).
|
|
/// </summary>
|
|
public int RecordFailedAttempt(string ip)
|
|
{
|
|
var now = Environment.TickCount64;
|
|
var windowTicks = (long)Window.TotalMilliseconds;
|
|
|
|
var (count, windowStart) = _store.AddOrUpdate(ip,
|
|
_ => (1, now),
|
|
(_, entry) =>
|
|
{
|
|
if (now - entry.WindowStartTicks >= windowTicks)
|
|
return (1, now);
|
|
return (entry.Count + 1, entry.WindowStartTicks);
|
|
});
|
|
|
|
return Math.Max(0, MaxAttempts - count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the remaining attempts for the given IP without recording.
|
|
/// </summary>
|
|
public int GetRemaining(string ip)
|
|
{
|
|
var now = Environment.TickCount64;
|
|
var windowTicks = (long)Window.TotalMilliseconds;
|
|
|
|
if (_store.TryGetValue(ip, out var entry))
|
|
{
|
|
if (now - entry.WindowStartTicks >= windowTicks)
|
|
return MaxAttempts;
|
|
return Math.Max(0, MaxAttempts - entry.Count);
|
|
}
|
|
|
|
return MaxAttempts;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of seconds until the rate-limit window resets,
|
|
/// or 0 if the window has already expired / no attempts recorded.
|
|
/// </summary>
|
|
public int GetRetryAfterSeconds(string ip)
|
|
{
|
|
var now = Environment.TickCount64;
|
|
var windowTicks = (long)Window.TotalMilliseconds;
|
|
|
|
if (!_store.TryGetValue(ip, out var entry))
|
|
return 0;
|
|
|
|
var elapsed = now - entry.WindowStartTicks;
|
|
if (elapsed >= windowTicks)
|
|
return 0;
|
|
|
|
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets attempt count for the given IP (e.g. on success).
|
|
/// </summary>
|
|
public void Reset(string ip)
|
|
{
|
|
_store.TryRemove(ip, out _);
|
|
}
|
|
}
|