100 lines
4.0 KiB
C#
100 lines
4.0 KiB
C#
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);
|
||
}
|