using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Sunfish.Config;
namespace Sunfish.Auth;
public enum AuthResult { Ok, Missing, Wrong, Blocked }
/// <summary>
/// Comprueba usuarios y contrasenas (HTTP Basic, y mas adelante FTP).
/// HTTP Basic reenvia la contrasena en cada peticion: las ya verificadas se recuerdan un rato
/// (como HMAC con una clave de esta ejecucion, nunca en claro) para no recalcular PBKDF2.
/// Los fallos repetidos desde una IP la bloquean un tiempo.
/// </summary>
public sealed class Authenticator
{
public const int MaxFailures = 5;
public static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(5);
public static readonly TimeSpan BlockTime = TimeSpan.FromMinutes(1);
public static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10);
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTime> _verified = new();
private readonly ConcurrentDictionary<IPAddress, Failures> _failures = new();
private sealed class Failures
{
public int Count;
public DateTime First;
public DateTime BlockedUntil;
}
/// <summary>Reloj inyectable para las pruebas.</summary>
internal Func<DateTime> Now { get; init; } = () => DateTime.UtcNow;
public AuthResult Check(IEnumerable<UserConfig> users, IReadOnlyCollection<string> allowed,
string? userName, string? password, IPAddress? from)
{
if (from is not null && IsBlocked(from))
return AuthResult.Blocked;
if (userName is null || password is null)
return AuthResult.Missing;
var user = users.FirstOrDefault(u => string.Equals(u.Name, userName, StringComparison.OrdinalIgnoreCase));
bool ok = user is not null &&
allowed.Contains(user.Name, StringComparer.OrdinalIgnoreCase) &&
VerifyCached(user, password);
if (ok)
return AuthResult.Ok;
if (from is not null)
RegisterFailure(from);
return AuthResult.Wrong;
}
private bool VerifyCached(UserConfig user, string password)
{
string key = Convert.ToBase64String(HMACSHA256.HashData(_key,
Encoding.UTF8.GetBytes(user.Name + "\0" + password + "\0" + user.PasswordHash)));
var now = Now();
if (_verified.TryGetValue(key, out var until) && until > now)
return true;
if (!Passwords.Verify(password, user.PasswordHash))
return false;
_verified[key] = now + CacheTime;
if (_verified.Count > 1000)
foreach (var (k, v) in _verified)
if (v <= now)
_verified.TryRemove(k, out _);
return true;
}
private bool IsBlocked(IPAddress from) =>
_failures.TryGetValue(from, out var f) && f.BlockedUntil > Now();
private void RegisterFailure(IPAddress from)
{
var now = Now();
var f = _failures.GetOrAdd(from, _ => new Failures { First = now });
lock (f)
{
if (now - f.First > FailureWindow)
{
f.Count = 0;
f.First = now;
}
if (++f.Count >= MaxFailures)
{
f.BlockedUntil = now + BlockTime;
f.Count = 0;
f.First = now;
}
}
}
/// <summary>
/// "Basic dXNlcjpwYXNz" -> (usuario, contrasena). Los bytes se leen como UTF-8 y, si no lo
/// son, como Latin-1 (navegadores antiguos).
/// </summary>
public static bool TryParseBasic(string? header, out string user, out string password)
{
user = password = "";
if (header is null || !header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
return false;
byte[] bytes;
try
{
bytes = Convert.FromBase64String(header[6..].Trim());
}
catch (FormatException)
{
return false;
}
string text;
try
{
text = new UTF8Encoding(false, true).GetString(bytes);
}
catch (DecoderFallbackException)
{
text = Encoding.Latin1.GetString(bytes);
}
int colon = text.IndexOf(':');
if (colon < 0)
return false;
user = text[..colon];
password = text[(colon + 1)..];
return true;
}
}