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:
@@ -0,0 +1,38 @@
|
||||
using Avalonia_Services.Services.AuthService;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Avalonia_PC.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// 第三方授权客户端占位实现。接入真实第三方接口时替换此服务即可。
|
||||
/// </summary>
|
||||
public sealed class DefaultPcThirdPartyAuthorizationClient : IPcThirdPartyAuthorizationClient
|
||||
{
|
||||
public Task<ThirdPartyAuthCheckResult> ValidateAuthorizationCodeAsync(
|
||||
string authorizationCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationCode) ||
|
||||
string.Equals(authorizationCode, "invalid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
|
||||
}
|
||||
|
||||
return Task.FromResult(ThirdPartyAuthCheckResult.Valid);
|
||||
}
|
||||
|
||||
public Task<ThirdPartyAuthCheckResult> RefreshAuthorizationAsync(
|
||||
string authorizationReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.Equals(authorizationReference, "invalid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
|
||||
}
|
||||
|
||||
return Task.FromResult(ThirdPartyAuthCheckResult.TemporaryFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Authentication;
|
||||
using Avalonia_Common.Core;
|
||||
using Avalonia_Services.Core;
|
||||
using Avalonia_Services.Services.AuthService;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Avalonia_PC.Authentication
|
||||
{
|
||||
public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task<object?> AuthorizeAsync(ServiceEndpointContext ctx)
|
||||
{
|
||||
var request = Deserialize<PcAuthorizeRequest>(ctx.Body);
|
||||
var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode);
|
||||
if (token is null)
|
||||
{
|
||||
ctx.StatusCode = 401;
|
||||
return ResponseHelper.Failure(401, "授权失败");
|
||||
}
|
||||
|
||||
return ResponseHelper.Ok(token, "授权成功");
|
||||
}
|
||||
|
||||
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
|
||||
{
|
||||
var request = Deserialize<PcRefreshRequest>(ctx.Body);
|
||||
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
|
||||
var refreshed = await tokenService.RefreshAsync(token);
|
||||
if (refreshed is null)
|
||||
{
|
||||
ctx.StatusCode = 401;
|
||||
return ResponseHelper.Failure(401, "授权已失效");
|
||||
}
|
||||
|
||||
return ResponseHelper.Ok(refreshed, "刷新成功");
|
||||
}
|
||||
|
||||
public Task<object?> LogoutAsync(ServiceEndpointContext ctx)
|
||||
{
|
||||
var request = Deserialize<PcLogoutRequest>(ctx.Body);
|
||||
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
|
||||
tokenService.Logout(token);
|
||||
return Task.FromResult<object?>(ResponseHelper.Succeed("退出成功"));
|
||||
}
|
||||
|
||||
private static T? Deserialize<T>(string? body)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(body)
|
||||
? default
|
||||
: JsonSerializer.Deserialize<T>(body, JsonOptions);
|
||||
}
|
||||
|
||||
private static string? ExtractBearerToken(string? authorization)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string prefix = "Bearer ";
|
||||
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
? authorization[prefix.Length..].Trim()
|
||||
: authorization.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Authentication;
|
||||
using Avalonia_Services.Core;
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Avalonia_PC.Authentication
|
||||
{
|
||||
public sealed class PcAuthService(PcGlobalTokenService tokenService) : IAuthService
|
||||
{
|
||||
public async Task<ClaimsPrincipal?> AuthenticateAsync(ServiceEndpointContext context)
|
||||
{
|
||||
var token = ExtractBearerToken(context.GetHeader("Authorization"));
|
||||
if (!await tokenService.ValidateAsync(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "pc-local"),
|
||||
new Claim(ClaimTypes.Name, "PC授权用户"),
|
||||
new Claim(ClaimTypes.Role, "SuperAdmin"),
|
||||
new Claim(ClaimTypes.Role, "Admin"),
|
||||
new Claim("auth_type", "pc-global-token"),
|
||||
],
|
||||
"pc-global-token");
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, string policy)
|
||||
{
|
||||
return Task.FromResult(user.Identity?.IsAuthenticated == true);
|
||||
}
|
||||
|
||||
private static string? ExtractBearerToken(string? authorization)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string prefix = "Bearer ";
|
||||
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
? authorization[prefix.Length..].Trim()
|
||||
: authorization.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Avalonia_Services.Services.AuthService;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Authentication
|
||||
{
|
||||
public sealed class PcGlobalTokenService(IPcThirdPartyAuthorizationClient thirdPartyClient)
|
||||
{
|
||||
private static readonly string[] SuperRoles = ["SuperAdmin", "Admin"];
|
||||
private readonly object _syncRoot = new();
|
||||
private PcTokenState? _current;
|
||||
|
||||
private static readonly TimeSpan NormalLifetime = TimeSpan.FromHours(8);
|
||||
private static readonly TimeSpan TemporaryFailureLifetime = TimeSpan.FromMinutes(20);
|
||||
private static readonly TimeSpan MaxTemporaryFailureWindow = TimeSpan.FromHours(24);
|
||||
|
||||
public async Task<PcTokenResponse?> AuthorizeAsync(string? authorizationCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await thirdPartyClient.ValidateAuthorizationCodeAsync(authorizationCode, cancellationToken);
|
||||
if (result != ThirdPartyAuthCheckResult.Valid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return IssueToken(authorizationCode, NormalLifetime, resetTemporaryFailureWindow: true);
|
||||
}
|
||||
|
||||
public async Task<PcTokenResponse?> RefreshAsync(string? token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PcTokenState? current;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
current = IsCurrentToken(token) ? _current : null;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await thirdPartyClient.RefreshAuthorizationAsync(current.AuthorizationReference, cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
ThirdPartyAuthCheckResult.Valid => IssueToken(current.AuthorizationReference, NormalLifetime, resetTemporaryFailureWindow: true),
|
||||
ThirdPartyAuthCheckResult.AuthorizationLost => ClearAndReturnNull(),
|
||||
ThirdPartyAuthCheckResult.TemporaryFailure => RefreshAfterTemporaryFailure(current),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string? token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PcTokenState? current;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!IsCurrentToken(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
current = _current;
|
||||
if (current is not null && current.ExpiresAt > DateTime.UtcNow)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return await RefreshAsync(token, cancellationToken) is not null;
|
||||
}
|
||||
|
||||
public void Logout(string? token)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (IsCurrentToken(token))
|
||||
{
|
||||
_current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PcTokenResponse IssueToken(string authorizationReference, TimeSpan lifetime, bool resetTemporaryFailureWindow)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
var now = DateTime.UtcNow;
|
||||
var state = new PcTokenState(
|
||||
HashToken(token),
|
||||
authorizationReference,
|
||||
now.Add(lifetime),
|
||||
resetTemporaryFailureWindow ? null : _current?.TemporaryFailureStartedAt ?? now);
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_current = state;
|
||||
}
|
||||
|
||||
return new PcTokenResponse(token, state.ExpiresAt, SuperRoles);
|
||||
}
|
||||
|
||||
private PcTokenResponse? RefreshAfterTemporaryFailure(PcTokenState current)
|
||||
{
|
||||
var startedAt = current.TemporaryFailureStartedAt ?? DateTime.UtcNow;
|
||||
if (DateTime.UtcNow - startedAt > MaxTemporaryFailureWindow)
|
||||
{
|
||||
return ClearAndReturnNull();
|
||||
}
|
||||
|
||||
return IssueToken(current.AuthorizationReference, TemporaryFailureLifetime, resetTemporaryFailureWindow: false);
|
||||
}
|
||||
|
||||
private PcTokenResponse? ClearAndReturnNull()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_current = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsCurrentToken(string? token)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(token) &&
|
||||
_current is not null &&
|
||||
string.Equals(_current.TokenHash, HashToken(token), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private sealed record PcTokenState(
|
||||
string TokenHash,
|
||||
string AuthorizationReference,
|
||||
DateTime ExpiresAt,
|
||||
DateTime? TemporaryFailureStartedAt);
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -1,10 +1,13 @@
|
||||
using Avalonia;
|
||||
using Authentication;
|
||||
using Avalonia;
|
||||
using Avalonia_Common.Infrastructure;
|
||||
using Avalonia_EFCore.Database;
|
||||
using Avalonia_PC.Authentication;
|
||||
using Avalonia_PC.Views;
|
||||
using Avalonia_Services.Core;
|
||||
using Avalonia_Services.Endpoints;
|
||||
using Avalonia_Services.Services;
|
||||
using Avalonia_Services.Services.AuthService;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using System;
|
||||
@@ -28,10 +31,6 @@ namespace Avalonia_PC
|
||||
// 初始化数据库(自动迁移 + 种子数据)
|
||||
Services.InitializeDatabase<AppDataContext>();
|
||||
|
||||
// 启动时打印所有拦截的接口
|
||||
var endpoints = Services.GetRequiredService<ServiceEndpointCollection>();
|
||||
EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-PC 拦截接口列表");
|
||||
|
||||
#if DEBUG
|
||||
// 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
|
||||
Environment.SetEnvironmentVariable(
|
||||
@@ -54,10 +53,15 @@ namespace Avalonia_PC
|
||||
|
||||
// ---- 业务服务 ----
|
||||
services.AddSingleton<WeatherForecastService>();
|
||||
services.AddSingleton<IPcThirdPartyAuthorizationClient, DefaultPcThirdPartyAuthorizationClient>();
|
||||
services.AddSingleton<PcGlobalTokenService>();
|
||||
services.AddSingleton<IAuthService, PcAuthService>();
|
||||
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
|
||||
|
||||
// ---- 统一端点 ----
|
||||
// ---- 端点注册 ----
|
||||
var endpointBuilder = new ServiceEndpointBuilder();
|
||||
AppEndpoints.Configure(endpointBuilder);
|
||||
AuthEndpoints.ConfigurePc(endpointBuilder);
|
||||
var endpoints = endpointBuilder.Build();
|
||||
services.AddSingleton(endpoints);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user