feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]

This commit is contained in:
justme committed 2026-10-07 22:29:24 +08:00
1 parent 1379b8c7cb
commit ff45ad086e
27 files changed
+1305 -58

No files matched your search

+286 -1
View File
@@ -1625,6 +1625,208 @@
"x-moshiro-capability": "authenticated" "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": { "/api/admin/auth/me": {
"get": { "get": {
"tags": [ "tags": [
@@ -4025,7 +4227,7 @@
} }
} }
}, },
"x-moshiro-capability": "accounts.manage" "x-moshiro-capability": "accounts.read"
}, },
"post": { "post": {
"tags": [ "tags": [
@@ -5803,6 +6005,59 @@
} }
} }
}, },
"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": { "EventDto": {
"required": [ "required": [
"id", "id",
@@ -7147,6 +7402,27 @@
} }
} }
}, },
"PageDtoOfElevationDto": {
"required": [
"items",
"next_cursor"
],
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ElevationDto"
}
},
"next_cursor": {
"type": [
"null",
"string"
]
}
}
},
"PageDtoOfEventDto": { "PageDtoOfEventDto": {
"required": [ "required": [
"items", "items",
@@ -7688,6 +7964,15 @@
"string" "string"
], ],
"format": "int32" "format": "int32"
},
"allow_credentials": {
"type": [
"null",
"array"
],
"items": {
"type": "string"
}
} }
} }
}, },
@@ -11,6 +11,7 @@ public static class MoShiroErrors
public static readonly ErrorCode CsrfFailed = new("csrf_failed"); public static readonly ErrorCode CsrfFailed = new("csrf_failed");
public static readonly ErrorCode Forbidden = new("forbidden"); public static readonly ErrorCode Forbidden = new("forbidden");
public static readonly ErrorCode SitePrivate = new("site_private"); 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 NotFound = new("not_found");
public static readonly ErrorCode AgentNotFound = new("agent_not_found"); public static readonly ErrorCode AgentNotFound = new("agent_not_found");
public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
@@ -51,6 +51,22 @@ public static class AuthEndpoints
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404) .Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
.Requires(Capability.Authenticated); .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) guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag) .Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401) .Returns<MeDto>().ProducesProblems(401)
@@ -163,8 +179,20 @@ public static class AuthEndpoints
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); 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) 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); Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out CreationOptions? o, out FleetError? error)) if (!result.TryGet(out CreationOptions? o, out FleetError? error))
{ {
@@ -177,6 +205,11 @@ public static class AuthEndpoints
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) 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( 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); 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) return result.TryGet(out PasskeyRecord? record, out FleetError? error)
@@ -184,13 +217,91 @@ public static class AuthEndpoints
: Problems.From(http, error); : Problems.From(http, error);
} }
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) => private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) {
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() ? TypedResults.NoContent()
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); : Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth) private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
{ {
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.Connection.RemoteIpAddress?.ToString(), 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); string? bearer = PrincipalResolver.BearerOf(http);
if (bearer is not null) if (bearer is not null)
{ {
@@ -43,7 +43,7 @@ public static class SystemEndpoints
group.MapGet("/users", ListUsersAsync) group.MapGet("/users", ListUsersAsync)
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403) .Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
.Requires(Capability.AccountsManage); .Requires(Capability.AccountsRead);
group.MapPost("/users", CreateUserAsync) group.MapPost("/users", CreateUserAsync)
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage); .Requires(Capability.AccountsManage);
@@ -64,6 +64,11 @@ public static class SystemEndpoints
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) 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 ?? [])}"); http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); 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)) if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
@@ -89,10 +89,21 @@ public static class CapabilityGuard
} }
} }
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && 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)) if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
{ {
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 // 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? ""; string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? "";
if (action.EndsWith("_options", StringComparison.Ordinal))
{
return await next(context); // 取挑战不是操作
}
string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value)); string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value));
http.Response.OnCompleted(() => http.Response.OnCompleted(() =>
{ {
@@ -106,6 +117,24 @@ 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> /// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard public static class PublicGuard
{ {
@@ -156,6 +156,8 @@ public static class Mappers
public static JoinTokenDto ToDto(JoinTokenInfo t) => 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); 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 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); 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);
public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams); Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized); Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid); Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
Add( Add(
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound); MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
@@ -9,7 +9,15 @@ 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); 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> /// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs); /// <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 RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
@@ -79,6 +79,10 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PasskeyRequestOptionsDto))] [JsonSerializable(typeof(PasskeyRequestOptionsDto))]
[JsonSerializable(typeof(RegisterPasskeyRequest))] [JsonSerializable(typeof(RegisterPasskeyRequest))]
[JsonSerializable(typeof(PasskeyLoginRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))]
[JsonSerializable(typeof(ElevationRequest))]
[JsonSerializable(typeof(ElevationAssertionRequest))]
[JsonSerializable(typeof(ElevationDto))]
[JsonSerializable(typeof(PageDto<ElevationDto>))]
[JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))] [JsonSerializable(typeof(PageDto<PasskeyDto>))]
[JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(CreatedApiTokenDto))]
@@ -37,6 +37,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AuthService>(); services.AddSingleton<AuthService>();
services.AddSingleton<PasskeyService>(); services.AddSingleton<PasskeyService>();
services.AddSingleton<AuditService>(); services.AddSingleton<AuditService>();
services.AddSingleton<ElevationService>();
services.AddSingleton<SettingsService>(); services.AddSingleton<SettingsService>();
services.AddSingleton<ReleaseService>(); services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>(); services.AddSingleton<ScheduleService>();
@@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabili
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary> /// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
public string AuthMethod { get; init; } = ""; public string AuthMethod { get; init; } = "";
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
public string? SessionId { get; init; }
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary> /// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous";
@@ -71,7 +71,7 @@ public sealed partial class AuthService
} }
UserRecord? user = await _store.GetUserAsync(session.UserId, ct); UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
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 }; 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) public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
@@ -32,6 +32,7 @@ public static class Capability
public const string KeysRead = "keys.read"; public const string KeysRead = "keys.read";
public const string KeysManage = "keys.manage"; public const string KeysManage = "keys.manage";
public const string AccountsRead = "accounts.read";
public const string AccountsManage = "accounts.manage"; public const string AccountsManage = "accounts.manage";
public const string AuditRead = "audit.read"; public const string AuditRead = "audit.read";
@@ -78,7 +79,8 @@ public static class CapabilityCatalog
new(Capability.KeysRead, "keys"), new(Capability.KeysRead, "keys"),
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
new(Capability.AccountsManage, "accounts", true), new(Capability.AccountsRead, "accounts"),
new(Capability.AccountsManage, "accounts", true, false, Capability.AccountsRead),
new(Capability.EventsRead, "ops"), new(Capability.EventsRead, "ops"),
new(Capability.AuditRead, "ops"), new(Capability.AuditRead, "ops"),
@@ -130,6 +132,6 @@ public static class CapabilityCatalog
} }
} }
/// <summary>登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。</summary> /// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)]; public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
} }
@@ -0,0 +1,136 @@
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.FleetEnroll => ElevationClass.Enroll,
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);
}
}
}
@@ -13,7 +13,7 @@ public sealed record PasskeyContext(string RpId, IReadOnlySet<string> Origins);
public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs); public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs); public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports); public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
@@ -33,16 +33,23 @@ public sealed class PasskeyService
private readonly IAuthStore _store; private readonly IAuthStore _store;
private readonly AuthService _auth; private readonly AuthService _auth;
private readonly ElevationService _elevation;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly Dictionary<string, PendingChallenge> _challenges = []; private readonly Dictionary<string, PendingChallenge> _challenges = [];
private sealed record PendingChallenge(bool Registration, string? UserId, long ExpiresAtMs); private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs);
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) 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)
{ {
_store = store; _store = store;
_auth = auth; _auth = auth;
_elevation = elevation;
_time = time; _time = time;
} }
@@ -59,7 +66,7 @@ public sealed class PasskeyService
return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user");
} }
string challenge = Issue(registration: true, userId); string challenge = Issue(RegisterPurpose, userId);
return Result<CreationOptions>.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); return Result<CreationOptions>.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000));
} }
@@ -82,7 +89,7 @@ public sealed class PasskeyService
} }
// 挑战在 clientDataJSON 里:先取出来,一次有效 // 挑战在 clientDataJSON 里:先取出来,一次有效
if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge)) if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge))
{ {
return Result<PasskeyRecord>.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); return Result<PasskeyRecord>.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used");
} }
@@ -100,43 +107,82 @@ public sealed class PasskeyService
: Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); : Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered");
} }
public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, null), context.RpId, 60_000); public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000);
/// <summary>验证断言并建立会话。失败一律 unauthorized,不区分原因。</summary> /// <summary>验证断言并建立会话。失败一律 unauthorized,不区分原因。</summary>
public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
{ {
Result<LoginResult> denied = Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct);
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) return passkey is null
{ ? Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
return denied; : await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
} }
if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge)) /// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
{ {
return denied; 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)
{
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature))
{
return null;
}
if (!TryTakeChallenge(clientData, purpose, userId, out byte[] challenge))
{
return null;
} }
PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct); PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct);
if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount)) if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount))
{ {
return denied; return null;
} }
// 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查
if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount)
{ {
return denied; return null;
} }
await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
return await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); return passkey;
} }
public ValueTask<IReadOnlyList<PasskeyRecord>> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); public ValueTask<IReadOnlyList<PasskeyRecord>> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct);
public ValueTask<bool> DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct); public ValueTask<bool> DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct);
private string Issue(bool registration, string? userId) private string Issue(string purpose, string? userId)
{ {
byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes);
string key = Base64Url.Encode(challenge); string key = Base64Url.Encode(challenge);
@@ -156,14 +202,14 @@ public sealed class PasskeyService
} }
} }
_challenges[key] = new PendingChallenge(registration, userId, now + (long)s_challengeLifetime.TotalMilliseconds); _challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds);
} }
return key; return key;
} }
/// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary> /// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary>
private bool TryTakeChallenge(byte[] clientDataJson, bool registration, string? userId, out byte[] challenge) private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge)
{ {
challenge = []; challenge = [];
string? text; string? text;
@@ -185,7 +231,7 @@ public sealed class PasskeyService
long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
lock (_gate) lock (_gate)
{ {
if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Registration != registration || pending.UserId != userId) if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId)
{ {
return false; return false;
} }
@@ -32,6 +32,15 @@ public sealed class BackendOptions
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary> /// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
public string? WebAuthnOrigins { get; set; } 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> /// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
public bool SecureCookies { get; set; } = true; public bool SecureCookies { get; set; } = true;
@@ -75,7 +75,7 @@ public sealed class ApiBasicsTests
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>()); Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage created = await client.SendAsync(create); using HttpResponseMessage created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.Equal(HttpStatusCode.Created, created.StatusCode);
@@ -104,19 +104,19 @@ public sealed class ApiBasicsTests
using HttpResponseMessage me = await client.SendAsync(get); using HttpResponseMessage me = await client.SendAsync(get);
Assert.Equal(HttpStatusCode.OK, me.StatusCode); Assert.Equal(HttpStatusCode.OK, me.StatusCode);
using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
withoutCsrf.Headers.Add("Cookie", cookie); withoutCsrf.Headers.Add("Cookie", cookie);
using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf); using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue<string>()); Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
wrongCsrf.Headers.Add("Cookie", cookie); wrongCsrf.Headers.Add("Cookie", cookie);
wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x"); wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x");
using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf); using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
good.Headers.Add("Cookie", cookie); good.Headers.Add("Cookie", cookie);
good.Headers.Add("X-CSRF-Token", csrf); good.Headers.Add("X-CSRF-Token", csrf);
using HttpResponseMessage created = await client.SendAsync(good); using HttpResponseMessage created = await client.SendAsync(good);
@@ -242,10 +242,36 @@ public class BackendFixture : IAsyncLifetime
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }) }; // 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建
string csrf = body["csrf_token"]!.GetValue<string>();
async Task<HttpResponseMessage> SendAsync(string path, JsonObject? content)
{
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) };
request.Headers.Add("Cookie", cookie); request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue<string>()); request.Headers.Add("X-CSRF-Token", csrf);
using HttpResponseMessage created = await session.SendAsync(request); return await session.SendAsync(request);
}
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null))
{
options.EnsureSuccessStatusCode();
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge));
registered.EnsureSuccessStatusCode();
}
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", 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 SendAsync("/api/admin/auth/elevation", assertion);
elevated.EnsureSuccessStatusCode();
}
using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() });
created.EnsureSuccessStatusCode(); created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>(); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
} }
@@ -0,0 +1,190 @@
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<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>();
private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" });
[Fact]
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode());
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll");
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰
// 窗口只管它那一种
using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody");
Assert.Equal("elevation_required", await ProblemCodeAsync(revoke));
}
[Fact]
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode());
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
[Fact]
public async Task TheWindowCanBeListed_AndEndedEarly()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
(await ElevateAsync(session, passkey, "enroll")).Dispose();
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
Assert.Equal("enroll", 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 session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
}
[Fact]
public async Task ACredentialWindow_IsSingleUse()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
(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()
{
using HttpClient mine = await NewSessionAsync();
using SoftwarePasskey myPasskey = await RegisterAsync(mine);
using HttpClient theirs = await NewSessionAsync();
using SoftwarePasskey theirPasskey = await RegisterAsync(theirs);
// 别人的 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 theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).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()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") };
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()
{
using HttpClient session = await NewSessionAsync();
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
}
}
@@ -110,6 +110,27 @@ public sealed class PasskeyTests
return client; return client;
} }
private string _user = "";
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。</summary>
private async Task<HttpClient> FreshSessionAsync()
{
_user = _backend.UniqueName("pk");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _user, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
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 static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
private async Task<string> RegistrationChallengeAsync(HttpClient session) private async Task<string> RegistrationChallengeAsync(HttpClient session)
@@ -145,7 +166,7 @@ public sealed class PasskeyTests
[Fact] [Fact]
public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade() public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade()
{ {
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session); using SoftwarePasskey passkey = await RegisterAsync(session);
JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys")); JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys"));
@@ -154,7 +175,7 @@ public sealed class PasskeyTests
using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync()))); using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync())));
JsonNode body = await JsonOf(login); JsonNode body = await JsonOf(login);
Assert.Equal(HttpStatusCode.OK, login.StatusCode); Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.Equal(BackendFixture.AdminUser, body["username"]!.GetValue<string>()); Assert.Equal(_user, body["username"]!.GetValue<string>());
Assert.NotNull(body["session_token"]); Assert.NotNull(body["session_token"]);
Assert.Equal("no-store", login.Headers.CacheControl?.ToString()); Assert.Equal("no-store", login.Headers.CacheControl?.ToString());
@@ -162,14 +183,14 @@ public sealed class PasskeyTests
using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress }; using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress };
signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue<string>()); signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue<string>());
JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me")); JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me"));
Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue<string>()); Assert.Equal(_user, me["username"]!.GetValue<string>());
Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue<string>() == "fleet.read"); Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue<string>() == "fleet.read");
} }
[Fact] [Fact]
public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused() public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused()
{ {
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session); using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战
@@ -194,7 +215,7 @@ public sealed class PasskeyTests
[Fact] [Fact]
public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne() public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne()
{ {
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session); using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5)));
@@ -211,7 +232,7 @@ public sealed class PasskeyTests
Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode);
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); using HttpClient session = await FreshSessionAsync();
using var passkey = new SoftwarePasskey(RpId, Origin); using var passkey = new SoftwarePasskey(RpId, Origin);
async Task<HttpStatusCode> TryAsync(JsonObject body) async Task<HttpStatusCode> TryAsync(JsonObject body)
{ {
@@ -230,24 +251,30 @@ public sealed class PasskeyTests
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
string challenge = await RegistrationChallengeAsync(session); string challenge = await RegistrationChallengeAsync(session);
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge))); 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))); // 挑战只能用一次 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)))); 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()))); Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
} }
[Fact] [Fact]
public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn() public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn()
{ {
using HttpClient admin = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); using HttpClient admin = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(admin, "phone"); using SoftwarePasskey passkey = await RegisterAsync(admin, "phone");
string other = _backend.UniqueName("user"); string other = _backend.UniqueName("user");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" })); 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); Assert.Equal(HttpStatusCode.Created, created.StatusCode);
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery"); using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id); 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.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync()))); Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
} }
+1 -1
View File
@@ -282,7 +282,7 @@
## 12. 实施顺序(每步可独立验证和推送) ## 12. 实施顺序(每步可独立验证和推送)
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。
2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**passkey 登录已实现**(见第 3.1 节);提权窗口、审计日志、归属列还没做。 2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。
3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
+2 -13
View File
@@ -7,6 +7,7 @@ import { conditionalSupported, passkeySupported } from '@shared/api/webauthn'
import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui' import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
import { useAuth } from './auth' import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice' import { BackendUpdateNotice } from './BackendUpdateNotice'
import { ElevationBar } from './ElevationBar'
import { PasskeyNotice } from './PasskeyNotice' import { PasskeyNotice } from './PasskeyNotice'
import { AgentDetail } from './pages/AgentDetail' import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents' import { Agents } from './pages/Agents'
@@ -81,8 +82,6 @@ function Login() {
} }
} }
const conditional = useRef<AbortController | null>(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求 const conditional = useRef<AbortController | null>(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求
// 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持
const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown')
const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环 const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环
const usePasskey = async () => { const usePasskey = async () => {
setBusy(true) setBusy(true)
@@ -110,7 +109,6 @@ function Login() {
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环) let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
void conditionalSupported().then(async (ok) => { void conditionalSupported().then(async (ok) => {
if (abort.signal.aborted) return if (abort.signal.aborted) return
setAutofill(ok ? 'yes' : 'no')
if (!ok) return if (!ok) return
timer = setTimeout(() => { timer = setTimeout(() => {
expired = true expired = true
@@ -194,7 +192,6 @@ function Login() {
) : null} ) : null}
{usingPasskey ? ( {usingPasskey ? (
<> <>
<p className="muted">用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。</p>
{error ? <div className="notice bad">{error}</div> : null} {error ? <div className="notice bad">{error}</div> : null}
<button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus> <button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus>
<KeyRound size={16} /> <KeyRound size={16} />
@@ -214,15 +211,6 @@ function Login() {
<Field label="用户名"> <Field label="用户名">
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} /> <input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} />
</Field> </Field>
{canPasskey ? (
<p className="faint" style={{ margin: '-6px 0 0', fontSize: 12 }}>
{autofill === 'yes'
? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。'
: autofill === 'no'
? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。'
: ''}
</p>
) : null}
<Field label="密码"> <Field label="密码">
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required /> <input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
</Field> </Field>
@@ -273,6 +261,7 @@ export function App() {
</div> </div>
))} ))}
<div className="spacer" /> <div className="spacer" />
<ElevationBar />
<SourceButton variant="status" /> <SourceButton variant="status" />
<div className="me"> <div className="me">
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span> <span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
+77
View File
@@ -0,0 +1,77 @@
import { ShieldCheck } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError, setElevationHandler } from '@shared/api/client'
import type { ElevationDto, Page } from '@shared/api/types'
import { ELEVATED_EVENT, elevate, passkeySupported, type ElevationClass } from '@shared/api/webauthn'
import { useAuth } from './auth'
const NAMES: Record<string, string> = { enroll: '添加节点', revoke: '吊销', credential: '改凭证' }
function remaining(until: string, now: number): string {
const seconds = Math.max(0, Math.round((Date.parse(until) - now) / 1000))
return seconds >= 60 ? `${Math.ceil(seconds / 60)} 分钟` : `${seconds} 秒`
}
/**
* 敏感操作要先触碰 passkey:这里注册全局的处理函数(API 返回 `elevation_required` 时自动弹出触碰,成功后重试原请求),
* 并在侧栏显示当前会话开着的提权窗口和剩余时间,可以提前结束。
*/
export function ElevationBar() {
const { me } = useAuth()
const [windows, setWindows] = useState<ElevationDto[]>([])
const [now, setNow] = useState(() => Date.now())
const signedIn = !!me?.user_id
const refresh = useCallback(() => {
if (!signedIn) return
api
.get<Page<ElevationDto>>('/api/admin/auth/elevation')
.then((page) => setWindows(page.items))
.catch(() => undefined)
}, [signedIn])
useEffect(() => {
if (!signedIn || !passkeySupported()) return undefined
setElevationHandler(async (kind) => {
try {
return await elevate(kind as ElevationClass)
} catch (e) {
if (e instanceof ApiError && e.status === 400) throw new Error('先添加一个 Passkey 才能做这个操作(系统 → 用户 → 我的 Passkey)')
throw e
}
})
refresh()
window.addEventListener(ELEVATED_EVENT, refresh)
const tick = setInterval(() => setNow(Date.now()), 1000)
return () => {
setElevationHandler(null)
window.removeEventListener(ELEVATED_EVENT, refresh)
clearInterval(tick)
}
}, [signedIn, refresh])
const open = windows.filter((w) => Date.parse(w.expires_at) > now)
if (open.length === 0) return null
return (
<div className="notice" style={{ margin: '0 0 8px', padding: '8px 10px' }}>
<div className="row" style={{ gap: 6 }}>
<ShieldCheck size={16} />
<b>已用 Passkey 确认</b>
<button
type="button"
className="btn small text right"
onClick={() => {
api.del('/api/admin/auth/elevation').then(() => setWindows([])).catch(() => undefined)
}}
>
结束
</button>
</div>
{open.map((w) => (
<div key={w.class} className="faint" style={{ fontSize: 12 }}>
{NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)}
</div>
))}
</div>
)
}
+22
View File
@@ -36,7 +36,29 @@ export function withQuery(path: string, query?: Query): string {
return text ? `${path}?${text}` : path return text ? `${path}?${text}` : path
} }
/**
* 敏感操作(加节点、吊销、改凭证)需要先触碰 passkey 开一个提权窗口:后端返回 403 `elevation_required`(detail 是窗口种类)时,
* 调用已注册的处理函数(弹出 passkey 触碰),成功后把原请求重试一次。
*/
let elevationHandler: ((elevation: string) => Promise<boolean>) | null = null
export function setElevationHandler(handler: ((elevation: string) => Promise<boolean>) | null) {
elevationHandler = handler
}
async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> { async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
try {
return await send<T>(method, path, body, init)
} catch (e) {
if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) {
if (await elevationHandler(e.detail)) return send<T>(method, path, body, init)
throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消')
}
throw e
}
}
async function send<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() } const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
+234
View File
@@ -448,6 +448,42 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/auth/elevation": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The elevation windows this session has open (sensitive actions need one) */
get: operations["auth_elevation_list"];
put?: never;
/** Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s) */
post: operations["auth_elevate"];
/** Close all elevation windows of this session */
delete: operations["auth_elevation_end"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/elevation/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** A challenge for opening an elevation window with a passkey touch */
post: operations["auth_elevation_options"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/me": { "/api/admin/auth/me": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1370,6 +1406,21 @@ export interface components {
first: boolean; first: boolean;
last: boolean; last: boolean;
}; };
ElevationAssertionRequest: {
class: string;
credential_id: string;
authenticator_data: string;
client_data_json: string;
signature: string;
};
ElevationDto: {
class: string;
/** Format: date-time */
expires_at: unknown;
};
ElevationRequest: {
class: string;
};
EventDto: { EventDto: {
/** Format: int64 */ /** Format: int64 */
id: number | string; id: number | string;
@@ -1668,6 +1719,10 @@ export interface components {
items: components["schemas"]["AuditDto"][]; items: components["schemas"]["AuditDto"][];
next_cursor: null | string; next_cursor: null | string;
}; };
PageDtoOfElevationDto: {
items: components["schemas"]["ElevationDto"][];
next_cursor: null | string;
};
PageDtoOfEventDto: { PageDtoOfEventDto: {
items: components["schemas"]["EventDto"][]; items: components["schemas"]["EventDto"][];
next_cursor: null | string; next_cursor: null | string;
@@ -1779,6 +1834,7 @@ export interface components {
rp_id: string; rp_id: string;
/** Format: int32 */ /** Format: int32 */
timeout_ms: number | string; timeout_ms: number | string;
allow_credentials?: null | string[];
}; };
PatchProbeRequest: { PatchProbeRequest: {
name: null | string; name: null | string;
@@ -3293,6 +3349,184 @@ export interface operations {
}; };
}; };
}; };
auth_elevation_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfElevationDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ElevationAssertionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ElevationDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevation_end: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevation_options: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ElevationRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PasskeyRequestOptionsDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_me: { auth_me: {
parameters: { parameters: {
query?: never; query?: never;
+1
View File
@@ -41,6 +41,7 @@ export type ApiTokenDto = Narrow<S['ApiTokenDto']>
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']> export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']> export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
export type PasskeyDto = Narrow<S['PasskeyDto']> export type PasskeyDto = Narrow<S['PasskeyDto']>
export type ElevationDto = Narrow<S['ElevationDto']>
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']> export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']> export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
export type MeDto = Narrow<S['MeDto']> export type MeDto = Narrow<S['MeDto']>
+39
View File
@@ -58,6 +58,45 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?:
}) })
} }
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
export const ELEVATED_EVENT = 'moshiro:elevated'
/**
* 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。
* 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。
*/
export async function elevate(elevation: ElevationClass): Promise<boolean> {
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/elevation/options', { class: elevation })
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey: {
challenge: toBytes(options.challenge),
rpId: options.rp_id,
timeout: options.timeout_ms,
userVerification: 'required',
allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
},
})) as PublicKeyCredential | null
} catch (e) {
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false
throw e
}
if (!credential) return false
const response = credential.response as AuthenticatorAssertionResponse
await api.post('/api/admin/auth/elevation', {
class: elevation,
credential_id: credential.id,
authenticator_data: toText(response.authenticatorData),
client_data_json: toText(response.clientDataJSON),
signature: toText(response.signature),
})
window.dispatchEvent(new Event(ELEVATED_EVENT))
return true
}
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ /** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> { export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options') const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')