Newer
Older
Sunfish / src / Sunfish.Core / Auth / Authenticator.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 4 KB 3.0
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;
    }
}