205 lines
8.6 KiB
C#
205 lines
8.6 KiB
C#
using Atomx.Admin.Client.Models;
|
||
using Atomx.Admin.Client.Validators;
|
||
using Atomx.Admin.Services;
|
||
using Atomx.Common.Constants;
|
||
using Atomx.Common.Models;
|
||
using Atomx.Data;
|
||
using Atomx.Data.CacheServices;
|
||
using Atomx.Data.Services;
|
||
using Atomx.Utils.Extension;
|
||
using MapsterMapper;
|
||
using Microsoft.AspNetCore.Authentication;
|
||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Components.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using System.Security.Claims;
|
||
|
||
namespace Atomx.Admin.Controllers
|
||
{
|
||
/// <summary>
|
||
/// 登录/刷新/登出控制器(重构)
|
||
/// 说明:
|
||
/// - 返回标准的 AuthResponse(access + refresh + expiry),便于 WASM 客户端保存到 localStorage
|
||
/// - 仍保留 Cookie 登录(SignInAsync)以兼容 Blazor Server 场景
|
||
/// - 使用注入的 ITokenService 负责 token 的生成、刷新与撤销(数据库保存 refresh token 哈希)
|
||
/// - 提供 /api/sign/in (POST), /api/sign/refresh (POST), /api/sign/out (POST)
|
||
/// </summary>
|
||
[Route("api/[controller]")]
|
||
[ApiController]
|
||
public class SignController : ControllerBase
|
||
{
|
||
private readonly ILogger<SignController> _logger;
|
||
private readonly IIdentityService _identityService;
|
||
private readonly IIdCreatorService _idCreator;
|
||
private readonly IMapper _mapper;
|
||
private readonly DataContext _dbContext;
|
||
private readonly JwtSetting _jwtSetting;
|
||
private readonly ICacheService _cacheService;
|
||
private readonly AuthenticationStateProvider _authenticationStateProvider;
|
||
private readonly ITokenService _tokenService;
|
||
|
||
public SignController(
|
||
ILogger<SignController> logger,
|
||
IIdentityService identityService,
|
||
IIdCreatorService idCreator,
|
||
IMapper mapper,
|
||
DataContext dbContext,
|
||
JwtSetting jwtSetting,
|
||
ICacheService cacheService,
|
||
AuthenticationStateProvider authenticationStateProvider,
|
||
ITokenService tokenService)
|
||
{
|
||
_logger = logger;
|
||
_identityService = identityService;
|
||
_idCreator = idCreator;
|
||
_mapper = mapper;
|
||
_dbContext = dbContext;
|
||
_jwtSetting = jwtSetting;
|
||
_cacheService = cacheService;
|
||
_authenticationStateProvider = authenticationStateProvider;
|
||
_tokenService = tokenService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 登录:支持邮箱或用户名登录
|
||
/// - 返回 AuthResponse 给 WASM(access + refresh)
|
||
/// - 在 Server 场景同时创建 Cookie(兼容 Blazor Server)
|
||
/// </summary>
|
||
[HttpPost("in")]
|
||
[AllowAnonymous]
|
||
public async Task<IActionResult> Login([FromBody] LoginModel model)
|
||
{
|
||
var validator = new LoginModelValidator();
|
||
var validation = validator.Validate(model);
|
||
if (!validation.IsValid)
|
||
{
|
||
var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
|
||
return new JsonResult(new ApiResult<AuthResponse>().IsFail(message, null));
|
||
}
|
||
|
||
// 查询用户(支持 email / username)
|
||
Atomx.Common.Entities.Admin? user = null;
|
||
if (model.Account.Contains("@"))
|
||
{
|
||
user = _dbContext.Admins.SingleOrDefault(p => p.Email == model.Account);
|
||
}
|
||
else
|
||
{
|
||
user = _dbContext.Admins.SingleOrDefault(p => p.Username == model.Account);
|
||
}
|
||
|
||
if (user == null)
|
||
{
|
||
return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null));
|
||
}
|
||
|
||
// 简单密码校验(项目 uses MD5 存储示例)
|
||
if (user.Password != model.Password.ToMd5Password())
|
||
{
|
||
return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null));
|
||
}
|
||
|
||
// 生成 access + refresh(TokenService 会把 refresh 的哈希保存到数据库)
|
||
var ip = _identityService.GetClientIp();
|
||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||
var authResponse = await _tokenService.GenerateTokenAsync(user, ip, userAgent);
|
||
|
||
// 更新用户登录统计信息
|
||
user.LastLogin = DateTime.UtcNow;
|
||
user.LastIp = ip;
|
||
user.LoginCount++;
|
||
_dbContext.Admins.Update(user);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
// 为 Blazor Server 场景创建 Cookie(Claims 中包含必要角色/权限)
|
||
var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId);
|
||
var claims = new List<Claim>
|
||
{
|
||
new Claim(ClaimKeys.Id, user.Id.ToString()),
|
||
new Claim(ClaimKeys.Email, user.Email ?? string.Empty),
|
||
new Claim(ClaimKeys.Name, user.Username ?? string.Empty),
|
||
new Claim(ClaimKeys.Role, user.RoleId.ToString()),
|
||
new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty)
|
||
};
|
||
|
||
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||
|
||
// SignInAsync 创建 HttpOnly Cookie,便于 Server-side 认证
|
||
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||
new ClaimsPrincipal(claimsIdentity),
|
||
new AuthenticationProperties
|
||
{
|
||
IsPersistent = model.RememberMe,
|
||
AllowRefresh = true,
|
||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||
});
|
||
|
||
|
||
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 刷新:客户端传入(可能已过期的)access token 与 refresh token(明文)
|
||
/// - TokenService.RefreshTokenAsync 会验证并一次性撤销旧的 refresh token,生成新的对
|
||
/// </summary>
|
||
[HttpPost("refresh")]
|
||
[AllowAnonymous]
|
||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||
{
|
||
if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
|
||
{
|
||
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null));
|
||
}
|
||
|
||
try
|
||
{
|
||
var ip = _identityService.GetClientIp();
|
||
var newTokens = await _tokenService.RefreshTokenAsync(request.Token, request.RefreshToken, ip);
|
||
|
||
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(newTokens));
|
||
}
|
||
catch (SecurityTokenException ex)
|
||
{
|
||
_logger.LogWarning(ex, "刷新失败:刷新令牌无效或 access token 无效");
|
||
return Unauthorized(new ApiResult<AuthResponse>().IsFail("刷新令牌无效或已过期", null));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "刷新令牌时发生内部错误");
|
||
return StatusCode(500, new ApiResult<AuthResponse>().IsFail("服务器内部错误", null));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 登出:可传入 refresh token 以撤销(WASM 前端应同时清除 localStorage)
|
||
/// - 本接口会 SignOut Cookie(Server 场景)并尝试撤销 refresh token
|
||
/// </summary>
|
||
[HttpPost("out")]
|
||
[AllowAnonymous]
|
||
public async Task<IActionResult> LogoutAsync([FromBody] RevokeRequest? revokeRequest = null)
|
||
{
|
||
if (revokeRequest != null && !string.IsNullOrWhiteSpace(revokeRequest.RefreshToken))
|
||
{
|
||
try
|
||
{
|
||
var ip = _identityService.GetClientIp();
|
||
await _tokenService.RevokeTokenAsync(revokeRequest.RefreshToken, ip);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "撤销 refresh token 失败(允许)");
|
||
}
|
||
}
|
||
|
||
// 清理 Cookie
|
||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||
Response.Cookies.Delete("access_token");
|
||
Response.Cookies.Delete("refresh_token");
|
||
|
||
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
|
||
}
|
||
}
|
||
}
|