feat(auth): 添加统一 API 和 PC 认证端点

- 新增 API 端 JWT 登录、refresh token 轮换和退出登录流程
- 新增 refresh token 实体、DbSet 配置和 EF Core 迁移
- 新增 PC 端授权码登录、本地全局 token 刷新、登出和鉴权服务
- 扩展统一端点模型,支持宿主过滤、角色鉴权、OpenAPI 元数据和 DI 服务处理器
- API 启用 JwtBearer 认证、Swagger UI 和认证端点注册
- PC 端注册认证服务,并按宿主过滤桌面拦截端点
This commit is contained in:
2026-05-15 17:35:07 +08:00
parent c5f741e6a4
commit a9abd90874
26 changed files with 1295 additions and 31 deletions
+14 -8
View File
@@ -12,15 +12,19 @@ namespace Avalonia_Services.Core
/// <summary>
/// 打印所有已注册端点到控制台。
/// </summary>
public static void PrintEndpoints(ServiceEndpointCollection collection, string? title = null)
public static void PrintEndpoints(
ServiceEndpointCollection collection,
string? title = null,
EndpointHostTarget host = EndpointHostTarget.All)
{
title ??= "API Endpoints";
var endpoints = collection.ForHost(host).ToList();
var maxMethodLen = collection.Endpoints.Count > 0
? collection.Endpoints.Max(e => e.HttpMethod.Length)
var maxMethodLen = endpoints.Count > 0
? endpoints.Max(e => e.HttpMethod.Length)
: 4;
var maxPathLen = collection.Endpoints.Count > 0
? collection.Endpoints.Max(e => e.Pattern.Length)
var maxPathLen = endpoints.Count > 0
? endpoints.Max(e => e.Pattern.Length)
: 8;
var totalWidth = maxMethodLen + maxPathLen + 5;
@@ -31,9 +35,11 @@ namespace Avalonia_Services.Core
Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║");
Console.WriteLine($"╟{separator}╢");
foreach (var ep in collection.Endpoints.OrderBy(e => e.Pattern))
foreach (var ep in endpoints.OrderBy(e => e.Pattern))
{
var auth = ep.RequireAuthorization ? (ep.Policy ?? "✓") : "—";
var auth = ep.RequireAuthorization
? (ep.Roles.Count > 0 ? string.Join(",", ep.Roles) : ep.Policy ?? "✓")
: "—";
var methodColor = ep.HttpMethod switch
{
"GET" => ConsoleColor.Green,
@@ -57,7 +63,7 @@ namespace Avalonia_Services.Core
}
Console.WriteLine($"╚{separator}╝");
Console.WriteLine($" Total: {collection.Endpoints.Count} endpoint(s)");
Console.WriteLine($" Total: {endpoints.Count} endpoint(s)");
Console.WriteLine();
}
}
@@ -1,9 +1,18 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_Services.Core
{
[Flags]
public enum EndpointHostTarget
{
Api = 1,
Pc = 2,
All = Api | Pc,
}
/// <summary>
/// 单个端点定义。
/// </summary>
@@ -18,6 +27,21 @@ namespace Avalonia_Services.Core
/// <summary>端点名称(用于 OpenAPI / 日志)</summary>
public string? Name { get; set; }
/// <summary>OpenAPI 分组标签。</summary>
public string? OpenApiTag { get; set; }
/// <summary>OpenAPI 摘要。</summary>
public string? OpenApiSummary { get; set; }
/// <summary>OpenAPI 描述。</summary>
public string? OpenApiDescription { get; set; }
/// <summary>OpenAPI 请求体类型。</summary>
public Type? OpenApiRequestType { get; set; }
/// <summary>OpenAPI 200 响应数据类型。</summary>
public Type? OpenApiResponseType { get; set; }
/// <summary>端点处理器</summary>
public Func<ServiceEndpointContext, Task<object?>> Handler { get; init; } = _ => Task.FromResult<object?>(null);
@@ -30,6 +54,12 @@ namespace Avalonia_Services.Core
/// <summary>鉴权策略名</summary>
public string? Policy { get; set; }
/// <summary>允许访问该端点的角色。多个角色满足任意一个即可。</summary>
public List<string> Roles { get; } = new();
/// <summary>端点挂载的宿主。默认 API 和 PC 都挂载。</summary>
public EndpointHostTarget HostTarget { get; set; } = EndpointHostTarget.All;
/// <summary>
/// 设置端点名称(Fluent API)。
/// </summary>
@@ -38,6 +68,64 @@ namespace Avalonia_Services.Core
Name = name;
return this;
}
public ServiceEndpoint WithOpenApi(
string tag,
string summary,
string? description = null,
Type? requestType = null,
Type? responseType = null)
{
OpenApiTag = tag;
OpenApiSummary = summary;
OpenApiDescription = description;
OpenApiRequestType = requestType;
OpenApiResponseType = responseType;
return this;
}
/// <summary>
/// 标记端点需要登录。
/// </summary>
public ServiceEndpoint RequireAuth()
{
RequireAuthorization = true;
return this;
}
/// <summary>
/// 标记端点需要指定角色。多个角色满足任意一个即可。
/// </summary>
public ServiceEndpoint RequireRoles(params string[] roles)
{
RequireAuthorization = true;
Roles.Clear();
Roles.AddRange(roles.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim()));
return this;
}
/// <summary>
/// 只挂载到 Avalonia-API。
/// </summary>
public ServiceEndpoint ApiOnly()
{
HostTarget = EndpointHostTarget.Api;
return this;
}
/// <summary>
/// 只挂载到 Avalonia-PC。
/// </summary>
public ServiceEndpoint PcOnly()
{
HostTarget = EndpointHostTarget.Pc;
return this;
}
public bool SupportsHost(EndpointHostTarget host)
{
return (HostTarget & host) != 0;
}
}
/// <summary>
@@ -48,6 +136,11 @@ namespace Avalonia_Services.Core
/// <summary>所有已注册的端点</summary>
public List<ServiceEndpoint> Endpoints { get; } = new();
public IEnumerable<ServiceEndpoint> ForHost(EndpointHostTarget host)
{
return Endpoints.Where(endpoint => endpoint.SupportsHost(host));
}
/// <summary>作用于所有端点的全局过滤器</summary>
public List<IEndpointFilter> GlobalFilters { get; } = new();
@@ -59,6 +152,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "GET", handler);
}
public ServiceEndpoint MapGet<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapGet(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个 POST 端点。
/// </summary>
@@ -67,6 +168,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "POST", handler);
}
public ServiceEndpoint MapPost<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapPost(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个 PUT 端点。
/// </summary>
@@ -75,6 +184,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "PUT", handler);
}
public ServiceEndpoint MapPut<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapPut(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个 DELETE 端点。
/// </summary>
@@ -83,6 +200,14 @@ namespace Avalonia_Services.Core
return AddEndpoint(pattern, "DELETE", handler);
}
public ServiceEndpoint MapDelete<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapDelete(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 添加全局过滤器(作用于所有端点)。
/// </summary>
@@ -112,6 +237,21 @@ namespace Avalonia_Services.Core
Endpoints.Add(endpoint);
return endpoint;
}
private static Func<ServiceEndpointContext, Task<object?>> CreateServiceHandler<TService>(
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return async ctx =>
{
var serviceProvider = ctx.Items["ServiceProvider"] as IServiceProvider
?? throw new InvalidOperationException("ServiceProvider 未注入。");
await using var scope = serviceProvider.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<TService>();
return await handler(service, ctx);
};
}
}
/// <summary>
+1 -4
View File
@@ -4,10 +4,6 @@ using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Avalonia_Services.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Avalonia_Services.Endpoints
{
@@ -40,6 +36,7 @@ namespace Avalonia_Services.Endpoints
// ---- 业务端点注册 ----
// 天气预报(从数据库读取)
endpoints.MapGet("api/wData", GetWeatherForecastsAsync)
.WithOpenApi("Weather", "获取天气预报信息。")
.WithName("GetWeatherForecast");
// 获取用户(演示从数据库查询)
@@ -0,0 +1,53 @@
using Avalonia_Services.Core;
using Avalonia_Services.Services.AuthService;
namespace Avalonia_Services.Endpoints
{
/// <summary>
/// 认证端点统一入口。端点定义在这里,宿主项目只提供对应实现。
/// </summary>
public static class AuthEndpoints
{
public static void ConfigureApi(ServiceEndpointBuilder builder)
{
builder.ConfigureEndpoints(endpoints =>
{
endpoints.MapPost<IApiAuthEndpointService>("api/auth/login", (service, ctx) => service.LoginAsync(ctx))
.WithName("ApiLogin")
.WithOpenApi("Auth", "API 登录,返回 access token 和 refresh token。", "", typeof(ApiLoginRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService>("api/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
.WithName("ApiRefresh")
.WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService>("api/auth/logout", (service, ctx) => service.LogoutAsync(ctx))
.WithName("ApiLogout")
.WithOpenApi("Auth", "API 退出登录并吊销 refresh token。", "", typeof(ApiLogoutRequest))
.ApiOnly();
});
}
public static void ConfigurePc(ServiceEndpointBuilder builder)
{
builder.ConfigureEndpoints(endpoints =>
{
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/authorize", (service, ctx) => service.AuthorizeAsync(ctx))
.WithName("PcAuthorize")
.WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
.WithName("PcRefresh")
.WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/logout", (service, ctx) => service.LogoutAsync(ctx))
.WithName("PcLogout")
.WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest))
.PcOnly();
});
}
}
}
@@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia_Services.Core;
namespace Avalonia_Services.Extensions
@@ -75,6 +70,7 @@ namespace Avalonia_Services.Extensions
{
// 查找匹配的端点(忽略大小写 + 方法匹配)
var endpoint = _endpoints.Endpoints.FirstOrDefault(e =>
e.SupportsHost(EndpointHostTarget.Pc) &&
string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase));
@@ -108,7 +104,18 @@ namespace Avalonia_Services.Extensions
return RouteResult.Success(ctx.ResponseBody, ctx);
}
if (!string.IsNullOrEmpty(endpoint.Policy))
if (endpoint.Roles.Count > 0)
{
var authorized = await _authService.AuthorizeAsync(user, $"roles:{string.Join(',', endpoint.Roles)}");
if (!authorized)
{
ctx.StatusCode = 403;
ctx.StatusMessage = "Forbidden";
ctx.ResponseBody = new { success = false, error = "Forbidden" };
return RouteResult.Success(ctx.ResponseBody, ctx);
}
}
else if (!string.IsNullOrEmpty(endpoint.Policy))
{
var authorized = await _authService.AuthorizeAsync(user, endpoint.Policy);
if (!authorized)
@@ -0,0 +1,37 @@
namespace Avalonia_Services.Services.AuthService
{
public sealed record ApiLoginRequest(string? Account, string? Password, string[]? Roles = null);
public sealed record ApiRefreshTokenRequest(string? RefreshToken);
public sealed record ApiLogoutRequest(string? RefreshToken);
public sealed record AuthTokenResponse(
string AccessToken,
string RefreshToken,
DateTime AccessTokenExpiresAt,
DateTime RefreshTokenExpiresAt,
string[] Roles);
public sealed record PcAuthorizeRequest(string? AuthorizationCode);
public sealed record PcRefreshRequest(string? Token);
public sealed record PcLogoutRequest(string? Token);
public sealed record PcTokenResponse(string Token, DateTime ExpiresAt, string[] Roles);
public enum ThirdPartyAuthCheckResult
{
Valid,
AuthorizationLost,
TemporaryFailure,
}
public interface IPcThirdPartyAuthorizationClient
{
Task<ThirdPartyAuthCheckResult> ValidateAuthorizationCodeAsync(string authorizationCode, CancellationToken cancellationToken = default);
Task<ThirdPartyAuthCheckResult> RefreshAuthorizationAsync(string authorizationReference, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,23 @@
using Avalonia_Services.Core;
using System.Threading.Tasks;
namespace Avalonia_Services.Services.AuthService
{
public interface IApiAuthEndpointService
{
Task<object?> LoginAsync(ServiceEndpointContext ctx);
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
}
public interface IPcAuthEndpointService
{
Task<object?> AuthorizeAsync(ServiceEndpointContext ctx);
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
}
}