feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]

This commit is contained in:
justme committed 2026-10-07 22:29:24 +08:00
1 parent 1379b8c7cb
commit ff45ad086e
27 files changed
+1305 -58

No files matched your search

@@ -51,6 +51,22 @@ public static class AuthEndpoints
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/elevation", ListElevation)
.Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag)
.Returns<PageDto<ElevationDto>>().ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync)
.Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag)
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(400, 401, 403, 429)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/elevation", ElevateAsync)
.Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag)
.Returns<ElevationDto>().ProducesProblems(400, 401, 403, 429)
.Requires(Capability.Authenticated);
guarded.MapDelete("/auth/elevation", EndElevation)
.Api("auth_elevation_end", "Close all elevation windows of this session", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401)
@@ -163,8 +179,20 @@ public static class AuthEndpoints
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
}
/// <summary>已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。</summary>
private static async Task<IResult?> CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume)
{
string? userId = http.GetPrincipal().UserId;
return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume);
}
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
{
if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed)
{
return needed;
}
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
{
@@ -177,6 +205,11 @@ public static class AuthEndpoints
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
{
if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed)
{
return needed;
}
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)
@@ -184,13 +217,91 @@ public static class AuthEndpoints
: 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)
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
{
if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
{
return needed;
}
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
? TypedResults.NoContent()
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
{
IReadOnlyList<ElevationWindow> open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : [];
return TypedResults.Json(new PageDto<ElevationDto>([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto);
}
private static async Task<IResult> ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute);
if (!ok)
{
return limit;
}
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
{
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
}
Result<RequestOptions> result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out RequestOptions? o, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto);
}
private static async Task<IResult> ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "elevation:");
if (!ok)
{
return limit;
}
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
{
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
}
Principal principal = http.GetPrincipal();
Result<ElevationWindow> result = await passkeys.ElevateAsync(
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
{
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.Connection.RemoteIpAddress?.ToString(), false, http.RequestAborted);
return Problems.From(http, error);
}
http.SetDetail(Elevation.IdOf(elevation));
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto);
}
private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation)
{
if (http.GetPrincipal().SessionId is { } session)
{
elevation.End(session);
}
return TypedResults.NoContent();
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation)
{
if (http.GetPrincipal().SessionId is { } own)
{
elevation.End(own);
}
string? bearer = PrincipalResolver.BearerOf(http);
if (bearer is not null)
{
@@ -43,7 +43,7 @@ public static class SystemEndpoints
group.MapGet("/users", ListUsersAsync)
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
.Requires(Capability.AccountsManage);
.Requires(Capability.AccountsRead);
group.MapPost("/users", CreateUserAsync)
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage);
@@ -64,6 +64,11 @@ public static class SystemEndpoints
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
{
if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
{
return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次
}
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))