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:
justme committed 2026-10-08 04:57:28 +08:00
1 parent f704c66dd9
commit 4106f831dc
34 files changed
+191 -1364

No files matched your search

@@ -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))
{