feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI]
This commit is contained in:
1 parent
f704c66dd9
commit
4106f831dc
34 files changed
+191
-1364
No files matched your search
+39
-278
@@ -1625,208 +1625,6 @@
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/elevation": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "The elevation windows this session has open (sensitive actions need one)",
|
||||
"operationId": "auth_elevation_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfElevationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)",
|
||||
"operationId": "auth_elevate",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationAssertionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Close all elevation windows of this session",
|
||||
"operationId": "auth_elevation_end",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/elevation/options": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "A challenge for opening an elevation window with a passkey touch",
|
||||
"operationId": "auth_elevation_options",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyRequestOptionsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3932,7 +3730,7 @@
|
||||
"tags": [
|
||||
"admin-trust"
|
||||
],
|
||||
"summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)",
|
||||
"summary": "Store the next operator set, signed with a passkey (the first one sets the genesis)",
|
||||
"operationId": "trust_commit",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -6229,59 +6027,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ElevationAssertionRequest": {
|
||||
"required": [
|
||||
"class",
|
||||
"credential_id",
|
||||
"authenticator_data",
|
||||
"client_data_json",
|
||||
"signature"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"credential_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"authenticator_data": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_data_json": {
|
||||
"type": "string"
|
||||
},
|
||||
"signature": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ElevationDto": {
|
||||
"required": [
|
||||
"class",
|
||||
"expires_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ElevationRequest": {
|
||||
"required": [
|
||||
"class"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"class": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -7632,27 +7377,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfElevationDto": {
|
||||
"required": [
|
||||
"items",
|
||||
"next_cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ElevationDto"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfEventDto": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -8477,6 +8201,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProposedCredentialDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"created_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublishReleaseRequest": {
|
||||
"required": [
|
||||
"manifest",
|
||||
@@ -9272,7 +9015,10 @@
|
||||
"required": [
|
||||
"statement",
|
||||
"version",
|
||||
"challenge"
|
||||
"challenge",
|
||||
"added",
|
||||
"removed",
|
||||
"server_key_changed"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9289,6 +9035,21 @@
|
||||
},
|
||||
"challenge": {
|
||||
"type": "string"
|
||||
},
|
||||
"added": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProposedCredentialDto"
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"server_key_changed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ public static class MoShiroErrors
|
||||
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
||||
public static readonly ErrorCode Forbidden = new("forbidden");
|
||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||
public static readonly ErrorCode ElevationRequired = new("elevation_required");
|
||||
public static readonly ErrorCode NotFound = new("not_found");
|
||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||
|
||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary>
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
private const string Tag = "admin-audit";
|
||||
|
||||
@@ -51,22 +51,6 @@ public static class AuthEndpoints
|
||||
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/elevation", ListElevation)
|
||||
.Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag)
|
||||
.Returns<PageDto<ElevationDto>>().ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync)
|
||||
.Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag)
|
||||
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(400, 401, 403, 429)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapPost("/auth/elevation", ElevateAsync)
|
||||
.Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag)
|
||||
.Returns<ElevationDto>().ProducesProblems(400, 401, 403, 429)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapDelete("/auth/elevation", EndElevation)
|
||||
.Api("auth_elevation_end", "Close all elevation windows of this session", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401)
|
||||
@@ -179,20 +163,8 @@ public static class AuthEndpoints
|
||||
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||
}
|
||||
|
||||
/// <summary>已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。</summary>
|
||||
private static async Task<IResult?> CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume)
|
||||
{
|
||||
string? userId = http.GetPrincipal().UserId;
|
||||
return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
||||
{
|
||||
@@ -205,11 +177,6 @@ public static class AuthEndpoints
|
||||
|
||||
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
|
||||
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
||||
@@ -219,89 +186,13 @@ public static class AuthEndpoints
|
||||
|
||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
||||
{
|
||||
if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||
? TypedResults.NoContent()
|
||||
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
IReadOnlyList<ElevationWindow> open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : [];
|
||||
return TypedResults.Json(new PageDto<ElevationDto>([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute);
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||
}
|
||||
|
||||
Result<RequestOptions> result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out RequestOptions? o, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "elevation:");
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||
}
|
||||
|
||||
Principal principal = http.GetPrincipal();
|
||||
Result<ElevationWindow> result = await passkeys.ElevateAsync(
|
||||
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.SetDetail(Elevation.IdOf(elevation));
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto);
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation)
|
||||
{
|
||||
if (http.GetPrincipal().SessionId is { } session)
|
||||
{
|
||||
elevation.End(session);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation)
|
||||
{
|
||||
if (http.GetPrincipal().SessionId is { } own)
|
||||
{
|
||||
elevation.End(own);
|
||||
}
|
||||
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
|
||||
@@ -64,11 +64,6 @@ public static class SystemEndpoints
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次
|
||||
}
|
||||
|
||||
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||
|
||||
@@ -22,10 +22,9 @@ public static class TrustEndpoints
|
||||
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
guarded.MapPost("/trust", CommitAsync)
|
||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag)
|
||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
|
||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage)
|
||||
.ElevationExempt();
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||
@@ -60,12 +59,6 @@ public static class TrustEndpoints
|
||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||
{
|
||||
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||
// 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根
|
||||
if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||
{
|
||||
|
||||
@@ -18,11 +18,6 @@ public static class EndpointConventions
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
||||
/// </summary>
|
||||
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
@@ -60,9 +60,6 @@ public static class PrincipalResolver
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
||||
public sealed record ElevationExemptMarker;
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
@@ -92,12 +89,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
|
||||
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||
{
|
||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||
@@ -120,24 +111,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。</summary>
|
||||
public static class ElevationGate
|
||||
{
|
||||
/// <summary>没问题返回 null;否则 403 <c>elevation_required</c>,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。</summary>
|
||||
public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (principal.UserId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal.SessionId is { } session && http.RequestServices.GetRequiredService<ElevationService>().Check(session, elevation, consume)
|
||||
? null
|
||||
: Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
|
||||
@@ -156,9 +156,8 @@ 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), 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);
|
||||
|
||||
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
|
||||
|
||||
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(
|
||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
||||
|
||||
@@ -10,16 +10,10 @@ public sealed record LoginRequest(string Username, string Password, bool? Return
|
||||
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||
|
||||
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
|
||||
/// <param name="AllowCredentials">只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。</param>
|
||||
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
|
||||
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
||||
|
||||
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
|
||||
public sealed record ElevationRequest(string Class);
|
||||
|
||||
public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
||||
|
||||
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
|
||||
@@ -28,7 +22,7 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
@@ -83,7 +77,13 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>?
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
||||
|
||||
/// <param name="Added">相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。</param>
|
||||
/// <param name="Removed">相对当前生效的集合移除的 passkey 凭证 id。</param>
|
||||
/// <param name="ServerKeyChanged">服务器密钥有变化(换了新的)。</param>
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredentialDto> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||
|
||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
|
||||
@@ -81,10 +81,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(ElevationRequest))]
|
||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||
[JsonSerializable(typeof(ElevationDto))]
|
||||
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
|
||||
@@ -41,7 +41,6 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||
services.AddSingleton<TrustService>();
|
||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||
services.AddSingleton<ElevationService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
|
||||
@@ -12,10 +12,10 @@ public enum PrincipalKind
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
|
||||
public string AuthMethod { get; init; } = "";
|
||||
|
||||
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
|
||||
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed partial class AuthService
|
||||
}
|
||||
|
||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
|
||||
@@ -45,7 +45,7 @@ public static class Capability
|
||||
public const string WebhooksManage = "webhooks.manage";
|
||||
}
|
||||
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任或有破坏性的能力(吊销、账户),界面上特别标出,授权与其他能力一样靠 API key 或登录会话;真正由 passkey 签名保护的是操作员集合,那是节点自己验证的;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
||||
|
||||
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||
@@ -132,12 +132,10 @@ public static class CapabilityCatalog
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
|
||||
/// <summary>
|
||||
/// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>),
|
||||
/// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||
|
||||
/// <summary>
|
||||
/// 按登录方式给会话能力:passkey 登录的拿全部;<b>密码登录的什么能力都没有</b>,只能做「已登录」就能做的事——
|
||||
/// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。
|
||||
/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。
|
||||
/// </summary>
|
||||
public enum ElevationClass
|
||||
{
|
||||
Enroll,
|
||||
Revoke,
|
||||
Credential,
|
||||
}
|
||||
|
||||
public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs);
|
||||
|
||||
public static class Elevation
|
||||
{
|
||||
public static string IdOf(ElevationClass elevation) => elevation switch
|
||||
{
|
||||
ElevationClass.Enroll => "enroll",
|
||||
ElevationClass.Revoke => "revoke",
|
||||
_ => "credential",
|
||||
};
|
||||
|
||||
public static bool TryParse(string? id, out ElevationClass elevation)
|
||||
{
|
||||
(bool ok, elevation) = id switch
|
||||
{
|
||||
"enroll" => (true, ElevationClass.Enroll),
|
||||
"revoke" => (true, ElevationClass.Revoke),
|
||||
"credential" => (true, ElevationClass.Credential),
|
||||
_ => (false, default),
|
||||
};
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
||||
public static ElevationClass? For(string capability) => capability switch
|
||||
{
|
||||
Capability.FleetRevoke => ElevationClass.Revoke,
|
||||
Capability.AccountsManage => ElevationClass.Credential,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。
|
||||
/// </summary>
|
||||
public sealed class ElevationService
|
||||
{
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, Dictionary<ElevationClass, long>> _windows = [];
|
||||
|
||||
public ElevationService(BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch
|
||||
{
|
||||
ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes),
|
||||
ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes),
|
||||
_ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds),
|
||||
};
|
||||
|
||||
/// <summary>开窗口;已经有的窗口重新计时。返回到期时间。</summary>
|
||||
public long Grant(string sessionId, ElevationClass elevation)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds;
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(now);
|
||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own))
|
||||
{
|
||||
_windows[sessionId] = own = [];
|
||||
}
|
||||
|
||||
own[elevation] = expires;
|
||||
}
|
||||
|
||||
return expires;
|
||||
}
|
||||
|
||||
/// <summary>窗口是否有效。<paramref name="consume"/> 且是凭证类:同时用掉(一次有效)。</summary>
|
||||
public bool Check(string sessionId, ElevationClass elevation, bool consume)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own) || !own.TryGetValue(elevation, out long expires) || expires <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consume && elevation == ElevationClass.Credential)
|
||||
{
|
||||
own.Remove(elevation);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ElevationWindow> Active(string sessionId)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
lock (_gate)
|
||||
{
|
||||
return _windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own)
|
||||
? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>主动结束全部窗口(点「结束」或退出登录)。</summary>
|
||||
public void End(string sessionId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_windows.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void Prune(long now)
|
||||
{
|
||||
foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList())
|
||||
{
|
||||
_windows.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ public sealed class PasskeyService
|
||||
|
||||
private readonly IAuthStore _store;
|
||||
private readonly AuthService _auth;
|
||||
private readonly ElevationService _elevation;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
||||
@@ -43,13 +42,10 @@ public sealed class PasskeyService
|
||||
private const string RegisterPurpose = "register";
|
||||
private const string LoginPurpose = "login";
|
||||
|
||||
private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation);
|
||||
|
||||
public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time)
|
||||
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_auth = auth;
|
||||
_elevation = elevation;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
@@ -118,37 +114,6 @@ public sealed class PasskeyService
|
||||
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
||||
}
|
||||
|
||||
/// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
|
||||
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.SessionId is null)
|
||||
{
|
||||
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
||||
}
|
||||
|
||||
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
|
||||
if (own.Count == 0)
|
||||
{
|
||||
return Result<RequestOptions>.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)");
|
||||
}
|
||||
|
||||
return Result<RequestOptions>.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)]));
|
||||
}
|
||||
|
||||
/// <summary>验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。</summary>
|
||||
public async ValueTask<Result<ElevationWindow>> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId)
|
||||
{
|
||||
return Result<ElevationWindow>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
||||
}
|
||||
|
||||
PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct);
|
||||
return passkey is null || passkey.UserId != userId
|
||||
? Result<ElevationWindow>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
|
||||
: Result<ElevationWindow>.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation)));
|
||||
}
|
||||
|
||||
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
|
||||
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,10 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
||||
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
||||
public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredential> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||
|
||||
/// <summary>下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。</summary>
|
||||
public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs);
|
||||
|
||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||
@@ -72,7 +75,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
||||
[_server.PublicKeySpki]);
|
||||
string statement = OperatorTrust.Encode(next);
|
||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
|
||||
HashSet<string> before = [.. current?.Keys.Select(k => k.CredentialId) ?? []];
|
||||
HashSet<string> after = [.. next.Keys.Select(k => k.CredentialId)];
|
||||
ProposedCredential[] added = [.. passkeys.Where(p => !before.Contains(p.Id)).Select(p => new ProposedCredential(p.Id, p.Name, p.CreatedAtMs))];
|
||||
string[] removed = [.. before.Where(id => !after.Contains(id)).Order(StringComparer.Ordinal)];
|
||||
bool serverChanged = current is not null && !(current.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((next.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))), added, removed, serverChanged);
|
||||
}
|
||||
|
||||
private static bool Same(OperatorSet a, OperatorSet b) =>
|
||||
@@ -82,7 +90,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。
|
||||
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -32,15 +32,6 @@ public sealed class BackendOptions
|
||||
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||
public string? WebAuthnOrigins { get; set; }
|
||||
|
||||
/// <summary>提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。</summary>
|
||||
public int ElevationEnrollMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>吊销和删除类的分钟数。</summary>
|
||||
public int ElevationRevokeMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>改凭证类(用一次就没有)的秒数。</summary>
|
||||
public int ElevationCredentialSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||
public bool SecureCookies { get; set; } = true;
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
|
||||
/// <summary>
|
||||
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
||||
/// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
|
||||
/// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。
|
||||
/// </summary>
|
||||
private async Task<string> CreateAdminTokenAsync()
|
||||
{
|
||||
@@ -306,15 +306,6 @@ public class BackendFixture : IAsyncLifetime
|
||||
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
|
||||
{
|
||||
options.EnsureSuccessStatusCode();
|
||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
|
||||
elevated.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
||||
created.EnsureSuccessStatusCode();
|
||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ElevationTests
|
||||
{
|
||||
private const string Password = "correct horse battery";
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ElevationTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
private string RpId => _backend.BaseAddress.Host;
|
||||
|
||||
/// <summary>新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。</summary>
|
||||
private async Task<HttpClient> NewSessionAsync()
|
||||
{
|
||||
string name = _backend.UniqueName("elev");
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
|
||||
{
|
||||
(HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
|
||||
return (client, passkey);
|
||||
}
|
||||
|
||||
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
|
||||
{
|
||||
var passkey = new SoftwarePasskey(RpId, Origin);
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge)));
|
||||
Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync());
|
||||
return passkey;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls }));
|
||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
||||
JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!;
|
||||
Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue<string>())); // 浏览器只会让用户选自己的 passkey
|
||||
JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = cls;
|
||||
return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
}
|
||||
|
||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
|
||||
|
||||
/// <summary>需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。</summary>
|
||||
private static Task<HttpResponseMessage> Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
|
||||
using HttpResponseMessage denied = await Gated(session);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
||||
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
|
||||
|
||||
// 窗口只管它那一种
|
||||
using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
|
||||
{
|
||||
using HttpResponseMessage allowed = await Gated(_backend.Admin);
|
||||
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheWindowCanBeListed_AndEndedEarly()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||
|
||||
(await ElevateAsync(session, passkey, "revoke")).Dispose();
|
||||
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
|
||||
Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue<string>());
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
|
||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACredentialWindow_IsSingleUse()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
|
||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
||||
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
|
||||
{
|
||||
(HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
|
||||
(HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
|
||||
|
||||
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
|
||||
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
JsonObject foreign = theirPasskey.Assert(challenge);
|
||||
foreign["class"] = "enroll";
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode);
|
||||
|
||||
// 挑战是给 revoke 要的,提交成 enroll:不行
|
||||
using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode);
|
||||
|
||||
// 挑战只能用一次
|
||||
using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
JsonObject once = myPasskey.Assert(challenge2);
|
||||
once["class"] = "enroll";
|
||||
Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
||||
|
||||
// 另一个用户的会话没有因此提权
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow()
|
||||
{
|
||||
using HttpClient session = await NewSessionAsync();
|
||||
using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
|
||||
|
||||
using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权
|
||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰
|
||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
||||
using SoftwarePasskey added = await RegisterAsync(session, "second");
|
||||
|
||||
// 删除也要窗口
|
||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
|
||||
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
|
||||
using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListingUsers_NeedsOnlyTheReadCapability()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ public sealed class PasskeyTests
|
||||
|
||||
private string _user = "";
|
||||
|
||||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。</summary>
|
||||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。</summary>
|
||||
private async Task<HttpClient> FreshSessionAsync()
|
||||
{
|
||||
_user = _backend.UniqueName("pk");
|
||||
@@ -121,16 +121,6 @@ public sealed class PasskeyTests
|
||||
return await PasswordSessionAsync(_user, "correct horse battery");
|
||||
}
|
||||
|
||||
/// <summary>用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。</summary>
|
||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
||||
JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
|
||||
private async Task<string> RegistrationChallengeAsync(HttpClient session)
|
||||
@@ -251,12 +241,8 @@ public sealed class PasskeyTests
|
||||
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
|
||||
string challenge = await RegistrationChallengeAsync(session);
|
||||
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
|
||||
// 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有)
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
@@ -270,11 +256,9 @@ public sealed class PasskeyTests
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 别人的 passkey:看不见,也删不了
|
||||
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰
|
||||
await ElevateAsync(admin, passkey);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
@@ -43,11 +43,10 @@ public sealed class TrustTests
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
|
||||
// 操作员:有 passkey、用 passkey 登录的会话
|
||||
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
|
||||
|
||||
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
||||
await ElevateAsync(session, passkey);
|
||||
// 创世:用 passkey 对集合本身签名(节点据此验链)
|
||||
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
|
||||
Assert.False(state["initialized"]!.GetValue<bool>());
|
||||
string statement = state["proposal"]!["statement"]!.GetValue<string>();
|
||||
@@ -83,15 +82,6 @@ public sealed class TrustTests
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||
{
|
||||
@@ -235,9 +225,9 @@ public sealed class TrustTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
|
||||
public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey()
|
||||
{
|
||||
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey
|
||||
// 密码登录保留:日常管理(看列表、创建加入码)不需要 passkey;passkey 只用在节点自己会验证的事上(操作员集合的签名)
|
||||
string name = _backend.UniqueName("pwonly");
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
@@ -245,14 +235,14 @@ public sealed class TrustTests
|
||||
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
|
||||
Assert.Empty(me["capabilities"]!.AsArray());
|
||||
Assert.NotEmpty(me["capabilities"]!.AsArray());
|
||||
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Created, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
||||
|
||||
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||
// passkey 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
||||
using HttpClient strongScope = strong;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
@@ -260,6 +250,24 @@ public sealed class TrustTests
|
||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
|
||||
{
|
||||
// 往账号里塞一把 passkey 再等人签名是这个流程唯一的风险:提案要明确列出新增的凭证
|
||||
Operator op = await OperatorAsync();
|
||||
(HttpClient other, SoftwarePasskey added, _) = await _backend.CreateStrongSessionAsync("added");
|
||||
using HttpClient otherScope = other;
|
||||
using SoftwarePasskey addedScope = added;
|
||||
|
||||
JsonNode proposal = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!["proposal"]!;
|
||||
JsonArray list = proposal["added"]!.AsArray();
|
||||
Assert.Contains(list, a => a!["id"]!.GetValue<string>() == added.Id);
|
||||
Assert.All(list, a => Assert.False(string.IsNullOrWhiteSpace(a!["name"]!.GetValue<string>())));
|
||||
Assert.DoesNotContain(list, a => a!["id"]!.GetValue<string>() == op.Passkey.Id);
|
||||
Assert.Empty(proposal["removed"]!.AsArray());
|
||||
Assert.False(proposal["server_key_changed"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user