Files
Campus-25/static/Servers/Campus/api/server/LoginGuard.cs
T
2026-10-14 06:59:51 +02:00

113 lines
4.7 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Brute-force lockout for the credential endpoints: after ConfigCampus.LoginFailBlockThreshold
/// FAILED logins from one IP inside LoginFailWindowSeconds, that IP is locked out of the login
/// endpoint for LoginFailBlockMinutes. Unlike the TrafficGuard rate limiter (which counts EVERY
/// auth request), this only counts FAILURES - a legit client logging in successfully never trips
/// it, and one success clears the counter.
///
/// The lockout is enforced in TrafficGuardMiddleware (before the controller) and the failures are
/// recorded by AuthController on a bad password. Keyed on the trustworthy LAST X-Forwarded-For IP
/// (Caddy-appended; a client-sent XFF can't rotate it). Loopback is exempt.
/// </summary>
public static class LoginGuard
{
private sealed class State
{
public int Count;
public long WindowStart; // unix seconds
public long LockedUntil; // unix seconds, 0 = not locked
}
private static readonly ConcurrentDictionary<string, State> _byIp = new();
private static long _lastSweep;
public static bool IsLockedOut(string ip, out int retryAfterSeconds)
{
retryAfterSeconds = 0;
if (!ConfigCampus.LoginFailBlockEnabled || string.IsNullOrWhiteSpace(ip) || IsLoopback(ip))
return false;
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return false;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_byIp.TryGetValue(ip, out var s) && s.LockedUntil > now)
{
retryAfterSeconds = (int)(s.LockedUntil - now);
return true;
}
return false;
}
/// <summary>Record a FAILED login. Trips the lockout once the threshold is reached.</summary>
public static void RecordFailure(string ip)
{
if (!ConfigCampus.LoginFailBlockEnabled || string.IsNullOrWhiteSpace(ip) || IsLoopback(ip))
return;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Sweep(now);
var s = _byIp.AddOrUpdate(ip,
_ => new State { Count = 1, WindowStart = now },
(_, cur) =>
{
// A fresh counting window starts when the old one elapsed AND we aren't mid-lockout.
if (cur.LockedUntil <= now && now - cur.WindowStart >= ConfigCampus.LoginFailWindowSeconds)
{
cur.Count = 1;
cur.WindowStart = now;
}
else
{
cur.Count++;
}
return cur;
});
if (s.LockedUntil <= now && s.Count >= ConfigCampus.LoginFailBlockThreshold)
{
s.LockedUntil = now + (long)ConfigCampus.LoginFailBlockMinutes * 60;
Console.WriteLine($"[loginguard] {ip} LOCKED OUT for {ConfigCampus.LoginFailBlockMinutes} min after {s.Count} failed logins");
}
}
/// <summary>A successful login clears that IP's failure counter and any lockout.</summary>
public static void RecordSuccess(string ip)
{
if (!string.IsNullOrWhiteSpace(ip)) _byIp.TryRemove(ip, out _);
}
/// <summary>Trustworthy client IP = the LAST X-Forwarded-For entry (Caddy-appended), else the socket.</summary>
public static string ClientIp(HttpContext ctx)
{
string xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrWhiteSpace(xff))
{
var parts = xff.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length > 0) return parts[parts.Length - 1];
}
return ctx.Connection.RemoteIpAddress?.ToString() ?? "";
}
private static bool IsLoopback(string ip)
{
if (ip == "::1" || ip.StartsWith("127.")) return true;
return IPAddress.TryParse(ip, out var parsed) && IPAddress.IsLoopback(parsed);
}
private static void Sweep(long now)
{
if (now - Interlocked.Read(ref _lastSweep) < 300) return;
Interlocked.Exchange(ref _lastSweep, now);
foreach (var kv in _byIp)
if (kv.Value.LockedUntil <= now && now - kv.Value.WindowStart > ConfigCampus.LoginFailWindowSeconds + 60)
_byIp.TryRemove(kv.Key, out _);
}
}
}