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:
parent
c5f741e6a4
commit
a9abd90874
123
Avalonia-API/Authentication/ApiAuthEndpointService.cs
Normal file
123
Avalonia-API/Authentication/ApiAuthEndpointService.cs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
using Avalonia_Common.Core;
|
||||||
|
using Avalonia_EFCore.Database;
|
||||||
|
using Avalonia_EFCore.Models;
|
||||||
|
using Avalonia_Services.Core;
|
||||||
|
using Avalonia_Services.Services.AuthService;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Avalonia_API.Authentication
|
||||||
|
{
|
||||||
|
public sealed class ApiAuthEndpointService(
|
||||||
|
AppDataContext db,
|
||||||
|
JwtTokenService jwtTokenService,
|
||||||
|
RefreshTokenService refreshTokenService) : IApiAuthEndpointService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<object?> LoginAsync(ServiceEndpointContext ctx)
|
||||||
|
{
|
||||||
|
var request = Deserialize<ApiLoginRequest>(ctx.Body);
|
||||||
|
if (string.IsNullOrWhiteSpace(request?.Account))
|
||||||
|
{
|
||||||
|
ctx.StatusCode = 400;
|
||||||
|
return ResponseHelper.Failure(400, "账号不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(
|
||||||
|
x => x.Email == request.Account || x.Name == request.Account);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new UserEntity
|
||||||
|
{
|
||||||
|
Name = request.Account,
|
||||||
|
Email = request.Account.Contains('@') ? request.Account : null,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = NormalizeRoles(request.Roles);
|
||||||
|
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
|
||||||
|
var refreshToken = await refreshTokenService.CreateAsync(
|
||||||
|
user.Id,
|
||||||
|
ctx.GetHeader("User-Agent"),
|
||||||
|
GetRemoteIpAddress(ctx));
|
||||||
|
|
||||||
|
return ResponseHelper.Ok(new AuthTokenResponse(
|
||||||
|
accessToken.Token,
|
||||||
|
refreshToken.Token,
|
||||||
|
accessToken.ExpiresAt,
|
||||||
|
refreshToken.Entity.ExpiresAt,
|
||||||
|
roles), "登录成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
|
||||||
|
{
|
||||||
|
var request = Deserialize<ApiRefreshTokenRequest>(ctx.Body);
|
||||||
|
var rotated = await refreshTokenService.RotateAsync(
|
||||||
|
request?.RefreshToken,
|
||||||
|
ctx.GetHeader("User-Agent"),
|
||||||
|
GetRemoteIpAddress(ctx));
|
||||||
|
|
||||||
|
if (rotated is null)
|
||||||
|
{
|
||||||
|
ctx.StatusCode = 401;
|
||||||
|
return ResponseHelper.Failure(401, "刷新 token 无效或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(rotated.Value.Entity.UserId);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
ctx.StatusCode = 401;
|
||||||
|
return ResponseHelper.Failure(401, "用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = new[] { "Admin" };
|
||||||
|
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
|
||||||
|
|
||||||
|
return ResponseHelper.Ok(new AuthTokenResponse(
|
||||||
|
accessToken.Token,
|
||||||
|
rotated.Value.Token,
|
||||||
|
accessToken.ExpiresAt,
|
||||||
|
rotated.Value.Entity.ExpiresAt,
|
||||||
|
roles), "刷新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object?> LogoutAsync(ServiceEndpointContext ctx)
|
||||||
|
{
|
||||||
|
var request = Deserialize<ApiLogoutRequest>(ctx.Body);
|
||||||
|
await refreshTokenService.RevokeAsync(request?.RefreshToken);
|
||||||
|
return ResponseHelper.Succeed("退出成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? Deserialize<T>(string? body)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(body)
|
||||||
|
? default
|
||||||
|
: JsonSerializer.Deserialize<T>(body, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetRemoteIpAddress(ServiceEndpointContext ctx)
|
||||||
|
{
|
||||||
|
return ctx.Items.TryGetValue("HttpContext", out var value) && value is HttpContext httpContext
|
||||||
|
? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] NormalizeRoles(string[]? roles)
|
||||||
|
{
|
||||||
|
var normalized = roles?
|
||||||
|
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||||
|
.Select(role => role.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return normalized is { Length: > 0 } ? normalized : ["Admin"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Avalonia-API/Authentication/JwtOptions.cs
Normal file
15
Avalonia-API/Authentication/JwtOptions.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Avalonia_API.Authentication
|
||||||
|
{
|
||||||
|
public sealed class JwtOptions
|
||||||
|
{
|
||||||
|
public string Issuer { get; set; } = "Avalonia-API";
|
||||||
|
|
||||||
|
public string Audience { get; set; } = "Avalonia-Client";
|
||||||
|
|
||||||
|
public string SigningKey { get; set; } = "change-this-development-signing-key-at-least-32-bytes";
|
||||||
|
|
||||||
|
public int AccessTokenMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
public int RefreshTokenDays { get; set; } = 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Avalonia-API/Authentication/JwtTokenService.cs
Normal file
43
Avalonia-API/Authentication/JwtTokenService.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using Avalonia_EFCore.Models;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Avalonia_API.Authentication
|
||||||
|
{
|
||||||
|
public sealed class JwtTokenService(IOptions<JwtOptions> options)
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _options = options.Value;
|
||||||
|
|
||||||
|
public (string Token, DateTime ExpiresAt) CreateAccessToken(UserEntity user, IReadOnlyCollection<string> roles)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new(ClaimTypes.Name, user.Name ?? user.Email ?? $"user-{user.Id}"),
|
||||||
|
new("auth_type", "api-jwt"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var role in roles.Where(role => !string.IsNullOrWhiteSpace(role)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||||
|
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
var jwt = new JwtSecurityToken(
|
||||||
|
issuer: _options.Issuer,
|
||||||
|
audience: _options.Audience,
|
||||||
|
claims: claims,
|
||||||
|
notBefore: DateTime.UtcNow,
|
||||||
|
expires: expiresAt,
|
||||||
|
signingCredentials: credentials);
|
||||||
|
|
||||||
|
return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
Avalonia-API/Authentication/RefreshTokenService.cs
Normal file
84
Avalonia-API/Authentication/RefreshTokenService.cs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
using Avalonia_EFCore.Database;
|
||||||
|
using Avalonia_EFCore.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Avalonia_API.Authentication
|
||||||
|
{
|
||||||
|
public sealed class RefreshTokenService(AppDataContext db, IOptions<JwtOptions> options)
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<(string Token, ApiRefreshTokenEntity Entity)> CreateAsync(
|
||||||
|
int userId,
|
||||||
|
string? device,
|
||||||
|
string? ipAddress,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
|
var entity = new ApiRefreshTokenEntity
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
TokenHash = HashToken(token),
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
|
||||||
|
Device = device,
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ApiRefreshTokens.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return (token, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiRefreshTokenEntity?> FindActiveAsync(string? token, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = HashToken(token);
|
||||||
|
var entity = await db.ApiRefreshTokens.FirstOrDefaultAsync(x => x.TokenHash == hash, cancellationToken);
|
||||||
|
return entity?.IsActive == true ? entity : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeAsync(string? token, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await FindActiveAsync(token, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.RevokedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string Token, ApiRefreshTokenEntity Entity)?> RotateAsync(
|
||||||
|
string? token,
|
||||||
|
string? device,
|
||||||
|
string? ipAddress,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var current = await FindActiveAsync(token, cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = await CreateAsync(current.UserId, device, ipAddress, cancellationToken);
|
||||||
|
current.RevokedAt = DateTime.UtcNow;
|
||||||
|
current.ReplacedByTokenHash = next.Entity.TokenHash;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashToken(string token)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||||
|
return Convert.ToHexString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,11 +9,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
using Avalonia_EFCore.Database;
|
using Avalonia_API.Authentication;
|
||||||
|
using Avalonia_EFCore.Database;
|
||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
using Avalonia_Services.Endpoints;
|
using Avalonia_Services.Endpoints;
|
||||||
using Avalonia_Services.Services;
|
using Avalonia_Services.Services;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Avalonia_Services.Services.AuthService;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace Avalonia_API.Configuration
|
namespace Avalonia_API.Configuration
|
||||||
{
|
{
|
||||||
@ -31,9 +34,35 @@ namespace Avalonia_API.Configuration
|
|||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
services.AddScoped<WeatherForecastService>();
|
services.AddScoped<WeatherForecastService>();
|
||||||
|
|
||||||
|
// ---- API 鉴权 ----
|
||||||
|
var jwtSection = configuration.GetSection("Jwt");
|
||||||
|
services.Configure<JwtOptions>(jwtSection);
|
||||||
|
var jwtOptions = jwtSection.Get<JwtOptions>() ?? new JwtOptions();
|
||||||
|
services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
|
ValidAudience = jwtOptions.Audience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddScoped<JwtTokenService>();
|
||||||
|
services.AddScoped<RefreshTokenService>();
|
||||||
|
services.AddScoped<IApiAuthEndpointService, ApiAuthEndpointService>();
|
||||||
|
|
||||||
// ---- 统一端点 ----
|
// ---- 统一端点 ----
|
||||||
var endpointBuilder = new ServiceEndpointBuilder();
|
var endpointBuilder = new ServiceEndpointBuilder();
|
||||||
AppEndpoints.Configure(endpointBuilder);
|
AppEndpoints.Configure(endpointBuilder);
|
||||||
|
AuthEndpoints.ConfigureApi(endpointBuilder);
|
||||||
var endpoints = endpointBuilder.Build();
|
var endpoints = endpointBuilder.Build();
|
||||||
services.AddSingleton(endpoints);
|
services.AddSingleton(endpoints);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext;
|
using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext;
|
||||||
using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate;
|
using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate;
|
||||||
// 解决与 ASP.NET Core 同名类型的冲突
|
// 解决与 ASP.NET Core 同名类型的冲突
|
||||||
@ -22,7 +23,7 @@ namespace Avalonia_API.Extensions
|
|||||||
{
|
{
|
||||||
var apiGroup = routeBuilder.MapGroup("/");
|
var apiGroup = routeBuilder.MapGroup("/");
|
||||||
|
|
||||||
foreach (var endpoint in endpoints.Endpoints)
|
foreach (var endpoint in endpoints.ForHost(EndpointHostTarget.Api))
|
||||||
{
|
{
|
||||||
var routeHandlerBuilder = MapEndpoint(apiGroup, endpoint, serviceProvider);
|
var routeHandlerBuilder = MapEndpoint(apiGroup, endpoint, serviceProvider);
|
||||||
|
|
||||||
@ -43,7 +44,14 @@ namespace Avalonia_API.Extensions
|
|||||||
// 鉴权(使用 ASP.NET Core 原生鉴权机制)
|
// 鉴权(使用 ASP.NET Core 原生鉴权机制)
|
||||||
if (endpoint.RequireAuthorization)
|
if (endpoint.RequireAuthorization)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(endpoint.Policy))
|
if (endpoint.Roles.Count > 0)
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.RequireAuthorization(new AuthorizeAttribute
|
||||||
|
{
|
||||||
|
Roles = string.Join(',', endpoint.Roles),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(endpoint.Policy))
|
||||||
{
|
{
|
||||||
routeHandlerBuilder.RequireAuthorization(endpoint.Policy);
|
routeHandlerBuilder.RequireAuthorization(endpoint.Policy);
|
||||||
}
|
}
|
||||||
@ -57,6 +65,31 @@ namespace Avalonia_API.Extensions
|
|||||||
{
|
{
|
||||||
routeHandlerBuilder.WithName(endpoint.Name);
|
routeHandlerBuilder.WithName(endpoint.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(endpoint.OpenApiTag))
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.WithTags(endpoint.OpenApiTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(endpoint.OpenApiDescription))
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.WithDescription(endpoint.OpenApiDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(endpoint.OpenApiSummary))
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.WithSummary(endpoint.OpenApiSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint.OpenApiRequestType is not null)
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.Accepts(endpoint.OpenApiRequestType, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint.OpenApiResponseType is not null)
|
||||||
|
{
|
||||||
|
routeHandlerBuilder.Produces(200, endpoint.OpenApiResponseType, "application/json");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return routeBuilder;
|
return routeBuilder;
|
||||||
@ -87,6 +120,7 @@ namespace Avalonia_API.Extensions
|
|||||||
{
|
{
|
||||||
var ctx = await BuildContextFromHttpContext(httpContext);
|
var ctx = await BuildContextFromHttpContext(httpContext);
|
||||||
ctx.Items["ServiceProvider"] = serviceProvider;
|
ctx.Items["ServiceProvider"] = serviceProvider;
|
||||||
|
ctx.Items["User"] = httpContext.User;
|
||||||
|
|
||||||
var result = await unifiedHandler(ctx);
|
var result = await unifiedHandler(ctx);
|
||||||
|
|
||||||
@ -127,6 +161,7 @@ namespace Avalonia_API.Extensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.Items["HttpContext"] = httpContext;
|
ctx.Items["HttpContext"] = httpContext;
|
||||||
|
ctx.Items["User"] = httpContext.User;
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,17 +28,21 @@ try
|
|||||||
// 初始化数据库(自动迁移 + 种子数据)
|
// 初始化数据库(自动迁移 + 种子数据)
|
||||||
app.Services.InitializeDatabase<AppDataContext>();
|
app.Services.InitializeDatabase<AppDataContext>();
|
||||||
|
|
||||||
// 启动时打印所有接口
|
|
||||||
var endpoints = app.Services.GetRequiredService<ServiceEndpointCollection>();
|
var endpoints = app.Services.GetRequiredService<ServiceEndpointCollection>();
|
||||||
EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-API 接口列表");
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
app.UseSwaggerUI(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerEndpoint("/openapi/v1.json", "Avalonia API v1");
|
||||||
|
options.RoutePrefix = "swagger";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// 将统一端点映射到 ASP.NET Core 路由
|
// 将统一端点映射到 ASP.NET Core 路由
|
||||||
|
|||||||
@ -6,6 +6,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "Avalonia-API",
|
||||||
|
"Audience": "Avalonia-Client",
|
||||||
|
"SigningKey": "change-this-development-signing-key-at-least-32-bytes",
|
||||||
|
"AccessTokenMinutes": 60,
|
||||||
|
"RefreshTokenDays": 30
|
||||||
|
},
|
||||||
"DatabaseConfiguration": {
|
"DatabaseConfiguration": {
|
||||||
"Provider": "SQLite",
|
"Provider": "SQLite",
|
||||||
"ConnectionString": "Data Source=avalonia-api.db",
|
"ConnectionString": "Data Source=avalonia-api.db",
|
||||||
|
|||||||
@ -16,6 +16,9 @@ namespace Avalonia_EFCore.Database
|
|||||||
/// <summary>用户数据</summary>
|
/// <summary>用户数据</summary>
|
||||||
public DbSet<UserEntity> Users => Set<UserEntity>();
|
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||||
|
|
||||||
|
/// <summary>API refresh token 数据</summary>
|
||||||
|
public DbSet<ApiRefreshTokenEntity> ApiRefreshTokens => Set<ApiRefreshTokenEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@ -31,6 +34,13 @@ namespace Avalonia_EFCore.Database
|
|||||||
entity.HasKey(e => e.Id).HasName("pk-user");
|
entity.HasKey(e => e.Id).HasName("pk-user");
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ApiRefreshTokenEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id).HasName("pk-api-refresh-token");
|
||||||
|
entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash");
|
||||||
|
entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.Designer.cs
generated
Normal file
173
Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.Designer.cs
generated
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Avalonia_EFCore.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDataContext))]
|
||||||
|
[Migration("20260515085847_AutoMigration_20260515165835")]
|
||||||
|
partial class AutoMigration_20260515165835
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<string>("Device")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("expires-at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ip-address");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("replaced-by-token-hash");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("revoked-at");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("token-hash");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("user-id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-api-refresh-token");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-hash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-user-id");
|
||||||
|
|
||||||
|
b.ToTable("api-refresh-token", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("API refresh token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasComment("用户主键");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at")
|
||||||
|
.HasComment("创建时间");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email")
|
||||||
|
.HasComment("用户邮箱");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name")
|
||||||
|
.HasComment("用户名称");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("phone-number")
|
||||||
|
.HasComment("电话号码");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at")
|
||||||
|
.HasComment("更新时间");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-user");
|
||||||
|
|
||||||
|
b.ToTable("user", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("用户实体,演示数据库 CRUD 操作");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasComment("天气预报主键");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at")
|
||||||
|
.HasComment("创建时间");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("date")
|
||||||
|
.HasComment("预报日期");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("summary")
|
||||||
|
.HasComment("天气摘要");
|
||||||
|
|
||||||
|
b.Property<int>("TemperatureC")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("temperature-c")
|
||||||
|
.HasComment("摄氏温度");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at")
|
||||||
|
.HasComment("更新时间");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-weather-forecast");
|
||||||
|
|
||||||
|
b.ToTable("weather-forecast", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("天气预报数据实体");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AutoMigration_20260515165835 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "api-refresh-token",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
userid = table.Column<int>(name: "user-id", type: "INTEGER", nullable: false),
|
||||||
|
tokenhash = table.Column<string>(name: "token-hash", type: "TEXT", maxLength: 128, nullable: false),
|
||||||
|
createdat = table.Column<DateTime>(name: "created-at", type: "TEXT", nullable: false),
|
||||||
|
expiresat = table.Column<DateTime>(name: "expires-at", type: "TEXT", nullable: false),
|
||||||
|
revokedat = table.Column<DateTime>(name: "revoked-at", type: "TEXT", nullable: true),
|
||||||
|
replacedbytokenhash = table.Column<string>(name: "replaced-by-token-hash", type: "TEXT", maxLength: 128, nullable: true),
|
||||||
|
device = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||||
|
ipaddress = table.Column<string>(name: "ip-address", type: "TEXT", maxLength: 64, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk-api-refresh-token", x => x.id);
|
||||||
|
},
|
||||||
|
comment: "API refresh token");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-api-refresh-token-hash",
|
||||||
|
table: "api-refresh-token",
|
||||||
|
column: "token-hash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-api-refresh-token-user-id",
|
||||||
|
table: "api-refresh-token",
|
||||||
|
column: "user-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "api-refresh-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,66 @@ namespace Avalonia_EFCore.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<string>("Device")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("expires-at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ip-address");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("replaced-by-token-hash");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("revoked-at");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("token-hash");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("user-id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-api-refresh-token");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-hash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-user-id");
|
||||||
|
|
||||||
|
b.ToTable("api-refresh-token", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("API refresh token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
49
Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs
Normal file
49
Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// API refresh token。只保存哈希,不保存明文 token。
|
||||||
|
/// </summary>
|
||||||
|
[Comment("API refresh token")]
|
||||||
|
[Table("api-refresh-token")]
|
||||||
|
public class ApiRefreshTokenEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[Column("user-id")]
|
||||||
|
public int UserId { get; set; }
|
||||||
|
|
||||||
|
[Column("token-hash")]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TokenHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Column("created-at")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[Column("expires-at")]
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[Column("revoked-at")]
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
[Column("replaced-by-token-hash")]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? ReplacedByTokenHash { get; set; }
|
||||||
|
|
||||||
|
[Column("device")]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? Device { get; set; }
|
||||||
|
|
||||||
|
[Column("ip-address")]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive => RevokedAt is null && ExpiresAt > DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Avalonia-PC/Authentication/PcAuthEndpointService.cs
Normal file
73
Avalonia-PC/Authentication/PcAuthEndpointService.cs
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Avalonia-PC/Authentication/PcAuthService.cs
Normal file
50
Avalonia-PC/Authentication/PcAuthService.cs
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
Avalonia-PC/Authentication/PcGlobalTokenService.cs
Normal file
148
Avalonia-PC/Authentication/PcGlobalTokenService.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
using Avalonia;
|
using Authentication;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia_Common.Infrastructure;
|
using Avalonia_Common.Infrastructure;
|
||||||
using Avalonia_EFCore.Database;
|
using Avalonia_EFCore.Database;
|
||||||
|
using Avalonia_PC.Authentication;
|
||||||
using Avalonia_PC.Views;
|
using Avalonia_PC.Views;
|
||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
using Avalonia_Services.Endpoints;
|
using Avalonia_Services.Endpoints;
|
||||||
using Avalonia_Services.Services;
|
using Avalonia_Services.Services;
|
||||||
|
using Avalonia_Services.Services.AuthService;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System;
|
using System;
|
||||||
@ -28,10 +31,6 @@ namespace Avalonia_PC
|
|||||||
// 初始化数据库(自动迁移 + 种子数据)
|
// 初始化数据库(自动迁移 + 种子数据)
|
||||||
Services.InitializeDatabase<AppDataContext>();
|
Services.InitializeDatabase<AppDataContext>();
|
||||||
|
|
||||||
// 启动时打印所有拦截的接口
|
|
||||||
var endpoints = Services.GetRequiredService<ServiceEndpointCollection>();
|
|
||||||
EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-PC 拦截接口列表");
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
|
// 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
|
||||||
Environment.SetEnvironmentVariable(
|
Environment.SetEnvironmentVariable(
|
||||||
@ -54,10 +53,15 @@ namespace Avalonia_PC
|
|||||||
|
|
||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
services.AddSingleton<WeatherForecastService>();
|
services.AddSingleton<WeatherForecastService>();
|
||||||
|
services.AddSingleton<IPcThirdPartyAuthorizationClient, DefaultPcThirdPartyAuthorizationClient>();
|
||||||
|
services.AddSingleton<PcGlobalTokenService>();
|
||||||
|
services.AddSingleton<IAuthService, PcAuthService>();
|
||||||
|
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
|
||||||
|
|
||||||
// ---- 统一端点 ----
|
// ---- 端点注册 ----
|
||||||
var endpointBuilder = new ServiceEndpointBuilder();
|
var endpointBuilder = new ServiceEndpointBuilder();
|
||||||
AppEndpoints.Configure(endpointBuilder);
|
AppEndpoints.Configure(endpointBuilder);
|
||||||
|
AuthEndpoints.ConfigurePc(endpointBuilder);
|
||||||
var endpoints = endpointBuilder.Build();
|
var endpoints = endpointBuilder.Build();
|
||||||
services.AddSingleton(endpoints);
|
services.AddSingleton(endpoints);
|
||||||
|
|
||||||
|
|||||||
@ -12,15 +12,19 @@ namespace Avalonia_Services.Core
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 打印所有已注册端点到控制台。
|
/// 打印所有已注册端点到控制台。
|
||||||
/// </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";
|
title ??= "API Endpoints";
|
||||||
|
var endpoints = collection.ForHost(host).ToList();
|
||||||
|
|
||||||
var maxMethodLen = collection.Endpoints.Count > 0
|
var maxMethodLen = endpoints.Count > 0
|
||||||
? collection.Endpoints.Max(e => e.HttpMethod.Length)
|
? endpoints.Max(e => e.HttpMethod.Length)
|
||||||
: 4;
|
: 4;
|
||||||
var maxPathLen = collection.Endpoints.Count > 0
|
var maxPathLen = endpoints.Count > 0
|
||||||
? collection.Endpoints.Max(e => e.Pattern.Length)
|
? endpoints.Max(e => e.Pattern.Length)
|
||||||
: 8;
|
: 8;
|
||||||
|
|
||||||
var totalWidth = maxMethodLen + maxPathLen + 5;
|
var totalWidth = maxMethodLen + maxPathLen + 5;
|
||||||
@ -31,9 +35,11 @@ namespace Avalonia_Services.Core
|
|||||||
Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║");
|
Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║");
|
||||||
Console.WriteLine($"╟{separator}╢");
|
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
|
var methodColor = ep.HttpMethod switch
|
||||||
{
|
{
|
||||||
"GET" => ConsoleColor.Green,
|
"GET" => ConsoleColor.Green,
|
||||||
@ -57,7 +63,7 @@ namespace Avalonia_Services.Core
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"╚{separator}╝");
|
Console.WriteLine($"╚{separator}╝");
|
||||||
Console.WriteLine($" Total: {collection.Endpoints.Count} endpoint(s)");
|
Console.WriteLine($" Total: {endpoints.Count} endpoint(s)");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Avalonia_Services.Core
|
namespace Avalonia_Services.Core
|
||||||
{
|
{
|
||||||
|
[Flags]
|
||||||
|
public enum EndpointHostTarget
|
||||||
|
{
|
||||||
|
Api = 1,
|
||||||
|
Pc = 2,
|
||||||
|
All = Api | Pc,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 单个端点定义。
|
/// 单个端点定义。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -18,6 +27,21 @@ namespace Avalonia_Services.Core
|
|||||||
/// <summary>端点名称(用于 OpenAPI / 日志)</summary>
|
/// <summary>端点名称(用于 OpenAPI / 日志)</summary>
|
||||||
public string? Name { get; set; }
|
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>
|
/// <summary>端点处理器</summary>
|
||||||
public Func<ServiceEndpointContext, Task<object?>> Handler { get; init; } = _ => Task.FromResult<object?>(null);
|
public Func<ServiceEndpointContext, Task<object?>> Handler { get; init; } = _ => Task.FromResult<object?>(null);
|
||||||
|
|
||||||
@ -30,6 +54,12 @@ namespace Avalonia_Services.Core
|
|||||||
/// <summary>鉴权策略名</summary>
|
/// <summary>鉴权策略名</summary>
|
||||||
public string? Policy { get; set; }
|
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>
|
/// <summary>
|
||||||
/// 设置端点名称(Fluent API)。
|
/// 设置端点名称(Fluent API)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -38,6 +68,64 @@ namespace Avalonia_Services.Core
|
|||||||
Name = name;
|
Name = name;
|
||||||
return this;
|
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>
|
/// <summary>
|
||||||
@ -48,6 +136,11 @@ namespace Avalonia_Services.Core
|
|||||||
/// <summary>所有已注册的端点</summary>
|
/// <summary>所有已注册的端点</summary>
|
||||||
public List<ServiceEndpoint> Endpoints { get; } = new();
|
public List<ServiceEndpoint> Endpoints { get; } = new();
|
||||||
|
|
||||||
|
public IEnumerable<ServiceEndpoint> ForHost(EndpointHostTarget host)
|
||||||
|
{
|
||||||
|
return Endpoints.Where(endpoint => endpoint.SupportsHost(host));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>作用于所有端点的全局过滤器</summary>
|
/// <summary>作用于所有端点的全局过滤器</summary>
|
||||||
public List<IEndpointFilter> GlobalFilters { get; } = new();
|
public List<IEndpointFilter> GlobalFilters { get; } = new();
|
||||||
|
|
||||||
@ -59,6 +152,14 @@ namespace Avalonia_Services.Core
|
|||||||
return AddEndpoint(pattern, "GET", handler);
|
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>
|
/// <summary>
|
||||||
/// 注册一个 POST 端点。
|
/// 注册一个 POST 端点。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -67,6 +168,14 @@ namespace Avalonia_Services.Core
|
|||||||
return AddEndpoint(pattern, "POST", handler);
|
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>
|
/// <summary>
|
||||||
/// 注册一个 PUT 端点。
|
/// 注册一个 PUT 端点。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -75,6 +184,14 @@ namespace Avalonia_Services.Core
|
|||||||
return AddEndpoint(pattern, "PUT", handler);
|
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>
|
/// <summary>
|
||||||
/// 注册一个 DELETE 端点。
|
/// 注册一个 DELETE 端点。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -83,6 +200,14 @@ namespace Avalonia_Services.Core
|
|||||||
return AddEndpoint(pattern, "DELETE", handler);
|
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>
|
||||||
/// 添加全局过滤器(作用于所有端点)。
|
/// 添加全局过滤器(作用于所有端点)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -112,6 +237,21 @@ namespace Avalonia_Services.Core
|
|||||||
Endpoints.Add(endpoint);
|
Endpoints.Add(endpoint);
|
||||||
return 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>
|
/// <summary>
|
||||||
|
|||||||
@ -4,10 +4,6 @@ using Avalonia_EFCore.Models;
|
|||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
using Avalonia_Services.Services;
|
using Avalonia_Services.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Avalonia_Services.Endpoints
|
namespace Avalonia_Services.Endpoints
|
||||||
{
|
{
|
||||||
@ -40,6 +36,7 @@ namespace Avalonia_Services.Endpoints
|
|||||||
// ---- 业务端点注册 ----
|
// ---- 业务端点注册 ----
|
||||||
// 天气预报(从数据库读取)
|
// 天气预报(从数据库读取)
|
||||||
endpoints.MapGet("api/wData", GetWeatherForecastsAsync)
|
endpoints.MapGet("api/wData", GetWeatherForecastsAsync)
|
||||||
|
.WithOpenApi("Weather", "获取天气预报信息。")
|
||||||
.WithName("GetWeatherForecast");
|
.WithName("GetWeatherForecast");
|
||||||
|
|
||||||
// 获取用户(演示从数据库查询)
|
// 获取用户(演示从数据库查询)
|
||||||
|
|||||||
53
Avalonia-Services/Endpoints/AuthEndpoints.cs
Normal file
53
Avalonia-Services/Endpoints/AuthEndpoints.cs
Normal file
@ -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;
|
using Avalonia_Services.Core;
|
||||||
|
|
||||||
namespace Avalonia_Services.Extensions
|
namespace Avalonia_Services.Extensions
|
||||||
@ -75,6 +70,7 @@ namespace Avalonia_Services.Extensions
|
|||||||
{
|
{
|
||||||
// 查找匹配的端点(忽略大小写 + 方法匹配)
|
// 查找匹配的端点(忽略大小写 + 方法匹配)
|
||||||
var endpoint = _endpoints.Endpoints.FirstOrDefault(e =>
|
var endpoint = _endpoints.Endpoints.FirstOrDefault(e =>
|
||||||
|
e.SupportsHost(EndpointHostTarget.Pc) &&
|
||||||
string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase));
|
string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
@ -108,7 +104,18 @@ namespace Avalonia_Services.Extensions
|
|||||||
return RouteResult.Success(ctx.ResponseBody, ctx);
|
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);
|
var authorized = await _authService.AuthorizeAsync(user, endpoint.Policy);
|
||||||
if (!authorized)
|
if (!authorized)
|
||||||
|
|||||||
37
Avalonia-Services/Services/AuthService/AuthContracts.cs
Normal file
37
Avalonia-Services/Services/AuthService/AuthContracts.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user