feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI]
This commit is contained in:
1 parent
f704c66dd9
commit
4106f831dc
34 files changed
+191
-1364
No files matched your search
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary>
|
||||
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
private const string Tag = "admin-audit";
|
||||
|
||||
@@ -51,22 +51,6 @@ 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)
|
||||
@@ -179,20 +163,8 @@ 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))
|
||||
{
|
||||
@@ -205,11 +177,6 @@ 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)
|
||||
@@ -219,89 +186,13 @@ public static class AuthEndpoints
|
||||
|
||||
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 Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
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.ClientAddress(), 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)
|
||||
{
|
||||
|
||||
@@ -64,11 +64,6 @@ 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))
|
||||
|
||||
@@ -22,10 +22,9 @@ public static class TrustEndpoints
|
||||
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
guarded.MapPost("/trust", CommitAsync)
|
||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag)
|
||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
|
||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage)
|
||||
.ElevationExempt();
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||
@@ -60,12 +59,6 @@ public static class TrustEndpoints
|
||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||
{
|
||||
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||
// 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根
|
||||
if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||
{
|
||||
|
||||
@@ -18,11 +18,6 @@ public static class EndpointConventions
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
||||
/// </summary>
|
||||
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
@@ -60,9 +60,6 @@ public static class PrincipalResolver
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
||||
public sealed record ElevationExemptMarker;
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
@@ -92,12 +89,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
|
||||
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||
{
|
||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||
@@ -120,24 +111,6 @@ public static class CapabilityGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。</summary>
|
||||
public static class ElevationGate
|
||||
{
|
||||
/// <summary>没问题返回 null;否则 403 <c>elevation_required</c>,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。</summary>
|
||||
public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (principal.UserId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal.SessionId is { } session && http.RequestServices.GetRequiredService<ElevationService>().Check(session, elevation, consume)
|
||||
? null
|
||||
: Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
|
||||
@@ -156,9 +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), t.CreatedBy);
|
||||
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
|
||||
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||
|
||||
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
|
||||
|
||||
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||
Add(
|
||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
||||
|
||||
@@ -10,16 +10,10 @@ public sealed record LoginRequest(string Username, string Password, bool? Return
|
||||
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>
|
||||
/// <param name="AllowCredentials">只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。</param>
|
||||
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
|
||||
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
||||
|
||||
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
|
||||
public sealed record ElevationRequest(string Class);
|
||||
|
||||
public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt);
|
||||
|
||||
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);
|
||||
@@ -28,7 +22,7 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
@@ -83,7 +77,13 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>?
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
||||
|
||||
/// <param name="Added">相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。</param>
|
||||
/// <param name="Removed">相对当前生效的集合移除的 passkey 凭证 id。</param>
|
||||
/// <param name="ServerKeyChanged">服务器密钥有变化(换了新的)。</param>
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredentialDto> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||
|
||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
|
||||
@@ -81,10 +81,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(ElevationRequest))]
|
||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||
[JsonSerializable(typeof(ElevationDto))]
|
||||
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
|
||||
Reference in new issue
Block a user