using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using Fleet.Core; using Monitor.Protocol; namespace Monitor.Backend.Application; /// 面板用户、Cookie 会话、API 令牌。密码用 PBKDF2-SHA256,令牌和会话只保存 SHA-256。 public sealed partial class AuthService { private const int MinPasswordLength = 8; private const int MaxPasswordLength = 256; private const long TokenTouchIntervalMs = 60_000; private readonly IAuthStore _store; private readonly BackendOptions _options; private readonly TimeProvider _time; private readonly byte[] _dummySalt = RandomNumberGenerator.GetBytes(16); public AuthService(IAuthStore store, BackendOptions options, TimeProvider time) { _store = store; _options = options; _time = time; } /// 登录失败一律返回 unauthorized,不区分“用户不存在”和“密码错误”。 public async ValueTask> LoginAsync(string username, string password, CancellationToken ct) { UserRecord? user = await _store.GetUserByNameAsync(username, ct); byte[] salt = user?.PasswordSalt ?? _dummySalt; int iterations = user?.Iterations ?? _options.Pbkdf2Iterations; byte[] hash = Hash(password, salt, iterations); if (user is null || !CryptographicOperations.FixedTimeEquals(hash, user.PasswordHash)) { return Result.Fail(MonitorErrors.Unauthorized, "invalid credentials"); } byte[] token = RandomNumberGenerator.GetBytes(32); byte[] csrf = RandomNumberGenerator.GetBytes(32); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long expires = now + (long)_options.SessionLifetime.TotalMilliseconds; await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct); await _store.DeleteExpiredSessionsAsync(now, ct); return Result.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username)); } public async ValueTask AuthenticateSessionAsync(string sessionToken, CancellationToken ct) { if (!Base64Url.TryDecode(sessionToken, out byte[] raw) || raw.Length != 32) { return null; } SessionRecord? session = await _store.GetSessionAsync(SHA256.HashData(raw), ct); if (session is null || session.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds()) { return null; } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) { ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct); if (token is null || token.RevokedAtMs is not null) { return null; } long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs) { await _store.TouchApiTokenAsync(token.Id, now, ct); } ApiScope scope = token.Scope == "admin" ? ApiScope.Admin : ApiScope.Read; return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id); } public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct) { if (Base64Url.TryDecode(sessionToken, out byte[] raw)) { await _store.DeleteSessionAsync(SHA256.HashData(raw), ct); } } public async ValueTask> CreateUserAsync(string username, string password, CancellationToken ct) { var errors = new Dictionary(); if (!UsernamePattern().IsMatch(username)) { errors["username"] = "must be 3-32 characters of letters, digits, '.', '_' or '-'"; } if (password.Length is < MinPasswordLength or > MaxPasswordLength) { errors["password"] = $"must be {MinPasswordLength}-{MaxPasswordLength} characters"; } if (errors.Count > 0) { return ValidationError.Fail(errors); } byte[] salt = RandomNumberGenerator.GetBytes(16); var user = new UserRecord(Ulid.NewPrefixed("usr", _time), username, Hash(password, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, _time.GetUtcNow().ToUnixTimeMilliseconds()); return await _store.CreateUserAsync(user, ct) ? Result.Ok(user) : Result.Fail(MonitorErrors.NameConflict, "username already exists"); } public async ValueTask> ChangePasswordAsync(string userId, string newPassword, CancellationToken ct) { if (newPassword.Length is < MinPasswordLength or > MaxPasswordLength) { return ValidationError.Fail("password", $"must be {MinPasswordLength}-{MaxPasswordLength} characters"); } byte[] salt = RandomNumberGenerator.GetBytes(16); await _store.UpdatePasswordAsync(userId, Hash(newPassword, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, ct); return Result.Ok(true); } public ValueTask> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct); public async ValueTask> CreateApiTokenAsync(string name, string scope, CancellationToken ct) { if (name.Length is 0 or > 64) { return ValidationError.Fail("name", "must be 1-64 characters"); } if (scope is not ("read" or "admin")) { return ValidationError.Fail("scope", "must be read or admin"); } string plaintext = TokenHash.NewToken("mon"); var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null); await _store.CreateApiTokenAsync(record, ct); return Result.Ok(new CreatedApiToken(record, plaintext)); } public ValueTask> ListApiTokensAsync(CancellationToken ct) => _store.ListApiTokensAsync(ct); public ValueTask RevokeApiTokenAsync(string id, CancellationToken ct) => _store.RevokeApiTokenAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); /// 首次启动时(没有任何用户)用配置里的凭据创建初始管理员。 public async ValueTask EnsureInitialAdminAsync(CancellationToken ct) { if (string.IsNullOrEmpty(_options.InitialAdminUser) || string.IsNullOrEmpty(_options.InitialAdminPassword) || await _store.CountUsersAsync(ct) > 0) { return false; } return (await CreateUserAsync(_options.InitialAdminUser, _options.InitialAdminPassword, ct)).IsSuccess; } public static bool FixedTimeEquals(string a, string b) => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); private static byte[] Hash(string password, byte[] salt, int iterations) => Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, 32); [GeneratedRegex("^[A-Za-z0-9._-]{3,32}$")] private static partial Regex UsernamePattern(); }