feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]
This commit is contained in:
1 parent
ddb6608e32
commit
6385c1b9a3
31 files changed
+2296
-42
No files matched your search
@@ -0,0 +1,85 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
|
||||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||
/// </summary>
|
||||
public static class WebAuthnAssertion
|
||||
{
|
||||
public const byte FlagUserPresent = 0x01;
|
||||
public const byte FlagUserVerified = 0x04;
|
||||
|
||||
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
|
||||
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
|
||||
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
|
||||
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
|
||||
public static bool Verify(
|
||||
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
|
||||
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> 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;
|
||||
}
|
||||
|
||||
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
|
||||
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class AuthEndpoints
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().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<PasskeyRequestOptionsDto>().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<LoginResponse>().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<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/passkeys", ListPasskeysAsync)
|
||||
.Api("auth_passkeys_list", "The signed-in user's passkeys", Tag)
|
||||
.Returns<PageDto<PasskeyDto>>().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<PasskeyCreationOptionsDto>().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<PasskeyDto>(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<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().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);
|
||||
}
|
||||
|
||||
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
|
||||
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<string> 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<string>(origins, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。</summary>
|
||||
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<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok);
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
Result<LoginResult> 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<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
|
||||
{
|
||||
string userId = http.GetPrincipal().UserId ?? "";
|
||||
IReadOnlyList<PasskeyRecord> list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted);
|
||||
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
Result<CreationOptions> 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<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||
{
|
||||
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
|
||||
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
||||
? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) =>
|
||||
http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||
? TypedResults.NoContent()
|
||||
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -5,6 +5,18 @@ namespace MoShiro.Backend.Api;
|
||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
||||
|
||||
/// <summary>浏览器 <c>navigator.credentials.create</c> 要的参数;二进制字段都是 base64url。</summary>
|
||||
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||
|
||||
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
|
||||
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs);
|
||||
|
||||
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? 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<string> Transports);
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken);
|
||||
|
||||
@@ -74,6 +74,12 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||
[JsonSerializable(typeof(CapabilityCatalogDto))]
|
||||
[JsonSerializable(typeof(PasskeyCreationOptionsDto))]
|
||||
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
|
||||
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||
|
||||
@@ -35,6 +35,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<IngestService>();
|
||||
services.AddSingleton<WindowComputer>();
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
|
||||
@@ -12,6 +12,9 @@ public enum PrincipalKind
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
|
||||
public string AuthMethod { get; init; } = "";
|
||||
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
|
||||
|
||||
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
|
||||
|
||||
@@ -37,11 +37,22 @@ public sealed partial class AuthService
|
||||
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||
}
|
||||
|
||||
return await CreateSessionAsync(user, AuthMethods.Password, ct);
|
||||
}
|
||||
|
||||
/// <summary>已经确认了用户身份(例如 passkey 断言通过)之后建立会话。</summary>
|
||||
public async ValueTask<Result<LoginResult>> CreateSessionForAsync(string userId, string method, CancellationToken ct) =>
|
||||
await _store.GetUserAsync(userId, ct) is { } user
|
||||
? await CreateSessionAsync(user, method, ct)
|
||||
: Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||
|
||||
private async ValueTask<Result<LoginResult>> 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<LoginResult>.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<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
|
||||
@@ -15,8 +15,8 @@ public sealed class LoginRateLimiter
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。</summary>
|
||||
public bool TryAcquire(string source, out int retryAfterSeconds)
|
||||
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。<paramref name="limit"/> 缺省是登录失败的额度(每分钟 <see cref="BackendOptions.LoginAttemptsPerMinute"/> 次)。</summary>
|
||||
public bool TryAcquire(string source, out int retryAfterSeconds, int? limit = null)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
Queue<long> queue = _attempts.GetOrAdd(source, _ => new Queue<long>());
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> Transports, long CreatedAtMs, long? LastUsedAtMs);
|
||||
|
||||
/// <summary>这次请求的 WebAuthn 依赖方:<c>RpId</c> 是域名,<c>Origins</c> 是浏览器会填进 <c>clientDataJSON.origin</c> 的来源。</summary>
|
||||
public sealed record PasskeyContext(string RpId, IReadOnlySet<string> Origins);
|
||||
|
||||
public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||
|
||||
public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs);
|
||||
|
||||
public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
||||
|
||||
public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
/// <summary>
|
||||
/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期;
|
||||
/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。
|
||||
/// 设计见 docs/access.md 第 3 节。
|
||||
/// </summary>
|
||||
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<string, PendingChallenge> _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<Result<CreationOptions>> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId || principal.Username is not { } username)
|
||||
{
|
||||
return Result<CreationOptions>.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token");
|
||||
}
|
||||
|
||||
IReadOnlyList<PasskeyRecord> existing = await _store.ListPasskeysAsync(userId, ct);
|
||||
if (existing.Count >= MaxPasskeysPerUser)
|
||||
{
|
||||
return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user");
|
||||
}
|
||||
|
||||
string challenge = Issue(registration: true, userId);
|
||||
return Result<CreationOptions>.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000));
|
||||
}
|
||||
|
||||
public async ValueTask<Result<PasskeyRecord>> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
if (principal.UserId is not { } userId)
|
||||
{
|
||||
return Result<PasskeyRecord>.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<PasskeyRecord>("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<PasskeyRecord>("credential", "malformed registration");
|
||||
}
|
||||
|
||||
// 挑战在 clientDataJSON 里:先取出来,一次有效
|
||||
if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge))
|
||||
{
|
||||
return Result<PasskeyRecord>.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<PasskeyRecord>.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<PasskeyRecord>.Ok(record)
|
||||
: Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered");
|
||||
}
|
||||
|
||||
public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, null), context.RpId, 60_000);
|
||||
|
||||
/// <summary>验证断言并建立会话。失败一律 unauthorized,不区分原因。</summary>
|
||||
public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
Result<LoginResult> denied = Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature))
|
||||
{
|
||||
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<IReadOnlyList<PasskeyRecord>> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct);
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct);
|
||||
|
||||
private string Issue(bool registration, string? userId)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary>
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>注册成功后要保存的凭证。</summary>
|
||||
public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount);
|
||||
|
||||
/// <summary>
|
||||
/// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理:
|
||||
/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。
|
||||
/// </summary>
|
||||
public static class WebAuthnRegistration
|
||||
{
|
||||
public static RegisteredCredential? Verify(
|
||||
ReadOnlySpan<byte> attestationObject, ReadOnlySpan<byte> clientDataJson, string rpId, ReadOnlySpan<byte> expectedChallenge,
|
||||
Func<string, bool> originAllowed, bool requireUserVerification)
|
||||
{
|
||||
if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary<object, object> 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<object, object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。</summary>
|
||||
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<object?>();
|
||||
for (long i = 0; i < argument; i++)
|
||||
{
|
||||
list.Add(ReadValue(depth + 1));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
case 5:
|
||||
{
|
||||
CheckCount(argument);
|
||||
var map = new Dictionary<object, object>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ public sealed class BackendOptions
|
||||
|
||||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||||
|
||||
/// <summary>WebAuthn 的依赖方域名;缺省取 <see cref="PublicUrl"/> 的主机,没有就取请求的主机。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。</summary>
|
||||
public string? WebAuthnRpId { get; set; }
|
||||
|
||||
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||
public string? WebAuthnOrigins { get; set; }
|
||||
|
||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||
public bool SecureCookies { get; set; } = true;
|
||||
|
||||
|
||||
@@ -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<string> Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs);
|
||||
|
||||
@@ -31,6 +31,18 @@ public interface IAuthStore
|
||||
|
||||
ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct);
|
||||
|
||||
/// <summary>凭证 id 已经存在时返回 false。</summary>
|
||||
ValueTask<bool> AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct);
|
||||
|
||||
ValueTask<PasskeyRecord?> FindPasskeyAsync(string credentialId, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct);
|
||||
|
||||
/// <summary>只能删自己的。</summary>
|
||||
ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct);
|
||||
|
||||
ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<SessionRecord?> 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<bool> AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct) =>
|
||||
_db.WriteAsync(
|
||||
c => c.Scalar<long>("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<PasskeyRecord?> FindPasskeyAsync(string credentialId, CancellationToken ct) =>
|
||||
_db.ReadAsync(c => c.QueryOne($"SELECT {PasskeyColumns} FROM passkeys WHERE id = @id", ReadPasskey, ("@id", credentialId)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct);
|
||||
|
||||
public ValueTask<bool> 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<ApiTokenRecord?> 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));
|
||||
|
||||
@@ -35,8 +35,8 @@ public sealed class CapabilityTests
|
||||
}
|
||||
}
|
||||
|
||||
// 只有登录是匿名的
|
||||
Assert.Equal(["/api/admin/auth/login"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.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<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。</summary>
|
||||
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)];
|
||||
}
|
||||
|
||||
/// <summary>passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/access.md 第 3 节。</summary>
|
||||
[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<HttpClient> 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<string>());
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
|
||||
private async Task<string> 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<string>());
|
||||
return body["challenge"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
private async Task<string> 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<string>();
|
||||
}
|
||||
|
||||
private async Task<SoftwarePasskey> 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<HttpStatusCode> 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<string>() == passkey.Id && p["name"]!.GetValue<string>() == "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<string>());
|
||||
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<string>());
|
||||
JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me"));
|
||||
Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue<string>());
|
||||
Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue<string>() == "fleet.read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused()
|
||||
{
|
||||
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
|
||||
using 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<HttpStatusCode> 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<string>() == 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())));
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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 拉镜像)。
|
||||
|
||||
@@ -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、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
|
||||
|
||||
+146
-19
@@ -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<AbortController | null>(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求
|
||||
// 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持
|
||||
const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown')
|
||||
const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环
|
||||
const usePasskey = async () => {
|
||||
setBusy(true)
|
||||
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<typeof setTimeout> | 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() {
|
||||
</aside>
|
||||
<section className="login-main stack">
|
||||
<h2>{title}</h2>
|
||||
{adding ? (
|
||||
<Field label="后端地址">
|
||||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
{canPasskey ? (
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'password' as const, label: '账号密码' },
|
||||
{ value: 'passkey' as const, label: 'Passkey' },
|
||||
]}
|
||||
value={method}
|
||||
onChange={pickMethod}
|
||||
/>
|
||||
) : null}
|
||||
<Field label="用户名">
|
||||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{adding ? '保存并登录' : '登录'}
|
||||
</button>
|
||||
{usingPasskey ? (
|
||||
<>
|
||||
<p className="muted">用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。</p>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus>
|
||||
<KeyRound size={16} />
|
||||
使用 Passkey 登录
|
||||
</button>
|
||||
<p className="faint" style={{ fontSize: 12 }}>
|
||||
还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{adding ? (
|
||||
<Field label="后端地址">
|
||||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="用户名">
|
||||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} />
|
||||
</Field>
|
||||
{canPasskey ? (
|
||||
<p className="faint" style={{ margin: '-6px 0 0', fontSize: 12 }}>
|
||||
{autofill === 'yes'
|
||||
? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。'
|
||||
: autofill === 'no'
|
||||
? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。'
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
<Field label="密码">
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{adding ? '保存并登录' : '登录'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
@@ -164,6 +290,7 @@ export function App() {
|
||||
</aside>
|
||||
<div className="main">
|
||||
<BackendUpdateNotice />
|
||||
<PasskeyNotice />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/agents" replace />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
|
||||
@@ -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<Page<PasskeyDto>>('/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 (
|
||||
<div className="notice" style={{ marginBottom: 16 }}>
|
||||
<div className="row wrap">
|
||||
<KeyRound size={16} />
|
||||
<span>
|
||||
<b>建议添加 Passkey</b>:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。
|
||||
</span>
|
||||
<span className="right row">
|
||||
<button type="button" className="btn small text" onClick={later}>
|
||||
7 天后再提醒
|
||||
</button>
|
||||
<button type="button" className="btn small primary" onClick={() => setAdding(true)}>
|
||||
现在添加
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{adding ? (
|
||||
<Dialog
|
||||
title="添加 Passkey"
|
||||
onClose={() => setAdding(false)}
|
||||
actions={
|
||||
<button type="button" className="btn text" onClick={() => setAdding(false)}>
|
||||
关闭
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p className="muted" style={{ marginBottom: 12 }}>
|
||||
建议至少注册两个(比如这台电脑和手机),其中一个丢了还能登录。也可以随时在「系统 → 用户 → 我的 Passkey」里管理。
|
||||
</p>
|
||||
<PasskeyAddForm
|
||||
compact
|
||||
onAdded={() => {
|
||||
setAdding(false)
|
||||
setMissing(false)
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-1
@@ -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<void>
|
||||
/** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */
|
||||
loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -52,6 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setMe(await api.get<MeDto>('/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<MeDto>('/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 <AuthContext.Provider value={{ me, ready, login, logout }}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Auth {
|
||||
|
||||
@@ -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<PasskeyPlace>('device')
|
||||
const supported = passkeySupported()
|
||||
return (
|
||||
<div className={compact ? 'stack' : 'row wrap'} style={{ marginTop: compact ? 0 : 12 }}>
|
||||
<input className="input" style={{ maxWidth: compact ? undefined : 220 }} placeholder="名称,例如 MacBook" value={name} onChange={(e) => setName(e.target.value)} disabled={!supported} />
|
||||
<select className="input" style={{ maxWidth: compact ? undefined : 320 }} value={place} onChange={(e) => setPlace(e.target.value as PasskeyPlace)} disabled={!supported} title="保存位置只是给浏览器的提示,最终由浏览器和系统决定">
|
||||
<option value="device">保存在此设备或浏览器账户(Google 密码管理工具、Windows Hello)</option>
|
||||
<option value="external">安全密钥,或用手机扫码</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={busy || !supported || !name.trim()}
|
||||
title={supported ? '' : '这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)'}
|
||||
onClick={async () => {
|
||||
let added: string | undefined
|
||||
const ok = await run(async () => {
|
||||
try {
|
||||
added = (await registerPasskey(name.trim(), place)).name
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') throw new Error('已取消')
|
||||
throw e
|
||||
}
|
||||
})
|
||||
if (ok) {
|
||||
// 成功提示停 10 秒:说清楚存了什么、下一步怎么用,点一下可以提前关掉
|
||||
toast(`「${added}」已经保存。下次登录时点用户名框选它,或者点「使用 Passkey 登录」;建议再添加一个备用的。`, false, { title: 'Passkey 添加成功', ms: 10_000 })
|
||||
onAdded()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
添加 Passkey
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Page<PasskeyDto>>('/api/admin/auth/passkeys'), [])
|
||||
const { run } = useAction()
|
||||
const [removing, setRemoving] = useState<PasskeyDto>()
|
||||
const items = list.data?.items ?? []
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<b>我的 Passkey</b>
|
||||
<span className="muted">用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。</span>
|
||||
</div>
|
||||
<ErrorNotice error={list.error} />
|
||||
{items.length === 0 ? (
|
||||
<Empty>还没有 Passkey</Empty>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>创建</th>
|
||||
<th>最后使用</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{p.name} {p.transports.includes('internal') ? <Chip>本机</Chip> : p.transports.length > 0 ? <Chip>{p.transports.join('、')}</Chip> : null}
|
||||
</td>
|
||||
<td className="muted">{fmtTime(p.created_at)}</td>
|
||||
<td className="muted">{p.last_used_at ? fmtTime(p.last_used_at) : '从未'}</td>
|
||||
<td className="r">
|
||||
<button type="button" className="btn small danger" onClick={() => setRemoving(p)}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<PasskeyAddForm onAdded={list.reload} />
|
||||
{removing ? (
|
||||
<Confirm title="删除 Passkey" danger confirmText="删除" onClose={() => setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}>
|
||||
「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Users() {
|
||||
const users = useQuery(() => api.get<Page<UserDto>>('/api/admin/users'), [])
|
||||
const { run } = useAction()
|
||||
@@ -502,6 +556,8 @@ function Users() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
return (
|
||||
<>
|
||||
<Passkeys />
|
||||
<div className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="muted">能登录管理端的账号。</span>
|
||||
@@ -576,6 +632,7 @@ function Users() {
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
|
||||
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
|
||||
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
||||
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
||||
export type PasskeyDto = Narrow<S['PasskeyDto']>
|
||||
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
||||
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
|
||||
export type MeDto = Narrow<S['MeDto']>
|
||||
export type SettingDto = Narrow<S['SettingDto']>
|
||||
export type UserDto = Narrow<S['UserDto']>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { api } from './client'
|
||||
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
|
||||
|
||||
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
|
||||
export async function conditionalSupported(): Promise<boolean> {
|
||||
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<ArrayBuffer> {
|
||||
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<LoginResponse> {
|
||||
const options = await api.post<PasskeyRequestOptionsDto>('/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<LoginResponse>('/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<PasskeyDto> {
|
||||
const options = await api.post<PasskeyCreationOptionsDto>('/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<PasskeyDto>('/api/admin/auth/passkeys', {
|
||||
name,
|
||||
credential_id: credential.id,
|
||||
attestation_object: toText(response.attestationObject),
|
||||
client_data_json: toText(response.clientDataJSON),
|
||||
transports: response.getTransports?.() ?? [],
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+14
-5
@@ -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<ToastItem[]>([])
|
||||
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 (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="toasts" aria-live="polite">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
|
||||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`} role="status" onClick={() => dismiss(t.id)} title="点一下关闭">
|
||||
{t.title ? <div className="toast-title">{t.title}</div> : null}
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user