VEU.WathBody/3-app/2-cli/Server.WatchBody.Web/Services/AdminAuthService.cs

92 lines
3.4 KiB
C#

using System.Security.Cryptography;
namespace Server.WatchBody.Web.Services;
/// <summary>
/// Аутентификация владельца консоли (spec 01 m08): PBKDF2-проверка
/// Admin.PasswordHash → in-memory Bearer-сессии (TTL 8 ч; рестарт
/// сервера сбрасывает). 🛡 Токены/пароли не логируются и не
/// возвращаются; Admin.Login пуст — консоль отключена.
/// </summary>
public class AdminAuthService(string login, string passwordHash)
{
private static readonly TimeSpan Ttl = TimeSpan.FromHours(8);
private readonly Dictionary<string, DateTime> _sessions = new();
public bool Enabled => login.Length > 0 && passwordHash.StartsWith("pbkdf2$", StringComparison.Ordinal);
/// <summary>Вход → токен (base64url) или null.</summary>
public string? Login(string candidateLogin, string candidatePassword)
{
if (!Enabled ||
!FixedTimeEquals(candidateLogin, login) ||
!VerifyPassword(candidatePassword, passwordHash))
return null;
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
lock (_sessions)
{
_sessions[token] = DateTime.UtcNow.Add(Ttl);
}
return token;
}
public bool Validate(string? token)
{
if (token == null || token.Length == 0) return false;
lock (_sessions)
{
if (!_sessions.TryGetValue(token, out var expiresAt)) return false;
if (expiresAt < DateTime.UtcNow)
{
_sessions.Remove(token);
return false;
}
return true;
}
}
public void Logout(string token)
{
lock (_sessions) _sessions.Remove(token);
}
internal static bool VerifyPassword(string password, string stored)
{
// pbkdf2$100000$<salt_b64>$<hash_b64>
var parts = stored.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2" ||
!int.TryParse(parts[1], out var iterations) || iterations < 1)
return false;
byte[] salt, expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch (FormatException)
{
return false;
}
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
/// <summary>Хэш пароля в формате конфига (тесты; владельцу —
/// python-однострочник из spec 01).</summary>
public static string HashPassword(string password, byte[]? salt = null)
{
salt ??= RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, 32);
return $"pbkdf2$100000${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
private static bool FixedTimeEquals(string a, string b)
{
var bytesA = System.Text.Encoding.UTF8.GetBytes(a);
var bytesB = System.Text.Encoding.UTF8.GetBytes(b);
return bytesA.Length == bytesB.Length &&
CryptographicOperations.FixedTimeEquals(bytesA, bytesB);
}
}