From 6385c1b9a361e051b5ffa178b87009cfd498b6a6 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 21:16:00 +0800 Subject: [PATCH] feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI] --- .../src/Fleet.Identity/WebAuthnAssertion.cs | 85 ++++ backend/openapi/v1.json | 477 ++++++++++++++++++ .../Admin/AuthEndpoints.cs | 114 ++++- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 + .../Contracts/AdminDtos.cs | 12 + .../Contracts/ApiJsonContext.cs | 6 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Auth/AuthModels.cs | 3 + .../Auth/AuthService.cs | 15 +- .../Auth/LoginRateLimiter.cs | 6 +- .../Auth/PasskeyService.cs | 205 ++++++++ .../Auth/WebAuthnRegistration.cs | 177 +++++++ .../BackendOptions.cs | 6 + .../Ports/IAuthStore.cs | 14 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 24 +- .../SqliteAuthStore.cs | 31 +- .../MoShiro.Backend.Tests/CapabilityTests.cs | 4 +- .../OpenApiConformanceTests.cs | 2 +- .../MoShiro.Backend.Tests/PasskeyTests.cs | 254 ++++++++++ docs/access.md | 11 +- docs/api.md | 2 + web/admin/src/App.tsx | 165 +++++- web/admin/src/PasskeyNotice.tsx | 92 ++++ web/admin/src/auth.tsx | 11 +- web/admin/src/pages/PasskeyAdd.tsx | 57 +++ web/admin/src/pages/System.tsx | 59 ++- web/shared/api/schema.ts | 385 ++++++++++++++ web/shared/api/types.ts | 3 + web/shared/api/webauthn.ts | 87 ++++ web/shared/theme/base.css | 9 + web/shared/ui/index.tsx | 19 +- 31 files changed, 2296 insertions(+), 42 deletions(-) create mode 100644 backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs create mode 100644 web/admin/src/PasskeyNotice.tsx create mode 100644 web/admin/src/pages/PasskeyAdd.tsx create mode 100644 web/shared/api/webauthn.ts diff --git a/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs new file mode 100644 index 0000000..60a0d0d --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs @@ -0,0 +1,85 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Fleet.Identity; + +/// +/// 验证一次 WebAuthn 断言(navigator.credentials.get 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: +/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。 +/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。 +/// +public static class WebAuthnAssertion +{ + public const byte FlagUserPresent = 0x01; + public const byte FlagUserVerified = 0x04; + + /// 注册时保存的凭证公钥(SPKI)。 + /// 这次请求里服务端或签名人规定的挑战原文(不是 base64url)。 + /// 校验 clientDataJSON.origin。 + /// authenticatorData 里的签名计数器。 + public static bool Verify( + ReadOnlySpan publicKeySpki, ReadOnlySpan authenticatorData, ReadOnlySpan clientDataJson, ReadOnlySpan signature, + string rpId, ReadOnlySpan expectedChallenge, Func originAllowed, bool requireUserVerification, out uint signCount) + { + signCount = 0; + if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return false; + } + + byte flags = authenticatorData[32]; + if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0)) + { + return false; + } + + if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed)) + { + return false; + } + + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKeySpki, out _); + byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)]; + if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)) + { + return false; + } + } + catch (CryptographicException) + { + return false; + } + + signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]); + return true; + } + + /// clientDataJSON 的 type、challenge、origin 都要对得上。注册和登录共用。 + public static bool ClientDataValid(ReadOnlySpan clientDataJson, string expectedType, ReadOnlySpan expectedChallenge, Func originAllowed) + { + try + { + using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray()); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || root.GetProperty("type").GetString() != expectedType + || root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin) + || root.GetProperty("challenge").GetString() is not { } challenge) + { + return false; + } + + byte[] decoded = Base64Url.DecodeFromChars(challenge); + return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException) + { + return false; + } + } +} diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 28a6789..439e5b5 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1187,6 +1187,98 @@ } } }, + "/api/admin/auth/passkey/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for signing in with a passkey (discoverable credential, no username needed)", + "operationId": "auth_passkey_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRequestOptionsDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/passkey/login": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Sign in with a passkey assertion; same response as the password sign-in", + "operationId": "auth_passkey_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "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" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/admin/releases": { "post": { "tags": [ @@ -1329,6 +1421,210 @@ "x-moshiro-capability": "authenticated" } }, + "/api/admin/auth/passkeys": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The signed-in user's passkeys", + "operationId": "auth_passkeys_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfPasskeyDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Register a passkey from a WebAuthn attestation (ES256, user verification required)", + "operationId": "auth_passkeys_register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterPasskeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyDto" + } + } + } + }, + "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" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for registering a new passkey (the user must be signed in)", + "operationId": "auth_passkeys_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCreationOptionsDto" + } + } + } + }, + "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" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/{id}": { + "delete": { + "tags": [ + "admin-auth" + ], + "summary": "Remove one of your own passkeys", + "operationId": "auth_passkeys_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, "/api/admin/auth/me": { "get": { "tags": [ @@ -6821,6 +7117,27 @@ } } }, + "PageDtoOfPasskeyDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PasskeyDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfPlanDto": { "required": [ "items", @@ -7070,6 +7387,133 @@ } } }, + "PasskeyCreationOptionsDto": { + "required": [ + "challenge", + "rp_id", + "rp_name", + "user_id", + "user_name", + "exclude_credentials", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "rp_name": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + }, + "exclude_credentials": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "PasskeyDto": { + "required": [ + "id", + "name", + "created_at", + "last_used_at", + "transports" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "format": "date-time" + }, + "last_used_at": { + "format": "date-time" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PasskeyLoginRequest": { + "required": [ + "credential_id", + "authenticator_data", + "client_data_json", + "signature", + "return_session" + ], + "type": "object", + "properties": { + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "return_session": { + "type": [ + "null", + "boolean" + ] + } + } + }, + "PasskeyRequestOptionsDto": { + "required": [ + "challenge", + "rp_id", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, "PatchProbeRequest": { "required": [ "name" @@ -7482,6 +7926,39 @@ } } }, + "RegisterPasskeyRequest": { + "required": [ + "name", + "credential_id", + "attestation_object", + "client_data_json", + "transports" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "attestation_object": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "transports": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "RejectedDto": { "required": [ "id", diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 6782cff..85cc869 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -19,6 +19,14 @@ public static class AuthEndpoints .AllowsAnonymous() .Api("auth_login", "Sign in with a username and password", Tag) .Returns().ProducesProblems(400, 401, 429); + open.MapPost("/auth/passkey/options", PasskeyLoginOptions) + .AllowsAnonymous() + .Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag) + .Returns().ProducesProblems(429); + open.MapPost("/auth/passkey/login", PasskeyLoginAsync) + .AllowsAnonymous() + .Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag) + .Returns().ProducesProblems(400, 401, 429); } public static void MapSession(RouteGroupBuilder guarded) @@ -27,6 +35,22 @@ public static class AuthEndpoints .Api("auth_logout", "Sign out and clear the session cookie", Tag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) .Requires(Capability.Authenticated); + guarded.MapGet("/auth/passkeys", ListPasskeysAsync) + .Api("auth_passkeys_list", "The signed-in user's passkeys", Tag) + .Returns>().ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync) + .Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag) + .Returns().ProducesProblems(400, 401, 403) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync) + .Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.Authenticated); + guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync) + .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 404) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) .Returns().ProducesProblems(401) @@ -47,8 +71,12 @@ public static class AuthEndpoints return Problems.From(http, error); } - // 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上 - bool returnSession = request.ReturnSession == true; + return IssueSession(http, login, request.ReturnSession == true, options); + } + + /// 发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。 + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options) + { if (!returnSession) { http.Response.Cookies.Append( @@ -68,6 +96,88 @@ public static class AuthEndpoints return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse); } + /// 这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。 + private static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) + { + string own = $"{http.Request.Scheme}://{http.Request.Host}"; + Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null; + string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host; + IEnumerable origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins) + ? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/')) + : [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)]; + return new PasskeyContext(rpId, new HashSet(origins, StringComparer.Ordinal)); + } + + /// 限流。 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。 + private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null) + { + string source = scope + (http.Connection.RemoteIpAddress?.ToString() ?? "unknown"); + ok = limiter.TryAcquire(source, out int retryAfter, limit); + return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); + } + + /// 取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。 + private const int PasskeyOptionsPerMinute = 60; + + private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute); + if (!ok) + { + return limit; + } + + RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options)); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto); + } + + private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok); + if (!ok) + { + return limit; + } + + Result result = await passkeys.LoginAsync( + new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); + return result.TryGet(out LoginResult? login, out FleetError? error) ? IssueSession(http, login, request.ReturnSession == true, options) : Problems.From(http, error); + } + + private static async Task ListPasskeysAsync(HttpContext http, PasskeyService passkeys) + { + string userId = http.GetPrincipal().UserId ?? ""; + IReadOnlyList list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted); + return TypedResults.Json(new PageDto([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); + } + + private static async Task PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options) + { + Result result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out CreationOptions? o, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto); + } + + private static async Task RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) + { + 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) + ? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created) + : 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) + ? TypedResults.NoContent() + : Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); + private static async Task LogoutAsync(HttpContext http, AuthService auth) { string? bearer = PrincipalResolver.BearerOf(http); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index b540d8a..8826c67 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)); + public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); + public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); public static RawChunkMetaDto ToDto(RawChunkMeta m) => diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 7985292..c5f2980 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -5,6 +5,18 @@ namespace MoShiro.Backend.Api; /// return_session:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 Authorization: Bearer 带上。 public sealed record LoginRequest(string Username, string Password, bool? ReturnSession); +/// 浏览器 navigator.credentials.create 要的参数;二进制字段都是 base64url。 +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); + +public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession); + +public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList Transports); + public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 30892fe..cbb44f7 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -74,6 +74,12 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] [JsonSerializable(typeof(CapabilityCatalogDto))] +[JsonSerializable(typeof(PasskeyCreationOptionsDto))] +[JsonSerializable(typeof(PasskeyRequestOptionsDto))] +[JsonSerializable(typeof(RegisterPasskeyRequest))] +[JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(PasskeyDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index df33d2b..d8a1c19 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -35,6 +35,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 6d8cf92..60d3f4c 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -12,6 +12,9 @@ public enum PrincipalKind /// public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) { + /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 + public string AuthMethod { get; init; } = ""; + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index 7dd0bf8..aa32c1d 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -37,11 +37,22 @@ public sealed partial class AuthService return Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); } + return await CreateSessionAsync(user, AuthMethods.Password, ct); + } + + /// 已经确认了用户身份(例如 passkey 断言通过)之后建立会话。 + public async ValueTask> CreateSessionForAsync(string userId, string method, CancellationToken ct) => + await _store.GetUserAsync(userId, ct) is { } user + ? await CreateSessionAsync(user, method, ct) + : Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); + + private async ValueTask> CreateSessionAsync(UserRecord user, string method, CancellationToken ct) + { byte[] token = RandomNumberGenerator.GetBytes(32); byte[] csrf = RandomNumberGenerator.GetBytes(32); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long expires = now + (long)_options.SessionLifetime.TotalMilliseconds; - await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct); + await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires, method), ct); await _store.DeleteExpiredSessionsAsync(now, ct); return Result.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username)); } @@ -60,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); + 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 }; } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs index ca7fede..7b77e49 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs @@ -15,8 +15,8 @@ public sealed class LoginRateLimiter _time = time; } - /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 - public bool TryAcquire(string source, out int retryAfterSeconds) + /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 缺省是登录失败的额度(每分钟 次)。 + public bool TryAcquire(string source, out int retryAfterSeconds, int? limit = null) { long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); Queue queue = _attempts.GetOrAdd(source, _ => new Queue()); @@ -27,7 +27,7 @@ public sealed class LoginRateLimiter queue.Dequeue(); } - if (queue.Count >= _options.LoginAttemptsPerMinute) + if (queue.Count >= (limit ?? _options.LoginAttemptsPerMinute)) { retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000); return false; diff --git a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs new file mode 100644 index 0000000..b24b6b3 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -0,0 +1,205 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +public sealed record PasskeyRecord(string Id, string UserId, string Name, byte[] PublicKeySpki, long SignCount, IReadOnlyList Transports, long CreatedAtMs, long? LastUsedAtMs); + +/// 这次请求的 WebAuthn 依赖方:RpId 是域名,Origins 是浏览器会填进 clientDataJSON.origin 的来源。 +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 PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +/// +/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期; +/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 (节点以后也用)。 +/// 设计见 docs/access.md 第 3 节。 +/// +public sealed class PasskeyService +{ + private const int ChallengeBytes = 32; + private const int MaxPasskeysPerUser = 10; + private const int MaxPendingChallenges = 4096; + private static readonly TimeSpan s_challengeLifetime = TimeSpan.FromMinutes(5); + + private readonly IAuthStore _store; + private readonly AuthService _auth; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary _challenges = []; + + private sealed record PendingChallenge(bool Registration, string? UserId, long ExpiresAtMs); + + public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) + { + _store = store; + _auth = auth; + _time = time; + } + + public async ValueTask> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.Username is not { } username) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + IReadOnlyList existing = await _store.ListPasskeysAsync(userId, ct); + if (existing.Count >= MaxPasskeysPerUser) + { + return ValidationError.Fail("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); + } + + string challenge = Issue(registration: true, userId); + return Result.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); + } + + public async ValueTask> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + string name = request.Name.Trim(); + if (name.Length is 0 or > 64) + { + return ValidationError.Fail("name", "must be 1-64 characters"); + } + + if (!TryDecode(request.AttestationObject, out byte[] attestation) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.CredentialId, out byte[] id)) + { + return ValidationError.Fail("credential", "malformed registration"); + } + + // 挑战在 clientDataJSON 里:先取出来,一次有效 + if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); + } + + RegisteredCredential? credential = WebAuthnRegistration.Verify(attestation, clientData, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true); + if (credential is null || !credential.CredentialId.AsSpan().SequenceEqual(id)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration could not be verified (ES256 with user verification is required)"); + } + + string credentialId = Base64Url.Encode(credential.CredentialId); + var record = new PasskeyRecord(credentialId, userId, name, credential.PublicKeySpki, credential.SignCount, request.Transports?.Take(8).ToArray() ?? [], _time.GetUtcNow().ToUnixTimeMilliseconds(), null); + return await _store.AddPasskeyAsync(record, ct) + ? Result.Ok(record) + : Result.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); + } + + public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, 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)) + { + return denied; + } + + if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge)) + { + return denied; + } + + 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; + } + + // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 + if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) + { + return denied; + } + + await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + return await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + } + + 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) + { + byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); + string key = Base64Url.Encode(challenge); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (_challenges.Count >= MaxPendingChallenges) + { + foreach (string stale in _challenges.Where(p => p.Value.ExpiresAtMs <= now).Select(p => p.Key).ToList()) + { + _challenges.Remove(stale); + } + + if (_challenges.Count >= MaxPendingChallenges) + { + _challenges.Remove(_challenges.OrderBy(p => p.Value.ExpiresAtMs).First().Key); // 淘汰最旧的 + } + } + + _challenges[key] = new PendingChallenge(registration, userId, now + (long)s_challengeLifetime.TotalMilliseconds); + } + + return key; + } + + /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 + private bool TryTakeChallenge(byte[] clientDataJson, bool registration, string? userId, out byte[] challenge) + { + challenge = []; + string? text; + try + { + using var document = System.Text.Json.JsonDocument.Parse(clientDataJson); + text = document.RootElement.GetProperty("challenge").GetString(); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException) + { + return false; + } + + if (text is null || !TryDecode(text, out byte[] raw)) + { + return false; + } + + 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) + { + return false; + } + } + + challenge = raw; + return true; + } + + private static bool TryDecode(string text, out byte[] data) => Base64Url.TryDecode(text, out data) && data.Length > 0; +} + +public static class AuthMethods +{ + public const string Password = "password"; + public const string Passkey = "passkey"; +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs new file mode 100644 index 0000000..a85021b --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs @@ -0,0 +1,177 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// 注册成功后要保存的凭证。 +public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount); + +/// +/// 解析并校验 WebAuthn 注册(navigator.credentials.create 的结果)。只接受 ES256;attestation 一律当 none 处理: +/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。 +/// +public static class WebAuthnRegistration +{ + public static RegisteredCredential? Verify( + ReadOnlySpan attestationObject, ReadOnlySpan clientDataJson, string rpId, ReadOnlySpan expectedChallenge, + Func originAllowed, bool requireUserVerification) + { + if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed)) + { + return null; + } + + try + { + if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary attestation + || attestation.GetValueOrDefault("authData") is not byte[] authData || authData.Length < 55) + { + return null; + } + + if (!CryptographicOperations.FixedTimeEquals(authData.AsSpan(0, 32), SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return null; + } + + byte flags = authData[32]; + const byte attestedData = 0x40; + if ((flags & WebAuthnAssertion.FlagUserPresent) == 0 || (flags & attestedData) == 0 || (requireUserVerification && (flags & WebAuthnAssertion.FlagUserVerified) == 0)) + { + return null; + } + + uint signCount = (uint)((authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]); + int idLength = (authData[53] << 8) | authData[54]; + if (idLength is 0 or > 1023 || authData.Length < 55 + idLength) + { + return null; + } + + byte[] credentialId = authData[55..(55 + idLength)]; + var reader = new MiniCbor(authData[(55 + idLength)..]); + if (reader.ReadValue() is not Dictionary cose + || cose.GetValueOrDefault(1L) is not long kty || kty != 2 + || cose.GetValueOrDefault(3L) is not long alg || alg != -7 + || cose.GetValueOrDefault(-1L) is not long curve || curve != 1 + || cose.GetValueOrDefault(-2L) is not byte[] { Length: 32 } x + || cose.GetValueOrDefault(-3L) is not byte[] { Length: 32 } y) + { + return null; + } + + using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } }); + return new RegisteredCredential(credentialId, key.ExportSubjectPublicKeyInfo(), signCount); + } + catch (Exception ex) when (ex is InvalidDataException or CryptographicException or ArgumentException or IndexOutOfRangeException) + { + return null; + } + } +} + +/// 够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。 +internal sealed class MiniCbor +{ + private const int MaxDepth = 8; + private const int MaxItems = 64; + + private readonly byte[] _data; + private int _position; + + public MiniCbor(byte[] data) + { + _data = data; + } + + public object? ReadValue(int depth = 0) + { + if (depth > MaxDepth || _position >= _data.Length) + { + throw new InvalidDataException("cbor too deep or truncated"); + } + + byte initial = _data[_position++]; + int major = initial >> 5; + long argument = ReadArgument(initial & 0x1F); + switch (major) + { + case 0: + return argument; + case 1: + return -1 - argument; + case 2: + return Take(argument); + case 3: + return Encoding.UTF8.GetString(Take(argument)); + case 4: + { + CheckCount(argument); + var list = new List(); + for (long i = 0; i < argument; i++) + { + list.Add(ReadValue(depth + 1)); + } + + return list; + } + + case 5: + { + CheckCount(argument); + var map = new Dictionary(); + for (long i = 0; i < argument; i++) + { + object key = ReadValue(depth + 1) ?? throw new InvalidDataException("null map key"); + map[key] = ReadValue(depth + 1)!; + } + + return map; + } + + case 7: + return initial switch { 0xF4 => false, 0xF5 => true, 0xF6 => null, _ => throw new InvalidDataException("unsupported simple value") }; + default: + throw new InvalidDataException("unsupported cbor type"); + } + } + + private long ReadArgument(int info) + { + if (info < 24) + { + return info; + } + + int length = info switch { 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => throw new InvalidDataException("indefinite lengths are not supported") }; + byte[] bytes = Take(length); + long value = 0; + foreach (byte b in bytes) + { + value = (value << 8) | b; + } + + return value < 0 ? throw new InvalidDataException("argument too large") : value; + } + + private byte[] Take(long length) + { + if (length < 0 || length > _data.Length - _position) + { + throw new InvalidDataException("cbor truncated"); + } + + byte[] result = _data[_position..(_position + (int)length)]; + _position += (int)length; + return result; + } + + private static void CheckCount(long count) + { + if (count > MaxItems) + { + throw new InvalidDataException("cbor collection too large"); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 27e8cf0..d4c8c29 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,12 @@ public sealed class BackendOptions public int Pbkdf2Iterations { get; set; } = 600_000; + /// WebAuthn 的依赖方域名;缺省取 的主机,没有就取请求的主机。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。 + public string? WebAuthnRpId { get; set; } + + /// 允许的来源(逗号分隔,如 https://panel.example.com);缺省是 的来源,没有就取请求自己的来源。 + public string? WebAuthnOrigins { get; set; } + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 public bool SecureCookies { get; set; } = true; diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 3fc8b97..4ec3401 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -2,7 +2,7 @@ namespace MoShiro.Backend.Application; public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs); -public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs); +public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs, string AuthMethod = AuthMethods.Password); public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs); @@ -31,6 +31,18 @@ public interface IAuthStore ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct); + /// 凭证 id 已经存在时返回 false。 + ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct); + + ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct); + + ValueTask> ListPasskeysAsync(string userId, CancellationToken ct); + + /// 只能删自己的。 + ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct); + + ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct); + ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct); ValueTask> ListApiTokensAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 79ec4e1..b8c1d26 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 11; + public const int CurrentVersion = 12; public static void Migrate(SqliteConnection connection) { @@ -161,6 +161,28 @@ internal static class Schema connection.Exec("PRAGMA user_version = 11"); transaction.Commit(); } + + if (version < 12) + { + // passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(""" + CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) + """); + connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)"); + connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'"); + connection.Exec("PRAGMA user_version = 12"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index 2498413..1fccdb6 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -46,13 +46,13 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at) VALUES (@id, @u, @csrf, @c, @e)", - ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs)), + "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at, auth_method) VALUES (@id, @u, @csrf, @c, @e, @m)", + ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs), ("@m", session.AuthMethod)), ct); public ValueTask GetSessionAsync(byte[] idSha256, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4)), ("@id", idSha256)), + c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at, auth_method FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4), r.GetString(5)), ("@id", idSha256)), ct); public ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct) => @@ -68,6 +68,27 @@ public sealed class SqliteAuthStore : IAuthStore ("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)), ct); + public ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct) => + _db.WriteAsync( + c => c.Scalar("SELECT COUNT(*) FROM passkeys WHERE id = @id", ("@id", passkey.Id)) > 0 + ? false + : c.Exec( + "INSERT INTO passkeys (id, user_id, name, public_key, sign_count, transports, created_at) VALUES (@id, @u, @n, @k, @s, @t, @at)", + ("@id", passkey.Id), ("@u", passkey.UserId), ("@n", passkey.Name), ("@k", passkey.PublicKeySpki), ("@s", passkey.SignCount), ("@t", StorageJson.ToJson(passkey.Transports)), ("@at", passkey.CreatedAtMs)) == 1, + ct); + + public ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {PasskeyColumns} FROM passkeys WHERE id = @id", ReadPasskey, ("@id", credentialId)), ct); + + public ValueTask> ListPasskeysAsync(string userId, CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct); + + public ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct); + + public ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE passkeys SET sign_count = @s, last_used_at = @now WHERE id = @id", ("@id", credentialId), ("@s", signCount), ("@now", nowMs)), ct); + public ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync(c => c.QueryOne($"SELECT {TokenColumns} FROM api_tokens WHERE token_sha256 = @h", ReadToken, ("@h", tokenSha256)), ct); @@ -80,6 +101,10 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE api_tokens SET last_used_at = @now WHERE id = @id", ("@id", id), ("@now", nowMs)), ct); + private const string PasskeyColumns = "id, user_id, name, public_key, sign_count, transports, created_at, last_used_at"; + + private static PasskeyRecord ReadPasskey(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), StorageJson.Strings(r.GetString(5)), r.GetInt64(6), r.NullableLong(7)); + private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5)); private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7)); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index 5d0f7f0..ec58046 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -35,8 +35,8 @@ public sealed class CapabilityTests } } - // 只有登录是匿名的 - Assert.Equal(["/api/admin/auth/login"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().ToArray()); + // 只有登录(密码和 passkey)是匿名的 + Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray()); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index d976acc..b675b81 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures() { JsonObject doc = await DocumentAsync(); - foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path != "/api/admin/auth/login")) + foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options"))) { JsonObject responses = operation["responses"]!.AsObject(); Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401"); diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs new file mode 100644 index 0000000..2225029 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; + +namespace MoShiro.Backend.Tests; + +/// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。 +internal sealed class SoftwarePasskey : IDisposable +{ + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private uint _counter; + + public SoftwarePasskey(string rpId, string origin) + { + RpId = rpId; + Origin = origin; + CredentialId = RandomNumberGenerator.GetBytes(32); + } + + public string RpId { get; } + + public string Origin { get; } + + public byte[] CredentialId { get; } + + public string Id => Base64Url.Encode(CredentialId); + + public JsonObject Register(string name, string challenge, byte flags = 0x45, string? origin = null, string? rpId = null) + { + ECParameters p = _key.ExportParameters(false); + byte[] cose = [.. MapHead(5), .. Int(1), .. Int(2), .. Int(3), .. Int(-7), .. Int(-1), .. Int(1), .. Int(-2), .. Bytes(p.Q.X!), .. Int(-3), .. Bytes(p.Q.Y!)]; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter), .. new byte[16], (byte)(CredentialId.Length >> 8), (byte)CredentialId.Length, .. CredentialId, .. cose]; + byte[] attestation = [.. MapHead(3), .. Text("fmt"), .. Text("none"), .. Text("attStmt"), .. MapHead(0), .. Text("authData"), .. Bytes(authData)]; + return new JsonObject + { + ["name"] = name, + ["credential_id"] = Id, + ["attestation_object"] = Base64Url.Encode(attestation), + ["client_data_json"] = Base64Url.Encode(ClientData("webauthn.create", challenge, origin ?? Origin)), + ["transports"] = new JsonArray("internal"), + }; + } + + public JsonObject Assert(string challenge, uint? counter = null, byte flags = 0x05, string? origin = null, string? rpId = null, bool tamper = false) + { + _counter = counter ?? _counter + 1; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter)]; + byte[] clientData = ClientData("webauthn.get", challenge, origin ?? Origin); + byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + if (tamper) + { + signature[^1] ^= 0x01; + } + + return new JsonObject + { + ["credential_id"] = Id, + ["authenticator_data"] = Base64Url.Encode(authData), + ["client_data_json"] = Base64Url.Encode(clientData), + ["signature"] = Base64Url.Encode(signature), + ["return_session"] = true, + }; + } + + public void Dispose() => _key.Dispose(); + + private static byte[] ClientData(string type, string challenge, string origin) => + Encoding.UTF8.GetBytes($"{{\"type\":\"{type}\",\"challenge\":\"{challenge}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}"); + + private static byte[] Counter(uint value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; + + // 最小的 CBOR 编码器:只写测试需要的几种 + private static byte[] Head(int major, int length) => + length < 24 ? [(byte)((major << 5) | length)] : length < 256 ? [(byte)((major << 5) | 24), (byte)length] : [(byte)((major << 5) | 25), (byte)(length >> 8), (byte)length]; + + private static byte[] MapHead(int count) => Head(5, count); + + private static byte[] Int(int value) => value >= 0 ? Head(0, value) : Head(1, -1 - value); + + private static byte[] Bytes(byte[] data) => [.. Head(2, data.Length), .. data]; + + private static byte[] Text(string text) => [.. Head(3, Encoding.UTF8.GetByteCount(text)), .. Encoding.UTF8.GetBytes(text)]; +} + +/// passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/access.md 第 3 节。 +[Collection("probe-e2e")] +public sealed class PasskeyTests +{ + private readonly BackendFixture _backend; + + public PasskeyTests(BackendFixture backend) + { + _backend = backend; + } + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private string RpId => _backend.BaseAddress.Host; + + private async Task PasswordSessionAsync(string user, string password) + { + HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password, ["return_session"] = true })); + string body = await login.Content.ReadAsStringAsync(); + Assert.True(login.IsSuccessStatusCode, body); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["session_token"]!.GetValue()); + return client; + } + + private static async Task JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + + private async Task RegistrationChallengeAsync(HttpClient session) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + JsonNode body = await JsonOf(options); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + Assert.Equal(RpId, body["rp_id"]!.GetValue()); + return body["challenge"]!.GetValue(); + } + + private async Task LoginChallengeAsync() + { + using HttpResponseMessage options = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/options", null); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + return (await JsonOf(options))["challenge"]!.GetValue(); + } + + private async Task RegisterAsync(HttpClient session, string name = "laptop") + { + var passkey = new SoftwarePasskey(RpId, Origin); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, await RegistrationChallengeAsync(session)))); + Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); + return passkey; + } + + private async Task LoginStatusAsync(JsonObject assertion) + { + using HttpResponseMessage response = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(assertion)); + return response.StatusCode; + } + + [Fact] + public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade() + { + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(session); + + JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys")); + Assert.Contains(list["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id && p["name"]!.GetValue() == "laptop"); + + 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.NotNull(body["session_token"]); + Assert.Equal("no-store", login.Headers.CacheControl?.ToString()); + + // 拿这个会话令牌就是登录状态,能力和密码登录一样 + 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.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 SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), tamper: true))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), flags: 0x01))); // 没有用户验证(UV) + + // 挑战只能用一次:同一份断言重放 + string challenge = await LoginChallengeAsync(); + JsonObject assertion = passkey.Assert(challenge); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(assertion)); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(assertion)); + + // 凭证不存在 + using var stranger = new SoftwarePasskey(RpId, Origin); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(stranger.Assert(await LoginChallengeAsync()))); + // 缺字段不会崩 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(new JsonObject())); + } + + [Fact] + public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne() + { + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); // 克隆或回放 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 3))); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 6))); + } + + [Fact] + public async Task Registration_NeedsASignedInUser_AValidAttestation_AndAFreshChallenge() + { + // API 令牌不是用户:没有 passkey 可言 + using HttpResponseMessage asToken = await _backend.Admin.PostAsync("/api/admin/auth/passkeys/options", null); + 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 var passkey = new SoftwarePasskey(RpId, Origin); + async Task TryAsync(JsonObject body) + { + using HttpResponseMessage response = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(body)); + return response.StatusCode; + } + + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", "bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x41))); // 没有 UV + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x05))); // 没有凭证数据 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register(" ", await RegistrationChallengeAsync(session)))); // 名字为空 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new JsonObject { ["name"] = "x", ["credential_id"] = "AA", ["attestation_object"] = "AAAA", ["client_data_json"] = "AAAA" })); // 垃圾 + + // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 + string challenge = await RegistrationChallengeAsync(session); + Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次 + Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session)))); + 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 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.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id); + + 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 8b02422..b9f3fcb 100644 --- a/docs/access.md +++ b/docs/access.md @@ -70,6 +70,15 @@ - 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 - **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。 +### 3.1 passkey 登录的实现(已完成) + +- 流程:登录页「使用 Passkey 登录」→ `POST /api/admin/auth/passkey/options` 取挑战 → 浏览器 `navigator.credentials.get`(可发现凭证,不用输用户名)→ `POST /api/admin/auth/passkey/login` 提交断言 → 建立会话(Cookie,或跨域前端用 `return_session`)。注册:已登录用户在「系统 → 用户 → 我的 Passkey」添加,`POST /api/admin/auth/passkeys/options` 加 `POST /api/admin/auth/passkeys`;每个用户最多 10 个。 +- 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。 +- 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),attestation 一律当 `none`,节点不带 CBOR。 +- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。 +- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只有 passkey 登录的会话以后能提权;API key 不是用户,不能注册 passkey。 +- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。 + ## 4. 提权窗口(信任类能力各管各的) | 窗口 | 覆盖 | 默认时长 | 行为 | @@ -273,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 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**passkey 登录已实现**(见第 3.1 节);提权窗口、审计日志、归属列还没做。 3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 diff --git a/docs/api.md b/docs/api.md index 3f0a577..7756dfc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -141,6 +141,8 @@ | GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | | GET | `/api/admin/analysis/recompute` | 指标重算进度 | | GET/POST/DELETE | `/api/admin/api-tokens` | 创建时勾选能力(`capabilities`,只能是创建者自己拥有能力的子集,写蕴含读)和可选的有效期(`expires_in_days`);明文只显示一次 | +| POST | `/api/admin/auth/passkey/options`、`/api/admin/auth/passkey/login` | 匿名(限流)。passkey 登录:先取挑战,再提交断言;响应同密码登录 | +| GET/POST/DELETE | `/api/admin/auth/passkeys`、`/auth/passkeys/options`、`/auth/passkeys/{id}` | 已登录用户管理自己的 passkey:列出、取注册挑战、注册、删除(API key 不能用) | | GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index f1d5c4a..a0c1d1c 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -1,10 +1,13 @@ -import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' -import { useState, type FormEvent } from 'react' +import { FlaskConical, KeyRound, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { ApiError } from '@shared/api/client' import { activateSource, getBase, listSources } from '@shared/api/source' -import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' +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 { PasskeyNotice } from './PasskeyNotice' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' import { Probes } from './pages/Probes' @@ -12,9 +15,9 @@ import { System } from './pages/System' import { Tasks } from './pages/Tasks' import { Tests } from './pages/Tests' -/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */ +/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */ function Login() { - const { login } = useAuth() + const { login, loginWithPasskey } = useAuth() const active = getBase() const saved = listSources() const [adding, setAdding] = useState(false) @@ -47,6 +50,94 @@ function Login() { } } + // passkey 绑定在后端的域名上:只有页面自己所在的后端能用,别的后端(跨域)不显示 + const canPasskey = passkeySupported() && !adding && !active + // 登录方式:记住上次用的;浏览器不支持或者是别的后端就只有账号密码 + const [method, setMethod] = useState<'password' | 'passkey'>(() => { + try { + return localStorage.getItem('moshiro.login_method') === 'passkey' ? 'passkey' : 'password' + } catch { + return 'password' + } + }) + const pickMethod = (next: 'password' | 'passkey') => { + setMethod(next) + setError('') + try { + localStorage.setItem('moshiro.login_method', next) + } catch { + // 记不住也只是下次回到默认 + } + } + const usingPasskey = canPasskey && method === 'passkey' + // 自动填充只在「账号密码」页有用(它依附在用户名输入框上) + const autofillActive = canPasskey && method === 'password' + // 重新开始自动填充登录(点按钮的弹窗会顶掉它,取消之后要重新挂上) + const [round, setRound] = useState(0) + const retry = () => { + if (retryOnFocus.current) { + retryOnFocus.current = false + setRound((n) => n + 1) + } + } + 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) + setError('') + conditional.current?.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + try { + await loginWithPasskey() + } catch (err) { + // 用户取消(NotAllowedError)不当成错误 + if (!(err instanceof DOMException && err.name === 'NotAllowedError')) setError(describeError(err)) + } finally { + setBusy(false) + setRound((n) => n + 1) + } + } + + // passkey 自动填充:点用户名输入框,浏览器会把保存的 passkey(Chrome 里是 Google 密码管理工具)列在下拉里,选一个就直接登录。 + // 挑战 5 分钟过期,所以每 4 分钟换一个 + useEffect(() => { + if (!autofillActive) return undefined + const abort = new AbortController() + conditional.current = abort + let timer: ReturnType | undefined + let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环) + void conditionalSupported().then(async (ok) => { + if (abort.signal.aborted) return + setAutofill(ok ? 'yes' : 'no') + if (!ok) return + timer = setTimeout(() => { + expired = true + abort.abort() + }, 4 * 60_000) + try { + await loginWithPasskey({ conditional: true, signal: abort.signal }) + } catch (err) { + if (timer) clearTimeout(timer) + if (abort.signal.aborted) { + if (expired) setRound((n) => n + 1) + return + } + // 取消、被弹窗顶掉、浏览器不允许都不是错误;服务端拒绝(含限流)才显示 + if (!(err instanceof DOMException)) { + retryOnFocus.current = true + setError(err instanceof ApiError && err.status === 401 ? '这个 Passkey 在服务器上已经不存在或已失效(可能被删除了),请换一个,或者用密码登录' : describeError(err)) + } + } + }) + return () => { + abort.abort() + if (timer) clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autofillActive, round]) + const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))] const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin @@ -91,21 +182,56 @@ function Login() {

