This commit is contained in:
2025-12-04 05:12:45 +08:00
parent 85f0cb613a
commit 5bdb04da15
8 changed files with 330 additions and 206 deletions

View File

@@ -1,5 +1,4 @@

using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services;
using Atomx.Admin.Utils;
@@ -16,27 +15,42 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace Atomx.Admin.Controllers
{
/// <summary>
/// 登录/刷新/登出控制器(重构)
/// 说明:
/// - 返回标准的 AuthResponseaccess + 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
{
readonly ILogger<SignController> _logger;
readonly IIdentityService _identityService;
readonly IIdCreatorService _idCreator;
readonly IMapper _mapper;
readonly DataContext _dbContext;
readonly JwtSetting _jwtSetting;
readonly ICacheService _cacheService;
readonly AuthenticationStateProvider _authenticationStateProvider;
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)
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;
@@ -46,106 +60,201 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting;
_cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider;
_tokenService = tokenService;
}
/// <summary>
/// 用户登录系统
/// 登录:支持邮箱或用户登录
/// - 返回 AuthResponse 给 WASMaccess + refresh
/// - 在 Server 场景同时创建 Cookie兼容 Blazor Server
/// </summary>
/// <returns></returns>
[HttpPost("in")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginModel model)
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;
var result = new ApiResult<string>().IsFail(message ?? string.Empty, null);
return new JsonResult(result);
var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
return new JsonResult(new ApiResult<AuthResponse>().IsFail(message, null));
}
var tokenHandler = new JwtSecurityTokenHandler();
var issuer = _jwtSetting.Issuer;
var audience = _jwtSetting.Audience;
var securityKey = _jwtSetting.SecurityKey;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
Common.Entities.Admin? user = null;
// 查询用户(支持 email / username
Atomx.Common.Entities.Admin? user = null;
if (model.Account.Contains("@"))
{
user = _dbContext.Admins.Where(p => p.Email == model.Account).SingleOrDefault();
user = _dbContext.Admins.SingleOrDefault(p => p.Email == model.Account);
}
else
{
user = _dbContext.Admins.Where(p => p.Username == model.Account).SingleOrDefault();
user = _dbContext.Admins.SingleOrDefault(p => p.Username == model.Account);
}
if (user == null)
{
var result = new ApiResult<string>().IsFail("用户不存在", null);
return new JsonResult(result);
return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null));
}
// 简单密码校验(项目 uses MD5 存储示例)
if (user.Password != model.Password.ToMd5Password())
{
var result = new ApiResult<string>().IsFail("账号密码不正确", null);
return new JsonResult(result);
return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null));
}
var role = _dbContext.Roles.Where(p => p.Id == user.RoleId).SingleOrDefault();
// 生成 access + refreshTokenService 会把 refresh 的哈希保存到数据库)
var ip = _identityService.GetClientIp();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
var authResponse = await _tokenService.GenerateTokenAsync(user, ip, userAgent);
var claims = new List<Claim>()
{
new Claim(ClaimKeys.Id, user.Id.ToString()),
new Claim(ClaimKeys.Email, user.Email),
new Claim(ClaimKeys.Name, user.Username),
new Claim(ClaimKeys.Role, user.RoleId.ToString()),
new Claim(ClaimKeys.Permission, role?.Permission??string.Empty)
};
// 更新用户登录统计信息
user.LastLogin = DateTime.UtcNow;
user.LastIp = ip;
user.LoginCount++;
_dbContext.Admins.Update(user);
await _dbContext.SaveChangesAsync();
// 为 Blazor Server 场景创建 CookieClaims 中包含必要角色/权限)
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)
});
var tokenDescriptor = new SecurityTokenDescriptor
// 另外将 tokens 写入 HttpOnly Cookie增强与传统中间件的兼容性
try
{
Subject = claimsIdentity,
var cookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(), // 本地调试时允许 http
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("access_token", authResponse.Token, cookieOptions);
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
SigningCredentials = credentials,
Issuer = issuer,
Audience = audience
};
var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
var loginResult = new ApiResult<string>().IsSuccess(tokenString);
user.LastLogin = DateTime.UtcNow;
user.LastIp = _identityService.GetClientIp();
user.LoginCount++;
_dbContext.Admins.Update(user);
_dbContext.SaveChanges();
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity));
return new JsonResult(loginResult);
var refreshCookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("refresh_token", authResponse.RefreshToken, refreshCookieOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "设置 token cookie 失败(非致命)");
}
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
}
/// <summary>
/// 用户退出系统
/// 刷新客户端传入可能已过期的access token 与 refresh token明文
/// - TokenService.RefreshTokenAsync 会验证并一次性撤销旧的 refresh token生成新的对
/// </summary>
/// <returns></returns>
[HttpGet("out")]
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> LogoutAsync()
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
await HttpContext.SignOutAsync();
return new JsonResult(new ApiResult<string>());
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);
// 更新 cookie如存在
try
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("access_token", newTokens.Token, cookieOptions);
var refreshCookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("refresh_token", newTokens.RefreshToken, refreshCookieOptions);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "刷新 token 时写 cookie 失败(允许)");
}
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 CookieServer 场景)并尝试撤销 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("已退出"));
}
}
}