Files
MoShiro/backend/src/Monitor.Backend.Application/Auth/AuthService.cs
T
2026-10-04 21:46:39 +08:00

173 lines
7.6 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Fleet.Core;
using Monitor.Protocol;
namespace Monitor.Backend.Application;
/// <summary>面板用户、Cookie 会话、API 令牌。密码用 PBKDF2-SHA256,令牌和会话只保存 SHA-256。</summary>
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;
}
/// <summary>登录失败一律返回 unauthorized,不区分“用户不存在”和“密码错误”。</summary>
public async ValueTask<Result<LoginResult>> 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<LoginResult>.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<LoginResult>.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username));
}
public async ValueTask<Principal?> 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<Principal?> 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<Result<UserRecord>> CreateUserAsync(string username, string password, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
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<UserRecord>(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<UserRecord>.Ok(user)
: Result<UserRecord>.Fail(MonitorErrors.NameConflict, "username already exists");
}
public async ValueTask<Result<bool>> ChangePasswordAsync(string userId, string newPassword, CancellationToken ct)
{
if (newPassword.Length is < MinPasswordLength or > MaxPasswordLength)
{
return ValidationError.Fail<bool>("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<bool>.Ok(true);
}
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
{
if (name.Length is 0 or > 64)
{
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
}
if (scope is not ("read" or "admin"))
{
return ValidationError.Fail<CreatedApiToken>("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<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
}
public ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct) => _store.ListApiTokensAsync(ct);
public ValueTask<bool> RevokeApiTokenAsync(string id, CancellationToken ct) => _store.RevokeApiTokenAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
/// <summary>首次启动时(没有任何用户)用配置里的凭据创建初始管理员。</summary>
public async ValueTask<bool> 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();
}