92 lines
3.4 KiB
C#
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);
|
|
}
|
|
}
|