diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 5823ca4..5b9262f 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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" + } } } }, diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs index 91c7259..0cd40dc 100644 --- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs +++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs @@ -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"); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 39e6f8a..77920d3 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -51,6 +51,22 @@ public static class AuthEndpoints .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) .Returns(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>().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().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().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(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) .Returns().ProducesProblems(401) @@ -163,8 +179,20 @@ public static class AuthEndpoints return TypedResults.Json(new PageDto([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); } + /// 已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。 + private static async Task 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 PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options) { + if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed) + { + return needed; + } + Result 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 RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) { + if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed) + { + return needed; + } + Result 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 DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) => - http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) + private static async Task 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 LogoutAsync(HttpContext http, AuthService auth) + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult> ListElevation(HttpContext http, ElevationService elevation) { + IReadOnlyList open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : []; + return TypedResults.Json(new PageDto([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto); + } + + private static async Task 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 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 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 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 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) { diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 3c3b1dc..3597853 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -43,7 +43,7 @@ public static class SystemEndpoints group.MapGet("/users", ListUsersAsync) .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403) - .Requires(Capability.AccountsManage); + .Requires(Capability.AccountsRead); group.MapPost("/users", CreateUserAsync) .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Requires(Capability.AccountsManage); @@ -64,6 +64,11 @@ public static class SystemEndpoints private static async Task 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 result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 92ec7dc..14610d7 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -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()?.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 } } +/// 提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。 +public static class ElevationGate +{ + /// 没问题返回 null;否则 403 elevation_required,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。 + 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().Check(session, elevation, consume) + ? null + : Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation)); + } +} + /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index cf56e6c..d7e8afb 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Common/Problems.cs b/backend/src/MoShiro.Backend.Api/Common/Problems.cs index 87576e9..790d86d 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Problems.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Problems.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index a95568e..80fe143 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -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 ExcludeCredentials, int TimeoutMs); /// 浏览器 navigator.credentials.get 要的参数(可发现凭证:不需要先输用户名)。 -public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs); +/// 只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。 +public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); + +/// enroll(加节点)、revoke(吊销和删除)、credential(改凭证,用一次就没有)。 +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? Transports); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index ce5b95d..7238f19 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -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))] [JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index cfb3ad2..2ad27e3 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index c6a0620..73623a1 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabili /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 public string AuthMethod { get; init; } = ""; + /// 会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。 + public string? SessionId { get; init; } + /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index aa32c1d..458f717 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -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 AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index ec8590c..85784a8 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -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 } } - /// 登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。 + /// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。 public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)]; } diff --git a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs new file mode 100644 index 0000000..41a75f0 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs @@ -0,0 +1,136 @@ +namespace MoShiro.Backend.Application; + +/// +/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。 +/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。 +/// +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; + } + + /// 端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。 + public static ElevationClass? For(string capability) => capability switch + { + Capability.FleetEnroll => ElevationClass.Enroll, + Capability.FleetRevoke => ElevationClass.Revoke, + Capability.AccountsManage => ElevationClass.Credential, + _ => null, + }; +} + +/// +/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。 +/// +public sealed class ElevationService +{ + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary> _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), + }; + + /// 开窗口;已经有的窗口重新计时。返回到期时间。 + 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? own)) + { + _windows[sessionId] = own = []; + } + + own[elevation] = expires; + } + + return expires; + } + + /// 窗口是否有效。 且是凭证类:同时用掉(一次有效)。 + public bool Check(string sessionId, ElevationClass elevation, bool consume) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (!_windows.TryGetValue(sessionId, out Dictionary? own) || !own.TryGetValue(elevation, out long expires) || expires <= now) + { + return false; + } + + if (consume && elevation == ElevationClass.Credential) + { + own.Remove(elevation); + } + + return true; + } + } + + public IReadOnlyList Active(string sessionId) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + return _windows.TryGetValue(sessionId, out Dictionary? own) + ? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))] + : []; + } + } + + /// 主动结束全部窗口(点「结束」或退出登录)。 + 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); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs index b24b6b3..9dce41a 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -13,7 +13,7 @@ public sealed record PasskeyContext(string RpId, IReadOnlySet Origins); public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); -public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs); +public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? 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 _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("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); } - string challenge = Issue(registration: true, userId); + string challenge = Issue(RegisterPurpose, userId); return Result.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.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); } @@ -100,43 +107,82 @@ public sealed class PasskeyService : Result.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); /// 验证断言并建立会话。失败一律 unauthorized,不区分原因。 public async ValueTask> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) { - Result denied = Result.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.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + } + + /// 提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。 + public async ValueTask> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is null) { - return denied; + return Result.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 own = await _store.ListPasskeysAsync(userId, ct); + if (own.Count == 0) { - return denied; + return Result.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)"); + } + + return Result.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)])); + } + + /// 验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。 + public async ValueTask> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId) + { + return Result.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.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : Result.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation))); + } + + /// 验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。 + private async ValueTask 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> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); public ValueTask 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; } /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 - 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; } diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index d4c8c29..9dd255f 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -32,6 +32,15 @@ public sealed class BackendOptions /// 允许的来源(逗号分隔,如 https://panel.example.com);缺省是 的来源,没有就取请求自己的来源。 public string? WebAuthnOrigins { get; set; } + /// 提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。 + public int ElevationEnrollMinutes { get; set; } = 15; + + /// 吊销和删除类的分钟数。 + public int ElevationRevokeMinutes { get; set; } = 5; + + /// 改凭证类(用一次就没有)的秒数。 + public int ElevationCredentialSeconds { get; set; } = 60; + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 public bool SecureCookies { get; set; } = true; diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index 3f351ae..3f114fb 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -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()); - 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()); - 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); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index 8d9258c..f0d57e1 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -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()); - using HttpResponseMessage created = await session.SendAsync(request); + // 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建 + string csrf = body["csrf_token"]!.GetValue(); + async Task 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(); + 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()); + 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(); } diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs new file mode 100644 index 0000000..4f47c72 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。 +[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; + + /// 新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。 + private async Task 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()); + return client; + } + + private async Task 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(); + 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 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())); // 浏览器只会让用户选自己的 passkey + JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue()); + assertion["class"] = cls; + return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue(); + + 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()); + + 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(); + 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(); + 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); // 看列表不用提权 + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs index 2225029..5d20934 100644 --- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -110,6 +110,27 @@ public sealed class PasskeyTests return client; } + private string _user = ""; + + /// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。 + private async Task 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"); + } + + /// 用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。 + 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()); + 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 JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!; private async Task 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()); + Assert.Equal(_user, body["username"]!.GetValue()); 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()); JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me")); - Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue()); + Assert.Equal(_user, me["username"]!.GetValue()); Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue() == "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 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() == 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()))); } diff --git a/docs/access.md b/docs/access.md index b9f3fcb..33e660a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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 拉镜像)。 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index a0c1d1c..7bf430a 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -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(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 ? ( <> -

用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。

{error ?
{error}
: null} + + {open.map((w) => ( +
+ {NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)} +
+ ))} + + ) +} diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts index 04d459d..7d416a6 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -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) | null = null + +export function setElevationHandler(handler: ((elevation: string) => Promise) | null) { + elevationHandler = handler +} + async function request(method: string, path: string, body?: unknown, init?: RequestInit): Promise { + try { + return await send(method, path, body, init) + } catch (e) { + if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) { + if (await elevationHandler(e.detail)) return send(method, path, body, init) + throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消') + } + throw e + } +} + +async function send(method: string, path: string, body?: unknown, init?: RequestInit): Promise { const headers: Record = { 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 diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index f02fce8..4d0fcf7 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -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; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 8d1cf71..d1059b3 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -41,6 +41,7 @@ export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow export type PasskeyDto = Narrow +export type ElevationDto = Narrow export type PasskeyCreationOptionsDto = Narrow export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts index 2abf28e..551403e 100644 --- a/web/shared/api/webauthn.ts +++ b/web/shared/api/webauthn.ts @@ -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 { + const options = await api.post('/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 { const options = await api.post('/api/admin/auth/passkeys/options')