Files
MoShiro/backend/src/MoShiro.Backend.Application/Auth/SetupService.cs
T

100 lines
4.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Cryptography;
using Fleet.Core;
using Microsoft.Extensions.Logging;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
/// <para>
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的<b>认领码</b>:首次启动时随机生成并写进后端的日志
/// (<c>journalctl -u moshiro-backend</c> / <c>podman logs</c>),或者用 <c>MoShiro__ClaimCode</c> 自己指定。
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
/// </para>
/// </summary>
public sealed partial class SetupService : IDisposable
{
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 16;
private readonly AuthService _auth;
private readonly IAuthStore _store;
private readonly BackendOptions _options;
private readonly ILogger<SetupService> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _code;
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger<SetupService> log)
{
_auth = auth;
_store = store;
_options = options;
_log = log;
}
public void Dispose() => _gate.Dispose();
/// <summary>面板里还没有任何用户。</summary>
public async ValueTask<bool> NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
/// <summary>启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。</summary>
public async ValueTask AnnounceAsync(CancellationToken ct)
{
if (await NeedsSetupAsync(ct))
{
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
}
}
public async ValueTask<Result<LoginResult>> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
if (!await NeedsSetupAsync(ct))
{
return Result<LoginResult>.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
}
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
{
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
}
Result<UserRecord> created = await _auth.CreateUserAsync(username, password, ct);
if (!created.TryGet(out _, out FleetError? error))
{
return Result<LoginResult>.Fail(error);
}
_code = null; // 一次有效:第一个管理员创建完,这个入口就关了
return await _auth.LoginAsync(username, password, ct);
}
finally
{
_gate.Release();
}
}
private string Code() => _code ??= string.IsNullOrWhiteSpace(_options.ClaimCode) ? Generate() : _options.ClaimCode.Trim();
private static string Generate()
{
char[] chars = new char[CodeLength];
for (int i = 0; i < chars.Length; i++)
{
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
}
string raw = new(chars);
return string.Join('-', Enumerable.Range(0, CodeLength / 4).Select(g => raw.Substring(g * 4, 4)));
}
/// <summary>比较时不分大小写、忽略分隔符和空格。</summary>
private static string Normalize(string code) => new(code.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
[LoggerMessage(Level = LogLevel.Warning, Message = "First start: no administrator exists yet. Open {Address} and finish the setup there with this claim code: {Code}")]
private static partial void LogClaimCode(ILogger logger, string address, string code);
}