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

@@ -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);