feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
2ae63653c4
commit
f22fdf8b68
31 files changed
+799
-49
No files matched your search
+1
-1
@@ -49,6 +49,6 @@ cd backend
|
||||
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
||||
```
|
||||
|
||||
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
@@ -1187,6 +1187,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/setup": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Whether the panel has no users yet and still has to be claimed",
|
||||
"operationId": "setup_status",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetupStatusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)",
|
||||
"operationId": "setup_complete",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetupRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/passkey/options": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -8773,6 +8863,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetupRequest": {
|
||||
"required": [
|
||||
"claim_code",
|
||||
"username",
|
||||
"password",
|
||||
"return_session"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claim_code": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"return_session": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetupStatusDto": {
|
||||
"required": [
|
||||
"needs_setup"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"needs_setup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SignedStatementDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9260,6 +9396,15 @@
|
||||
"server_key_listed": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"operator_details": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeOwnerDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class AuthEndpoints
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
open.MapGet("/auth/setup", SetupStatusAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
|
||||
.Returns<SetupStatusDto>();
|
||||
open.MapPost("/auth/setup", SetupAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
|
||||
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
|
||||
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
||||
@@ -77,6 +85,32 @@ public static class AuthEndpoints
|
||||
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
|
||||
{
|
||||
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "setup:");
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
string remote = http.ClientAddress();
|
||||
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
|
||||
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
|
||||
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
||||
{
|
||||
|
||||
@@ -156,7 +156,7 @@ public static class Mappers
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
|
||||
|
||||
|
||||
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
||||
|
||||
@@ -75,6 +75,12 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
|
||||
public sealed record SetupStatusDto(bool NeedsSetup);
|
||||
|
||||
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
|
||||
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
|
||||
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
@@ -89,9 +95,10 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
|
||||
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(SetupStatusDto))]
|
||||
[JsonSerializable(typeof(SetupRequest))]
|
||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<SetupService>();
|
||||
services.AddSingleton<BinaryBindingService>();
|
||||
services.AddSingleton<NodeOwnerService>();
|
||||
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs
|
||||
|
||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||
@@ -56,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
|
||||
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
|
||||
var details = new List<NodeOwner>();
|
||||
foreach (OperatorKey key in current?.Keys ?? [])
|
||||
{
|
||||
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
|
||||
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
|
||||
}
|
||||
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
|
||||
}
|
||||
|
||||
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||
|
||||
@@ -108,6 +108,12 @@ public sealed class BackendOptions
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
|
||||
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
|
||||
/// </summary>
|
||||
public string? ClaimCode { get; set; }
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
|
||||
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
||||
/// </summary>
|
||||
public sealed class StartupTasks : IHostedService
|
||||
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
|
||||
private readonly AuthService _auth;
|
||||
private readonly TaskService _tasks;
|
||||
private readonly ServerIdentityService _identity;
|
||||
private readonly SetupService _setup;
|
||||
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
|
||||
{
|
||||
_setup = setup;
|
||||
_identity = identity;
|
||||
_raw = raw;
|
||||
_auth = auth;
|
||||
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
|
||||
{
|
||||
await _raw.RecoverAsync(cancellationToken);
|
||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||
await _setup.AnnounceAsync(cancellationToken);
|
||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||
await _identity.EnsureAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -25,6 +26,9 @@ public class BackendFixture : IAsyncLifetime
|
||||
public const string AdminUser = "admin";
|
||||
public const string AdminPassword = "correct horse battery";
|
||||
|
||||
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
|
||||
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
|
||||
|
||||
private readonly List<AgentSimulator> _agents = [];
|
||||
private string _adminToken = "";
|
||||
private int _counter;
|
||||
@@ -256,8 +260,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.SecureCookies = false;
|
||||
options.WindowGrace = TimeSpan.Zero;
|
||||
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
|
||||
options.InitialAdminUser = AdminUser;
|
||||
options.InitialAdminPassword = AdminPassword;
|
||||
options.ClaimCode = ClaimCode;
|
||||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||||
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
@@ -282,8 +285,8 @@ public class BackendFixture : IAsyncLifetime
|
||||
{
|
||||
using HttpClient session = NewClient();
|
||||
using HttpResponseMessage login = await session.PostAsync(
|
||||
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
||||
login.EnsureSuccessStatusCode();
|
||||
"/api/admin/auth/setup", Json(new JsonObject { ["claim_code"] = ClaimCode, ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
||||
Assert.Equal(HttpStatusCode.Created, login.StatusCode);
|
||||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
|
||||
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class CapabilityTests
|
||||
}
|
||||
|
||||
// 只有登录(密码和 passkey)是匿名的
|
||||
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests
|
||||
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
|
||||
{
|
||||
JsonObject doc = await DocumentAsync();
|
||||
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options")))
|
||||
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
|
||||
{
|
||||
JsonObject responses = operation["responses"]!.AsObject();
|
||||
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。
|
||||
/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。
|
||||
/// </summary>
|
||||
public sealed class SetupTests : IAsyncLifetime
|
||||
{
|
||||
private const string Code = "ABCD-EFGH-JKLM-NPQR";
|
||||
|
||||
private WebApplication _app = null!;
|
||||
private HttpClient _http = null!;
|
||||
private string _directory = "";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||
_app = BackendHost.Build(
|
||||
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||
options =>
|
||||
{
|
||||
options.DataDirectory = _directory;
|
||||
options.Pbkdf2Iterations = 1000;
|
||||
options.SecureCookies = false;
|
||||
options.ClaimCode = Code;
|
||||
options.LoginAttemptsPerMinute = 1000;
|
||||
options.GeoLookupUrl = string.Empty;
|
||||
});
|
||||
await _app.StartAsync();
|
||||
_http = new HttpClient { BaseAddress = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SetupAsync(string? code, string username, string password, bool returnSession = false) =>
|
||||
_http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = code, ["username"] = username, ["password"] = password, ["return_session"] = returnSession }));
|
||||
|
||||
[Fact]
|
||||
public async Task ABrandNewPanel_HasNoUsers_AndNoDefaultAccountToSignInWith()
|
||||
{
|
||||
JsonNode status = JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!;
|
||||
Assert.True(status["needs_setup"]!.GetValue<bool>());
|
||||
|
||||
foreach (string username in new[] { "admin", "administrator", "root" })
|
||||
{
|
||||
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = username, ["password"] = "admin" }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheClaimCode_IsRequired_SoTheFirstVisitorCannotTakeThePanel()
|
||||
{
|
||||
using HttpResponseMessage none = await SetupAsync(null, "owner", "correct horse battery");
|
||||
using HttpResponseMessage wrong = await SetupAsync("AAAA-BBBB-CCCC-DDDD", "owner", "correct horse battery");
|
||||
using HttpResponseMessage empty = await SetupAsync("", "owner", "correct horse battery");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, empty.StatusCode);
|
||||
Assert.True(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>()); // 还是没人
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheCredentialsAreValidated_AndAMistakeDoesNotBurnTheCode()
|
||||
{
|
||||
using HttpResponseMessage shortPassword = await SetupAsync(Code, "owner", "short");
|
||||
using HttpResponseMessage badName = await SetupAsync(Code, "x", "correct horse battery");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, shortPassword.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badName.StatusCode);
|
||||
|
||||
// 填错了还能再来
|
||||
using HttpResponseMessage ok = await SetupAsync(Code, "owner", "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstAdministrator_IsCreatedByTheDeployer_SignedInRightAway_AndTheDoorCloses()
|
||||
{
|
||||
// 认领码不分大小写、不看分隔符
|
||||
using HttpResponseMessage created = await SetupAsync("abcd efgh-jklm npqr", "owner", "correct horse battery", returnSession: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
||||
Assert.Equal("owner", body["username"]!.GetValue<string>());
|
||||
Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue<string>())); // 马上登录,接着引导 passkey 和信任根
|
||||
|
||||
// 入口关了:再用同一个码、别的用户名也不行,状态也变了
|
||||
using HttpResponseMessage again = await SetupAsync(Code, "intruder", "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||
Assert.False(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||
|
||||
// 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,passkey 之后再加)
|
||||
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutAConfiguredCode_TheBackendGeneratesOne_AndNothingElseUnlocksTheSetup()
|
||||
{
|
||||
// 另起一个没有指定认领码的后端:随机生成并写进日志,猜不到;任何别的码都进不去
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||
await using WebApplication generated = BackendHost.Build(
|
||||
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||
options =>
|
||||
{
|
||||
options.DataDirectory = directory;
|
||||
options.Pbkdf2Iterations = 1000;
|
||||
options.SecureCookies = false;
|
||||
options.LoginAttemptsPerMinute = 1000;
|
||||
options.GeoLookupUrl = string.Empty;
|
||||
});
|
||||
await generated.StartAsync();
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { BaseAddress = new Uri(generated.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||
foreach (string guess in new[] { Code, "0000-0000-0000-0000", "admin", "TEST-CLAIM-CODE-0001" })
|
||||
{
|
||||
using HttpResponseMessage response = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = guess, ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.True(JsonNode.Parse(await http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await generated.StopAsync();
|
||||
await generated.DisposeAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,20 @@ public sealed class TrustTests
|
||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTrustState_SaysWhichAccountsPasskeyEachOperatorIs()
|
||||
{
|
||||
// 添加节点时选属主、信任页都要显示「哪个账户的哪把 passkey」,不只是一串凭证 id
|
||||
Operator op = await OperatorAsync();
|
||||
|
||||
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||
|
||||
JsonNode detail = state["operator_details"]!.AsArray().First(d => d!["credential_id"]!.GetValue<string>() == op.Passkey.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
|
||||
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
|
||||
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user