Files
Radicade/Server/AccountCreation/RegistrationGuard.cs
T
2026-10-16 23:33:13 -07:00

132 lines
3.8 KiB
C#

using System.Collections.Concurrent;
namespace RadicadeServer.Utils
{
public static class RegistrationGuard
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private static int maxCreationsPerIp = 20;
private static int maxCreationsPerDevice = 10;
private static TimeSpan minimumDeviceInterval = TimeSpan.FromSeconds(10);
public static int MaxCreationsPerIp => maxCreationsPerIp;
public static int MaxCreationsPerDevice => maxCreationsPerDevice;
public static TimeSpan MinimumDeviceInterval => minimumDeviceInterval;
public static void Configure(int maxCreationsPerIp, int maxCreationsPerDevice, TimeSpan minimumDeviceInterval)
{
RegistrationGuard.maxCreationsPerIp = maxCreationsPerIp;
RegistrationGuard.maxCreationsPerDevice = maxCreationsPerDevice;
RegistrationGuard.minimumDeviceInterval = minimumDeviceInterval;
Reset();
}
private sealed class Counter
{
public readonly List<DateTime> Events = [];
public DateTime LastCreation = DateTime.MinValue;
}
private static readonly ConcurrentDictionary<string, Counter> ByIp = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, Counter> ByDevice = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, SemaphoreSlim> IdentityGates = new(StringComparer.Ordinal);
public static string? Check(string? ip, string? deviceId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
lock (device)
{
device.Events.RemoveAll(evt => now - evt > Window);
if (now - device.LastCreation < MinimumDeviceInterval)
return "An account was just created on this device. Please wait a moment and try again.";
if (device.Events.Count >= MaxCreationsPerDevice)
return "Too many accounts have been created on this device. Please try again later.";
}
}
if (!string.IsNullOrWhiteSpace(ip))
{
var source = ByIp.GetOrAdd(ip, _ => new Counter());
lock (source)
{
source.Events.RemoveAll(evt => now - evt > Window);
if (source.Events.Count >= MaxCreationsPerIp)
return "Too many accounts have been created from this connection. Please try again later.";
}
}
return null;
}
public static void Record(string? ip, string? deviceId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
lock (device)
{
device.Events.RemoveAll(evt => now - evt > Window);
device.Events.Add(now);
device.LastCreation = now;
}
}
if (!string.IsNullOrWhiteSpace(ip))
{
var source = ByIp.GetOrAdd(ip, _ => new Counter());
lock (source)
{
source.Events.RemoveAll(evt => now - evt > Window);
source.Events.Add(now);
}
}
}
public static string IdentityKey(string platform, string? platformId, string? deviceId) =>
platform + "|" + (platformId?.Trim() ?? string.Empty) + "|" + (deviceId?.Trim() ?? string.Empty);
public static Task<IDisposable> LockNameAsync(string name) =>
LockIdentityAsync("name|" + UsernameRules.ComparisonKey(name));
public static async Task<IDisposable> LockIdentityAsync(string key)
{
var gate = IdentityGates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
return new Releaser(gate);
}
private sealed class Releaser(SemaphoreSlim gate) : IDisposable
{
private int released;
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0) gate.Release();
}
}
public static void Reset()
{
ByIp.Clear();
ByDevice.Clear();
IdentityGates.Clear();
}
internal static string Describe(string? ip, string? deviceId) =>
"ip=" + (ip ?? "<none>") + " device=" + (deviceId ?? "<none>");
}
}