- 新增 IApiResponse 统一响应契约,覆盖普通响应和分页响应
- 扩展端点映射,支持 IApiResponse 和带请求 DTO 的 MapXxx 重载
- 增加 Body、Query、Route Values 到请求 DTO 的自动绑定
- 增加 PC 端路由模式匹配,支持 {id} 和 {id:int}
- API 与 PC 端点上下文补充路由参数传递
- 调整 OpenAPI 请求类型处理,避免 GET/DELETE 被标记为 JSON Body
- 鉴权端点迁移到强类型请求绑定和 IApiResponse 返回
- 增加统一端点文件流响应支持
139 lines
5.7 KiB
C#
139 lines
5.7 KiB
C#
using Avalonia_Common.Core;
|
||
using Avalonia_EFCore.Database;
|
||
using Avalonia_EFCore.Models;
|
||
using Avalonia_Services.Core;
|
||
using Avalonia_Services.Services.AuthService;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace Avalonia_API.Authentication
|
||
{
|
||
/// <summary>
|
||
/// API 鉴权端点服务,实现 <see cref="IApiAuthEndpointService"/>,
|
||
/// 处理登录、刷新 Token 和登出操作,使用 JWT 与 Refresh Token 机制。
|
||
/// </summary>
|
||
public sealed class ApiAuthEndpointService(
|
||
AppDataContext db,
|
||
JwtTokenService jwtTokenService,
|
||
RefreshTokenService refreshTokenService) : IApiAuthEndpointService
|
||
{
|
||
/// <summary>
|
||
/// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户,
|
||
/// 生成 JWT Access Token 和 Refresh Token 并返回。
|
||
/// </summary>
|
||
/// <param name="ctx">服务端点上下文,包含请求体、请求头等信息。</param>
|
||
/// <returns>包含 AccessToken、RefreshToken 及过期时间的认证响应。</returns>
|
||
public async Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx)
|
||
{
|
||
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), "登录成功");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用 Refresh Token 轮换新的 Access Token 和 Refresh Token。
|
||
/// 旧的 Refresh Token 会被撤销并替换。
|
||
/// </summary>
|
||
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
|
||
/// <returns>新的 Token 对;若 Refresh Token 无效则返回 401 错误。</returns>
|
||
public async Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx)
|
||
{
|
||
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), "刷新成功");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 处理用户登出请求,撤销指定的 Refresh Token。
|
||
/// </summary>
|
||
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
|
||
/// <returns>登出成功的响应。</returns>
|
||
public async Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx)
|
||
{
|
||
await refreshTokenService.RevokeAsync(request.RefreshToken);
|
||
return ResponseHelper.Succeed("退出成功");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从上下文的 Items 中提取 ASP.NET Core HttpContext,并获取客户端远程 IP 地址。
|
||
/// </summary>
|
||
/// <param name="ctx">服务端点上下文。</param>
|
||
/// <returns>客户端 IP 地址字符串;若无法获取则返回 null。</returns>
|
||
private static string? GetRemoteIpAddress(ServiceEndpointContext ctx)
|
||
{
|
||
return ctx.Items.TryGetValue("HttpContext", out var value) && value is HttpContext httpContext
|
||
? httpContext.Connection.RemoteIpAddress?.ToString()
|
||
: null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 规范化角色数组:去空白、去重(忽略大小写),为空时默认返回 Admin 角色。
|
||
/// </summary>
|
||
/// <param name="roles">原始角色数组,可为 null。</param>
|
||
/// <returns>规范化后的角色数组。</returns>
|
||
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"];
|
||
}
|
||
}
|
||
}
|