The versions page shows each node's update state with update buttons, API tokens get a release scope that can only register releases (used by CI), and the CI image and release builds now pass the tag, commit and build time.
173 lines
7.6 KiB
C#
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 switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => 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" or "release"))
|
|
{
|
|
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
|
|
}
|
|
|
|
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();
|
|
}
|