using System.Collections.Concurrent; namespace Nexus.Api.RateLimiting; /// /// 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. /// public sealed class LoginAttemptTracker { private const int MaxAttempts = 5; private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); // IP → (count, windowStartTicks) private static readonly ConcurrentDictionary _store = new(); /// /// Registers a failed attempt for the given IP. /// Returns remaining attempts (0 = locked out until reset). /// 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); } /// /// Returns the remaining attempts for the given IP without recording. /// 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; } /// /// Returns the number of seconds until the rate-limit window resets, /// or 0 if the window has already expired / no attempts recorded. /// 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); } /// /// Resets attempt count for the given IP (e.g. on success). /// public void Reset(string ip) { _store.TryRemove(ip, out _); } }