feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]
This commit is contained in:
1 parent
1379b8c7cb
commit
ff45ad086e
27 files changed
+1305
-58
No files matched your search
+286
-1
@@ -1625,6 +1625,208 @@
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/elevation": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "The elevation windows this session has open (sensitive actions need one)",
|
||||
"operationId": "auth_elevation_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfElevationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)",
|
||||
"operationId": "auth_elevate",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationAssertionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "Close all elevation windows of this session",
|
||||
"operationId": "auth_elevation_end",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/elevation/options": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-auth"
|
||||
],
|
||||
"summary": "A challenge for opening an elevation window with a passkey touch",
|
||||
"operationId": "auth_elevation_options",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ElevationRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyRequestOptionsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "authenticated"
|
||||
}
|
||||
},
|
||||
"/api/admin/auth/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4025,7 +4227,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "accounts.manage"
|
||||
"x-moshiro-capability": "accounts.read"
|
||||
},
|
||||
"post": {
|
||||
"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": {
|
||||
"required": [
|
||||
"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": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -7688,6 +7964,15 @@
|
||||
"string"
|
||||
],
|
||||
"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 Forbidden = new("forbidden");
|
||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||
public static readonly ErrorCode ElevationRequired = new("elevation_required");
|
||||
public static readonly ErrorCode NotFound = new("not_found");
|
||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||
|
||||
@@ -51,6 +51,22 @@ 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)
|
||||
@@ -163,8 +179,20 @@ 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))
|
||||
{
|
||||
@@ -177,6 +205,11 @@ 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)
|
||||
@@ -184,13 +217,91 @@ public static class AuthEndpoints
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) =>
|
||||
http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||
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 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);
|
||||
if (bearer is not null)
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class SystemEndpoints
|
||||
|
||||
group.MapGet("/users", ListUsersAsync)
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.AccountsManage);
|
||||
.Requires(Capability.AccountsRead);
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage);
|
||||
@@ -64,6 +64,11 @@ 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))
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||
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));
|
||||
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>
|
||||
public static class PublicGuard
|
||||
{
|
||||
|
||||
@@ -156,6 +156,8 @@ public static class Mappers
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
|
||||
public static 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 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(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(
|
||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
|
||||
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(ElevationRequest))]
|
||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||
[JsonSerializable(typeof(ElevationDto))]
|
||||
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
|
||||
@@ -37,6 +37,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<ElevationService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
|
||||
@@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabili
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
|
||||
public string AuthMethod { get; init; } = "";
|
||||
|
||||
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
||||
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);
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,7 @@ public static class Capability
|
||||
public const string KeysRead = "keys.read";
|
||||
public const string KeysManage = "keys.manage";
|
||||
|
||||
public const string AccountsRead = "accounts.read";
|
||||
public const string AccountsManage = "accounts.manage";
|
||||
|
||||
public const string AuditRead = "audit.read";
|
||||
@@ -78,7 +79,8 @@ public static class CapabilityCatalog
|
||||
new(Capability.KeysRead, "keys"),
|
||||
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.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)];
|
||||
}
|
||||
@@ -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 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);
|
||||
|
||||
@@ -33,16 +33,23 @@ public sealed class PasskeyService
|
||||
|
||||
private readonly IAuthStore _store;
|
||||
private readonly AuthService _auth;
|
||||
private readonly ElevationService _elevation;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
||||
|
||||
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;
|
||||
_auth = auth;
|
||||
_elevation = elevation;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
@@ -59,7 +66,7 @@ public sealed class PasskeyService
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -82,7 +89,7 @@ public sealed class PasskeyService
|
||||
}
|
||||
|
||||
// 挑战在 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");
|
||||
}
|
||||
@@ -100,43 +107,82 @@ public sealed class PasskeyService
|
||||
: 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>
|
||||
public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
Result<LoginResult> denied = Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature))
|
||||
PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct);
|
||||
return passkey is null
|
||||
? Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
|
||||
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
||||
}
|
||||
|
||||
/// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
|
||||
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.SessionId is null)
|
||||
{
|
||||
return denied;
|
||||
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
||||
}
|
||||
|
||||
if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge))
|
||||
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
|
||||
if (own.Count == 0)
|
||||
{
|
||||
return denied;
|
||||
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);
|
||||
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 时不检查
|
||||
if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount)
|
||||
{
|
||||
return denied;
|
||||
return null;
|
||||
}
|
||||
|
||||
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<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);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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 = [];
|
||||
string? text;
|
||||
@@ -185,7 +231,7 @@ public sealed class PasskeyService
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ public sealed class BackendOptions
|
||||
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||
public string? WebAuthnOrigins { get; set; }
|
||||
|
||||
/// <summary>提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。</summary>
|
||||
public int ElevationEnrollMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>吊销和删除类的分钟数。</summary>
|
||||
public int ElevationRevokeMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>改凭证类(用一次就没有)的秒数。</summary>
|
||||
public int ElevationCredentialSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||
public bool SecureCookies { get; set; } = true;
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ public sealed class ApiBasicsTests
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
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);
|
||||
using HttpResponseMessage created = await client.SendAsync(create);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
@@ -104,19 +104,19 @@ public sealed class ApiBasicsTests
|
||||
using HttpResponseMessage me = await client.SendAsync(get);
|
||||
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);
|
||||
using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
|
||||
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("X-CSRF-Token", csrf + "x");
|
||||
using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf);
|
||||
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("X-CSRF-Token", csrf);
|
||||
using HttpResponseMessage created = await client.SendAsync(good);
|
||||
|
||||
@@ -242,10 +242,36 @@ public class BackendFixture : IAsyncLifetime
|
||||
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
|
||||
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() }) };
|
||||
request.Headers.Add("Cookie", cookie);
|
||||
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue<string>());
|
||||
using HttpResponseMessage created = await session.SendAsync(request);
|
||||
// 带信任类能力的 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("X-CSRF-Token", csrf);
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
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 async Task<string> RegistrationChallengeAsync(HttpClient session)
|
||||
@@ -145,7 +166,7 @@ public sealed class PasskeyTests
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
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())));
|
||||
JsonNode body = await JsonOf(login);
|
||||
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.Equal("no-store", login.Headers.CacheControl?.ToString());
|
||||
|
||||
@@ -162,14 +183,14 @@ public sealed class PasskeyTests
|
||||
using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress };
|
||||
signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue<string>());
|
||||
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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战
|
||||
@@ -194,7 +215,7 @@ public sealed class PasskeyTests
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
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.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);
|
||||
async Task<HttpStatusCode> TryAsync(JsonObject body)
|
||||
{
|
||||
@@ -230,24 +251,30 @@ public sealed class PasskeyTests
|
||||
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
|
||||
string challenge = await RegistrationChallengeAsync(session);
|
||||
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
|
||||
// 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有)
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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");
|
||||
|
||||
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" }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
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.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰
|
||||
await ElevateAsync(admin, passkey);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@
|
||||
## 12. 实施顺序(每步可独立验证和推送)
|
||||
|
||||
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` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
|
||||
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
||||
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
||||
|
||||
+2
-13
@@ -7,6 +7,7 @@ import { conditionalSupported, passkeySupported } from '@shared/api/webauthn'
|
||||
import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||||
import { useAuth } from './auth'
|
||||
import { BackendUpdateNotice } from './BackendUpdateNotice'
|
||||
import { ElevationBar } from './ElevationBar'
|
||||
import { PasskeyNotice } from './PasskeyNotice'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
import { Agents } from './pages/Agents'
|
||||
@@ -81,8 +82,6 @@ function Login() {
|
||||
}
|
||||
}
|
||||
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 usePasskey = async () => {
|
||||
setBusy(true)
|
||||
@@ -110,7 +109,6 @@ function Login() {
|
||||
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
|
||||
void conditionalSupported().then(async (ok) => {
|
||||
if (abort.signal.aborted) return
|
||||
setAutofill(ok ? 'yes' : 'no')
|
||||
if (!ok) return
|
||||
timer = setTimeout(() => {
|
||||
expired = true
|
||||
@@ -194,7 +192,6 @@ function Login() {
|
||||
) : null}
|
||||
{usingPasskey ? (
|
||||
<>
|
||||
<p className="muted">用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。</p>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus>
|
||||
<KeyRound size={16} />
|
||||
@@ -214,15 +211,6 @@ function Login() {
|
||||
<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} />
|
||||
</Field>
|
||||
{canPasskey ? (
|
||||
<p className="faint" style={{ margin: '-6px 0 0', fontSize: 12 }}>
|
||||
{autofill === 'yes'
|
||||
? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。'
|
||||
: autofill === 'no'
|
||||
? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。'
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
<Field label="密码">
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
@@ -273,6 +261,7 @@ export function App() {
|
||||
</div>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<ElevationBar />
|
||||
<SourceButton variant="status" />
|
||||
<div className="me">
|
||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,29 @@ export function withQuery(path: string, query?: Query): string {
|
||||
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> {
|
||||
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() }
|
||||
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
|
||||
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
|
||||
@@ -448,6 +448,42 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1370,6 +1406,21 @@ export interface components {
|
||||
first: 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: {
|
||||
/** Format: int64 */
|
||||
id: number | string;
|
||||
@@ -1668,6 +1719,10 @@ export interface components {
|
||||
items: components["schemas"]["AuditDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfElevationDto: {
|
||||
items: components["schemas"]["ElevationDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfEventDto: {
|
||||
items: components["schemas"]["EventDto"][];
|
||||
next_cursor: null | string;
|
||||
@@ -1779,6 +1834,7 @@ export interface components {
|
||||
rp_id: string;
|
||||
/** Format: int32 */
|
||||
timeout_ms: number | string;
|
||||
allow_credentials?: null | string[];
|
||||
};
|
||||
PatchProbeRequest: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ApiTokenDto = Narrow<S['ApiTokenDto']>
|
||||
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
||||
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
||||
export type PasskeyDto = Narrow<S['PasskeyDto']>
|
||||
export type ElevationDto = Narrow<S['ElevationDto']>
|
||||
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
||||
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
|
||||
export type MeDto = Narrow<S['MeDto']>
|
||||
|
||||
@@ -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)。 */
|
||||
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
|
||||
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')
|
||||
|
||||
Reference in new issue
Block a user