{title}

- {adding ? ( - - setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> - + {canPasskey ? ( + ) : null} - - setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required /> - - - setPassword(e.target.value)} autoComplete="current-password" required /> - - {error ?
{error}
: null} - + {usingPasskey ? ( + <> +

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

+ {error ?
{error}
: null} + +

+ 还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。 +

+ + ) : ( + <> + {adding ? ( + + setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> + + ) : null} + + setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} /> + + {canPasskey ? ( +

+ {autofill === 'yes' + ? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。' + : autofill === 'no' + ? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。' + : ''} +

+ ) : null} + + setPassword(e.target.value)} autoComplete="current-password" required /> + + {error ?
{error}
: null} + + + )}
@@ -164,6 +290,7 @@ export function App() {
+ } /> } /> diff --git a/web/admin/src/PasskeyNotice.tsx b/web/admin/src/PasskeyNotice.tsx new file mode 100644 index 0000000..b17c18f --- /dev/null +++ b/web/admin/src/PasskeyNotice.tsx @@ -0,0 +1,92 @@ +import { KeyRound } from 'lucide-react' +import { useEffect, useState } from 'react' +import { api } from '@shared/api/client' +import { getBase } from '@shared/api/source' +import type { Page, PasskeyDto } from '@shared/api/types' +import { passkeySupported } from '@shared/api/webauthn' +import { Dialog } from '@shared/ui' +import { useAuth } from './auth' +import { PasskeyAddForm } from './pages/PasskeyAdd' + +const SNOOZE_KEY = 'moshiro.passkey_nag_until' +const SNOOZE_MS = 7 * 24 * 3600 * 1000 + +function snoozed(): boolean { + try { + return Number(localStorage.getItem(SNOOZE_KEY) ?? 0) > Date.now() + } catch { + return false + } +} + +/** + * 登录之后,如果这个用户还没有 Passkey,提醒他添加:Passkey 登录不用输密码,而且只有 passkey 登录的会话以后能做加节点这类信任操作。 + * 只对页面所在的后端提醒(passkey 绑定在后端的域名上);「稍后提醒」7 天内不再出现。 + */ +export function PasskeyNotice() { + const { me } = useAuth() + const [missing, setMissing] = useState(false) + const [adding, setAdding] = useState(false) + + useEffect(() => { + if (!me?.user_id || getBase() || !passkeySupported() || snoozed()) return + let alive = true + api + .get>('/api/admin/auth/passkeys') + .then((list) => alive && setMissing(list.items.length === 0)) + .catch(() => undefined) + return () => { + alive = false + } + }, [me?.user_id]) + + if (!missing) return null + const later = () => { + try { + localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)) + } catch { + // 存不了也只是下次还会提醒 + } + setMissing(false) + } + return ( +
+
+ + + 建议添加 Passkey:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。 + + + + + +
+ {adding ? ( + setAdding(false)} + actions={ + + } + > +

+ 建议至少注册两个(比如这台电脑和手机),其中一个丢了还能登录。也可以随时在「系统 → 用户 → 我的 Passkey」里管理。 +

+ { + setAdding(false) + setMissing(false) + }} + /> +
+ ) : null} +
+ ) +} diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index 9dc5252..ba53ec9 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState, type React import { api, ApiError, loginTo, setCsrf } from '@shared/api/client' import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source' import type { LoginResponse, MeDto } from '@shared/api/types' +import { signInWithPasskey } from '@shared/api/webauthn' interface Auth { me: MeDto | null ready: boolean /** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */ login: (username: string, password: string, base?: string) => Promise + /** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */ + loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise logout: () => Promise } @@ -52,6 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMe(await api.get('/api/admin/auth/me')) }, []) + const loginWithPasskey = useCallback(async (opts?: { conditional?: boolean; signal?: AbortSignal }) => { + const r = await signInWithPasskey(opts) + setCsrf(r.csrf_token) + setMe(await api.get('/api/admin/auth/me')) + }, []) + const logout = useCallback(async () => { try { await api.post('/api/admin/auth/logout') @@ -64,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) - return {children} + return {children} } export function useAuth(): Auth { diff --git a/web/admin/src/pages/PasskeyAdd.tsx b/web/admin/src/pages/PasskeyAdd.tsx new file mode 100644 index 0000000..bd1cb61 --- /dev/null +++ b/web/admin/src/pages/PasskeyAdd.tsx @@ -0,0 +1,57 @@ +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { passkeySupported, registerPasskey, type PasskeyPlace } from '@shared/api/webauthn' +import { guessPlatform, useAction, useToast } from '@shared/ui' + +/** 默认的 passkey 名称:按浏览器所在的系统给一个能认出来的名字。 */ +function defaultName(): string { + const platform = guessPlatform() + return platform === 'windows' ? 'Windows 设备' : platform === 'macos' ? 'Mac' : 'Linux 设备' +} + +/** + * 添加 passkey 的表单:名称 + 保存位置 + 按钮。「我的 Passkey」和登录后的提醒共用同一份。 + * 保存位置只是给浏览器的提示:`device` 优先用设备或浏览器账户自带的(Chrome 里是 Google 密码管理工具、Windows Hello), + * `external` 是安全密钥或手机扫码。 + */ +export function PasskeyAddForm({ onAdded, compact }: { onAdded: () => void; compact?: boolean }) { + const { busy, run } = useAction() + const toast = useToast() + const [name, setName] = useState(defaultName) + const [place, setPlace] = useState('device') + const supported = passkeySupported() + return ( +
+ setName(e.target.value)} disabled={!supported} /> + + +
+ ) +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 2856c34..ef7651f 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,13 +1,14 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' import { CapabilityPicker, capabilityName } from './CapabilityPicker' +import { PasskeyAddForm } from './PasskeyAdd' type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' @@ -495,6 +496,59 @@ function Tokens() { ) } +function Passkeys() { + const list = useQuery(() => api.get>('/api/admin/auth/passkeys'), []) + const { run } = useAction() + const [removing, setRemoving] = useState() + const items = list.data?.items ?? [] + return ( +
+
+ 我的 Passkey + 用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。 +
+ + {items.length === 0 ? ( + 还没有 Passkey + ) : ( + + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
名称创建最后使用 +
+ {p.name} {p.transports.includes('internal') ? 本机 : p.transports.length > 0 ? {p.transports.join('、')} : null} + {fmtTime(p.created_at)}{p.last_used_at ? fmtTime(p.last_used_at) : '从未'} + +
+ )} + + {removing ? ( + setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}> + 「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。 + + ) : null} +
+ ) +} + function Users() { const users = useQuery(() => api.get>('/api/admin/users'), []) const { run } = useAction() @@ -502,6 +556,8 @@ function Users() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') return ( + <> +
能登录管理端的账号。 @@ -576,6 +632,7 @@ function Users() { ) : null}
+ ) } diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 6c0246b..980e4df 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -327,6 +327,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkey/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for signing in with a passkey (discoverable credential, no username needed) */ + post: operations["auth_passkey_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkey/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Sign in with a passkey assertion; same response as the password sign-in */ + post: operations["auth_passkey_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/releases": { parameters: { query?: never; @@ -362,6 +396,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The signed-in user's passkeys */ + get: operations["auth_passkeys_list"]; + put?: never; + /** Register a passkey from a WebAuthn attestation (ES256, user verification required) */ + post: operations["auth_passkeys_register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for registering a new passkey (the user must be signed in) */ + post: operations["auth_passkeys_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove one of your own passkeys */ + delete: operations["auth_passkeys_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/me": { parameters: { query?: never; @@ -1574,6 +1660,10 @@ export interface components { items: components["schemas"]["NodeInfoDto"][]; next_cursor: null | string; }; + PageDtoOfPasskeyDto: { + items: components["schemas"]["PasskeyDto"][]; + next_cursor: null | string; + }; PageDtoOfPlanDto: { items: components["schemas"]["PlanDto"][]; next_cursor: null | string; @@ -1622,6 +1712,38 @@ export interface components { values: null | string[]; description: string; }; + PasskeyCreationOptionsDto: { + challenge: string; + rp_id: string; + rp_name: string; + user_id: string; + user_name: string; + exclude_credentials: string[]; + /** Format: int32 */ + timeout_ms: number | string; + }; + PasskeyDto: { + id: string; + name: string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + last_used_at: unknown; + transports: string[]; + }; + PasskeyLoginRequest: { + credential_id: string; + authenticator_data: string; + client_data_json: string; + signature: string; + return_session: null | boolean; + }; + PasskeyRequestOptionsDto: { + challenge: string; + rp_id: string; + /** Format: int32 */ + timeout_ms: number | string; + }; PatchProbeRequest: { name: null | string; }; @@ -1718,6 +1840,13 @@ export interface components { /** Format: int32 */ queue_length: number | string; }; + RegisterPasskeyRequest: { + name: string; + credential_id: string; + attestation_object: string; + client_data_json: string; + transports: null | string[]; + }; RejectedDto: { id: string; reason: string; @@ -2744,6 +2873,86 @@ export interface operations { }; }; }; + auth_passkey_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyRequestOptionsDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkey_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasskeyLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @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 Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; releases_list: { parameters: { query?: never; @@ -2871,6 +3080,182 @@ export interface operations { }; }; }; + auth_passkeys_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"]["PageDtoOfPasskeyDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterPasskeyRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyDto"]; + }; + }; + /** @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 Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyCreationOptionsDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + auth_passkeys_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + 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"]; + }; + }; + /** @description Not Found */ + 404: { + 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 483df39..0833115 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow +export type PasskeyDto = Narrow +export type PasskeyCreationOptionsDto = Narrow +export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts new file mode 100644 index 0000000..2abf28e --- /dev/null +++ b/web/shared/api/webauthn.ts @@ -0,0 +1,87 @@ +import { api } from './client' +import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types' + +/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ +export async function conditionalSupported(): Promise { + try { + return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true + } catch { + return false + } +} + +/** + * passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。 + * `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串); + * `external`:安全密钥,或者用手机扫码。 + */ +export type PasskeyPlace = 'device' | 'external' + +/** 浏览器是否支持 WebAuthn(passkey)。 */ +export function passkeySupported(): boolean { + return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials +} + +function toBytes(text: string): Uint8Array { + const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=') + const binary = atob(padded) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +function toText(buffer: ArrayBuffer): string { + let binary = '' + for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。 + * passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。 + */ +export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise { + const options = await api.post('/api/admin/auth/passkey/options') + const credential = (await navigator.credentials.get({ + // conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的) + mediation: opts.conditional ? 'conditional' : undefined, + signal: opts.signal, + publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' }, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有选择 passkey') + const response = credential.response as AuthenticatorAssertionResponse + return api.post('/api/admin/auth/passkey/login', { + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + }) +} + +/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ +export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise { + const options = await api.post('/api/admin/auth/passkeys/options') + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: toBytes(options.challenge), + rp: { id: options.rp_id, name: options.rp_name }, + user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), + authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, + attestation: 'none', + timeout: options.timeout_ms, + // 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机 + hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'], + } as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有创建 passkey') + const response = credential.response as AuthenticatorAttestationResponse + return api.post('/api/admin/auth/passkeys', { + name, + credential_id: credential.id, + attestation_object: toText(response.attestationObject), + client_data_json: toText(response.clientDataJSON), + transports: response.getTransports?.() ?? [], + }) +} diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 45e7146..1fd3f3c 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -504,6 +504,15 @@ textarea { box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%); } +.toast-title { + font-weight: 700; + margin-bottom: 2px; +} + +.toast { + cursor: pointer; +} + .toast.bad { background: var(--app-bad); color: var(--app-card); diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index c6d453e..d28f984 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -104,24 +104,33 @@ interface ToastItem { id: number text: string bad: boolean + title?: string } -const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined) +/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */ +export interface ToastOptions { + title?: string + ms?: number +} + +const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined) export function ToastProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState([]) const nextId = useRef(1) - const push = useCallback((text: string, bad = false) => { + const push = useCallback((text: string, bad = false, options?: ToastOptions) => { const id = nextId.current++ - setItems((list) => [...list, { id, text, bad }]) - window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000) + setItems((list) => [...list, { id, text, bad, title: options?.title }]) + window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000)) }, []) + const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id)) return ( {children}
{items.map((t) => ( -
+
dismiss(t.id)} title="点一下关闭"> + {t.title ?
{t.title}
: null} {t.text}
))}