using System.Security.Cryptography;
using Fleet.Core;
using Microsoft.Extensions.Logging;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
///
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
///
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的认领码:首次启动时随机生成并写进后端的日志
/// (journalctl -u moshiro-backend / podman logs),或者用 MoShiro__ClaimCode 自己指定。
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
///
///
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 _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _code;
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger log)
{
_auth = auth;
_store = store;
_options = options;
_log = log;
}
public void Dispose() => _gate.Dispose();
/// 面板里还没有任何用户。
public async ValueTask NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
/// 启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。
public async ValueTask AnnounceAsync(CancellationToken ct)
{
if (await NeedsSetupAsync(ct))
{
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
}
}
public async ValueTask> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
if (!await NeedsSetupAsync(ct))
{
return Result.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
}
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
{
return Result.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
}
Result created = await _auth.CreateUserAsync(username, password, ct);
if (!created.TryGet(out _, out FleetError? error))
{
return Result.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)));
}
/// 比较时不分大小写、忽略分隔符和空格。
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);
}