feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]

This commit is contained in:
justme committed 2026-10-07 21:57:19 +08:00
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;
}
}
}
+477
View File
@@ -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())));
}
}