feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
- 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)
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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 _);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user