refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,32 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public enum PrincipalKind
|
||||
{
|
||||
Anonymous,
|
||||
Session,
|
||||
Token,
|
||||
}
|
||||
|
||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
|
||||
public enum ApiScope
|
||||
{
|
||||
None = 0,
|
||||
Release = 1,
|
||||
Read = 2,
|
||||
Admin = 3,
|
||||
}
|
||||
|
||||
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
|
||||
|
||||
public bool CanRead => Scope >= ApiScope.Read;
|
||||
|
||||
public bool IsAdmin => Scope >= ApiScope.Admin;
|
||||
|
||||
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
|
||||
}
|
||||
|
||||
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
||||
|
||||
public sealed record CreatedApiToken(ApiTokenRecord Record, string Plaintext);
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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(MoShiroErrors.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(MoShiroErrors.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();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>登录限流:每个来源在滑动的一分钟内最多尝试若干次。</summary>
|
||||
public sealed class LoginRateLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Queue<long>> _attempts = new();
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public LoginRateLimiter(BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。</summary>
|
||||
public bool TryAcquire(string source, out int retryAfterSeconds)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
Queue<long> queue = _attempts.GetOrAdd(source, _ => new Queue<long>());
|
||||
lock (queue)
|
||||
{
|
||||
while (queue.Count > 0 && now - queue.Peek() >= 60_000)
|
||||
{
|
||||
queue.Dequeue();
|
||||
}
|
||||
|
||||
if (queue.Count >= _options.LoginAttemptsPerMinute)
|
||||
{
|
||||
retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000);
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.Enqueue(now);
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>清理很久没有尝试的来源,避免字典无限增长。</summary>
|
||||
public void Prune()
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach ((string key, Queue<long> queue) in _attempts)
|
||||
{
|
||||
lock (queue)
|
||||
{
|
||||
if (queue.Count == 0 || now - queue.Peek() >= 60_000)
|
||||
{
|
||||
_attempts.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user