chore
This commit is contained in:
@@ -19,9 +19,6 @@ builder.Services.AddScoped<IPermissionService, ClientPermissionService>();
|
||||
builder.Services.AddScoped<IconsExtension>();
|
||||
builder.Services.AddScoped<ILocalizationService, LocalizationClientService>();
|
||||
|
||||
// Token provider<65><72>WASM<53><4D>: <20><> localStorage <20><>ȡ access token
|
||||
builder.Services.AddScoped<ITokenProvider, ClientTokenProvider>();
|
||||
|
||||
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Զ<EFBFBD><D4B6><EFBFBD><EFBFBD><EFBFBD> token & ˢ<>µ<EFBFBD> DelegatingHandler
|
||||
builder.Services.AddScoped<AuthHeaderHandler>();
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Atomx.Admin.Client.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 统一的 Token 提供器接口(放在共享项目)
|
||||
/// 目标:
|
||||
/// - Server 与 WASM 使用相同的接口类型以避免 DI 注入类型不一致
|
||||
/// - 仅负责“提供”当前可用的 access token(不承担刷新策略)
|
||||
/// </summary>
|
||||
public interface ITokenProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回当前可用的 access token(如果没有则返回 null)
|
||||
/// </summary>
|
||||
Task<string?> GetTokenAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 快速判断当前 token 是否存在且(如果可以解析为 JWT)未过期。
|
||||
/// 注意:此方法为快速检查,不能替代服务端的完整验证。
|
||||
/// </summary>
|
||||
Task<bool> IsTokenValidAsync();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ namespace Atomx.Admin.Client.Utils
|
||||
/// </summary>
|
||||
public class AuthHeaderHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITokenProvider _tokenProvider;
|
||||
private readonly NavigationManager _navigationManager;
|
||||
private readonly ILogger<AuthHeaderHandler> _logger;
|
||||
private readonly ILocalStorageService _localStorage;
|
||||
@@ -26,13 +25,11 @@ namespace Atomx.Admin.Client.Utils
|
||||
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
public AuthHeaderHandler(
|
||||
ITokenProvider tokenProvider,
|
||||
NavigationManager navigationManager,
|
||||
ILogger<AuthHeaderHandler> logger,
|
||||
ILocalStorageService localStorage,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_tokenProvider = tokenProvider;
|
||||
_navigationManager = navigationManager;
|
||||
_logger = logger;
|
||||
_localStorage = localStorage;
|
||||
@@ -45,7 +42,12 @@ namespace Atomx.Admin.Client.Utils
|
||||
try
|
||||
{
|
||||
// 从 ITokenProvider 获取当前 access token(WASM: ClientTokenProvider 从 localStorage 读取)
|
||||
var token = await _tokenProvider.GetTokenAsync();
|
||||
var token = string.Empty;
|
||||
try
|
||||
{
|
||||
token = await _localStorage.GetItemAsync<string>(StorageKeys.AccessToken);
|
||||
}
|
||||
catch { }
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
@@ -206,5 +208,20 @@ namespace Atomx.Admin.Client.Utils
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private async Task HandleUnauthorizedAsync()
|
||||
{
|
||||
// 在WASM模式下重定向到登录页
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_navigationManager.NavigateTo("/account/login", true);
|
||||
}
|
||||
// 在Server模式下可以执行其他操作
|
||||
else
|
||||
{
|
||||
// Server端的处理逻辑
|
||||
_logger.LogWarning("Unauthorized access detected in server mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Atomx.Admin.Client.Services;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Atomx.Admin.Client.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// WASM 客户端下的 Token 提供器(实现共享的 ITokenProvider)
|
||||
/// - 直接从浏览器 storage(localStorage/sessionStorage)读取 access token
|
||||
/// - 设计为轻量,仅负责读取 token;刷新逻辑放在 AuthHeaderHandler / 后端 Refresh 接口
|
||||
/// </summary>
|
||||
public class ClientTokenProvider : ITokenProvider
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
public ClientTokenProvider(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task<string?> GetTokenAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "accessToken");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsTokenValidAsync()
|
||||
{
|
||||
var token = await GetTokenAsync();
|
||||
return !string.IsNullOrEmpty(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
<link rel="stylesheet" href="@Assets["Atomx.Admin.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveAuto" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveAuto" />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Atomx.Admin.Client.Validators;
|
||||
using Atomx.Admin.Services;
|
||||
using Atomx.Common.Constants;
|
||||
using Atomx.Common.Entities;
|
||||
using Atomx.Common.Models;
|
||||
using Atomx.Data;
|
||||
using Atomx.Data.CacheServices;
|
||||
@@ -13,8 +14,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Atomx.Admin.Controllers
|
||||
{
|
||||
@@ -38,7 +43,6 @@ namespace Atomx.Admin.Controllers
|
||||
private readonly JwtSetting _jwtSetting;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly AuthenticationStateProvider _authenticationStateProvider;
|
||||
private readonly ITokenService _tokenService;
|
||||
|
||||
public SignController(
|
||||
ILogger<SignController> logger,
|
||||
@@ -48,8 +52,7 @@ namespace Atomx.Admin.Controllers
|
||||
DataContext dbContext,
|
||||
JwtSetting jwtSetting,
|
||||
ICacheService cacheService,
|
||||
AuthenticationStateProvider authenticationStateProvider,
|
||||
ITokenService tokenService)
|
||||
AuthenticationStateProvider authenticationStateProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_identityService = identityService;
|
||||
@@ -59,7 +62,6 @@ namespace Atomx.Admin.Controllers
|
||||
_jwtSetting = jwtSetting;
|
||||
_cacheService = cacheService;
|
||||
_authenticationStateProvider = authenticationStateProvider;
|
||||
_tokenService = tokenService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,19 +103,20 @@ namespace Atomx.Admin.Controllers
|
||||
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);
|
||||
if (user.LockoutEndTime.HasValue && user.LockoutEndTime.Value > DateTime.UtcNow)
|
||||
{
|
||||
return new JsonResult(new ApiResult<AuthResponse>().IsFail($"账号已锁定,解锁时间:{user.LockoutEndTime.Value.ToLocalTime()}", null));
|
||||
}
|
||||
|
||||
// 更新用户登录统计信息
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
user.LastIp = ip;
|
||||
user.LoginCount++;
|
||||
_dbContext.Admins.Update(user);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var issuer = _jwtSetting.Issuer;
|
||||
var audience = _jwtSetting.Audience;
|
||||
var securityKey = _jwtSetting.SecurityKey;
|
||||
|
||||
// 为 Blazor Server 场景创建 Cookie(Claims 中包含必要角色/权限)
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
// Claims 中包含必要角色/权限
|
||||
var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -124,8 +127,60 @@ namespace Atomx.Admin.Controllers
|
||||
new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty)
|
||||
};
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
|
||||
SigningCredentials = credentials,
|
||||
Issuer = issuer,
|
||||
Audience = audience
|
||||
};
|
||||
|
||||
// 生成 access token
|
||||
var accessToken = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
|
||||
|
||||
// 生成 refresh token(明文)
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
// 保存 refresh token 的哈希到数据库(不可逆)
|
||||
var refreshTokenEntity = new RefreshToken
|
||||
{
|
||||
Token = HashRefreshToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
IssuedTime = DateTime.UtcNow,
|
||||
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
|
||||
Ip = _identityService.GetClientIp(),
|
||||
UserAgent = _identityService.GetUserAgent()
|
||||
};
|
||||
|
||||
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
|
||||
await RemoveOldRefreshTokensAsync(user.Id);
|
||||
|
||||
// 更新用户登录统计信息
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
user.LastIp = _identityService.GetClientIp();
|
||||
user.LoginCount++;
|
||||
_dbContext.Admins.Update(user);
|
||||
_dbContext.RefreshTokens.Add(refreshTokenEntity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
//将 access token 哈希写入缓存(防止重复使用或可用于快速校验,用于快速拒绝等),过期时间与 access token 保持一致(分钟)
|
||||
var cacheKey = $"token:{HashToken(accessToken)}";
|
||||
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
|
||||
|
||||
|
||||
|
||||
|
||||
var authResponse = new AuthResponse
|
||||
{
|
||||
Token = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||||
};
|
||||
|
||||
//SignInAsync 创建 HttpOnly Cookie,便于 Server-side 认证
|
||||
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(claimsIdentity),
|
||||
@@ -136,6 +191,17 @@ namespace Atomx.Admin.Controllers
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||||
});
|
||||
|
||||
// 设置Cookie(用于Server模式)
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Expires = DateTime.UtcNow.AddDays(7),
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Secure = Request.IsHttps
|
||||
};
|
||||
|
||||
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
||||
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
|
||||
|
||||
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
|
||||
}
|
||||
@@ -148,17 +214,119 @@ namespace Atomx.Admin.Controllers
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||||
{
|
||||
var uid = _identityService.GetUserId();
|
||||
if (uid == 0)
|
||||
{
|
||||
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的令牌请求", null));
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null));
|
||||
}
|
||||
|
||||
var user = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == uid);
|
||||
if (user == null)
|
||||
throw new SecurityTokenException("用户不存在或已被禁用");
|
||||
|
||||
try
|
||||
{
|
||||
var ip = _identityService.GetClientIp();
|
||||
var newTokens = await _tokenService.RefreshTokenAsync(request.Token, request.RefreshToken, ip);
|
||||
// 验证 refresh token(数据库中存储为哈希)
|
||||
var hashedRefreshToken = HashRefreshToken(request.RefreshToken);
|
||||
var storedToken = await _dbContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt =>
|
||||
rt.Token == hashedRefreshToken &&
|
||||
rt.UserId == uid &&
|
||||
rt.ExpiresTime > DateTime.UtcNow &&
|
||||
!rt.IsRevoked);
|
||||
|
||||
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(newTokens));
|
||||
if (storedToken == null)
|
||||
throw new SecurityTokenException("无效的刷新令牌");
|
||||
|
||||
// 标记该 refresh token 为已撤销(一次性)
|
||||
storedToken.IsRevoked = true;
|
||||
storedToken.RevokedTime = DateTime.UtcNow;
|
||||
storedToken.Ip = _identityService.GetClientIp();
|
||||
|
||||
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);
|
||||
|
||||
// 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 tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
|
||||
SigningCredentials = credentials,
|
||||
Issuer = issuer,
|
||||
Audience = audience
|
||||
};
|
||||
|
||||
// 生成 access token
|
||||
var accessToken = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
|
||||
|
||||
// 生成 refresh token(明文)
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
// 保存 refresh token 的哈希到数据库(不可逆)
|
||||
var refreshTokenEntity = new RefreshToken
|
||||
{
|
||||
Token = HashRefreshToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
IssuedTime = DateTime.UtcNow,
|
||||
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
|
||||
Ip = _identityService.GetClientIp(),
|
||||
UserAgent = _identityService.GetUserAgent()
|
||||
};
|
||||
|
||||
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
|
||||
await RemoveOldRefreshTokensAsync(user.Id);
|
||||
|
||||
_dbContext.RefreshTokens.Add(refreshTokenEntity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
//将 access token 哈希写入缓存(防止重复使用或可用于快速校验,用于快速拒绝等),过期时间与 access token 保持一致(分钟)
|
||||
var cacheKey = $"token:{HashToken(accessToken)}";
|
||||
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
|
||||
|
||||
var authResponse = new AuthResponse
|
||||
{
|
||||
Token = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||||
};
|
||||
|
||||
// 设置Cookie(用于Server模式)
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Expires = DateTime.UtcNow.AddDays(7),
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Secure = Request.IsHttps
|
||||
};
|
||||
|
||||
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
||||
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
|
||||
|
||||
|
||||
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
@@ -184,8 +352,21 @@ namespace Atomx.Admin.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var ip = _identityService.GetClientIp();
|
||||
await _tokenService.RevokeTokenAsync(revokeRequest.RefreshToken, ip);
|
||||
var hashedToken = HashRefreshToken(revokeRequest.RefreshToken);
|
||||
var token = await _dbContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.Token == hashedToken);
|
||||
|
||||
if (token == null || token.IsRevoked)
|
||||
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
|
||||
|
||||
token.IsRevoked = true;
|
||||
token.RevokedTime = DateTime.UtcNow;
|
||||
token.Ip = _identityService.GetClientIp();
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 清除与用户相关的缓存(例如 user info)
|
||||
await _cacheService.Remove($"user:{token.UserId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -195,10 +376,64 @@ namespace Atomx.Admin.Controllers
|
||||
|
||||
// 清理 Cookie
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
Response.Cookies.Delete("access_token");
|
||||
Response.Cookies.Delete("refresh_token");
|
||||
|
||||
Response.Cookies.Delete("accessToken");
|
||||
Response.Cookies.Delete("refreshToken");
|
||||
|
||||
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 生成随机 refresh token(明文,由服务返回到客户端,数据库仅存哈希)
|
||||
/// </summary>
|
||||
private string GenerateRefreshToken()
|
||||
{
|
||||
var randomNumber = new byte[64];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(randomNumber);
|
||||
return Convert.ToBase64String(randomNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哈希刷新令牌(不可逆):SHA256( refreshToken + secret )
|
||||
/// 数据库仅保存该值,客户端保存明文 refreshToken
|
||||
/// </summary>
|
||||
private string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(refreshToken + _jwtSetting.SecurityKey);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销并保留最近 N 个未撤销的刷新令牌(超出部分标记为已撤销)
|
||||
/// </summary>
|
||||
private async Task RemoveOldRefreshTokensAsync(long userId)
|
||||
{
|
||||
var tokens = await _dbContext.RefreshTokens
|
||||
.Where(rt => rt.UserId == userId && !rt.IsRevoked)
|
||||
.OrderByDescending(rt => rt.IssuedTime)
|
||||
.Skip(Math.Max(0, _jwtSetting.MaxRefreshTokensPerUser - 1))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
token.IsRevoked = true;
|
||||
token.RevokedTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哈希 access token(用于撤销缓存 key)
|
||||
/// </summary>
|
||||
private string HashToken(string token)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(token);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,51 +26,12 @@ namespace Atomx.Admin.Extensions
|
||||
}
|
||||
services.AddSingleton(jwtSetting);
|
||||
|
||||
// Cookie 配置读取
|
||||
var cookieConf = Configuration.GetSection("Authentication:Cookie");
|
||||
var cookieName = cookieConf.GetValue<string>("Name") ?? ".Atomx.Auth";
|
||||
var cookiePath = cookieConf.GetValue<string>("Path") ?? "/";
|
||||
var cookieDomain = cookieConf.GetValue<string>("Domain");
|
||||
var sameSiteStr = cookieConf.GetValue<string>("SameSite");
|
||||
var securePolicyStr = cookieConf.GetValue<string>("SecurePolicy");
|
||||
var expireMinutes = cookieConf.GetValue<int?>("ExpireMinutes") ?? 60;
|
||||
|
||||
SameSiteMode sameSiteMode;
|
||||
if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse<SameSiteMode>(sameSiteStr, true, out sameSiteMode))
|
||||
{
|
||||
sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None;
|
||||
}
|
||||
|
||||
CookieSecurePolicy securePolicy;
|
||||
if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse<CookieSecurePolicy>(securePolicyStr, true, out securePolicy))
|
||||
{
|
||||
securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
||||
}
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
// 默认用于 API 的认证方案为 JwtBearer
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.Cookie.Name = cookieName;
|
||||
options.Cookie.Path = cookiePath;
|
||||
if (!string.IsNullOrWhiteSpace(cookieDomain))
|
||||
{
|
||||
options.Cookie.Domain = cookieDomain;
|
||||
}
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = sameSiteMode;
|
||||
options.Cookie.SecurePolicy = securePolicy;
|
||||
|
||||
options.ExpireTimeSpan = TimeSpan.FromMinutes(expireMinutes);
|
||||
options.SlidingExpiration = true;
|
||||
|
||||
options.LoginPath = "/account/login";
|
||||
options.LogoutPath = "/api/sign/out";
|
||||
})
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = !environment.IsDevelopment();
|
||||
@@ -97,7 +58,7 @@ namespace Atomx.Admin.Extensions
|
||||
// SignalR 客户端常把 token 放在 query string 参数 access_token
|
||||
var accessToken = context.Request.Query["access_token"].FirstOrDefault();
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub")))
|
||||
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api")))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
@@ -120,7 +81,7 @@ namespace Atomx.Admin.Extensions
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
}).AddCookie();
|
||||
|
||||
// 注册基于权限的策略
|
||||
services.AddAuthorization(options =>
|
||||
|
||||
@@ -70,16 +70,8 @@ builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
builder.Services.AddScoped<ILocalizationService, LocalizationService>();
|
||||
builder.Services.AddScoped<LocalizationFile, LocalizationFile>();
|
||||
|
||||
// Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Provider<65><72>Server ʹ<><CAB9> ServerTokenProvider<65><72>
|
||||
builder.Services.AddScoped<ITokenProvider, ServerTokenProvider>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>(); // ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> TokenService
|
||||
builder.Services.AddScoped<AuthHeaderHandler>();
|
||||
|
||||
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>/ˢ<><CBA2>/<2F><><EFBFBD><EFBFBD> access & refresh token<65><6E>
|
||||
// - TokenService ʵ<><CAB5> ITokenService<63><65><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> DataContext<78><74>ICacheService<63><65>JwtSetting <20><>
|
||||
// - <20><> Server <20><>ע<EFBFBD>룬SignController <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> DI <20><>ȡ<EFBFBD><C8A1>ʵ<EFBFBD><CAB5>
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
|
||||
// SignalR<6C><52><EFBFBD><EFBFBD><EFBFBD>÷<EFBFBD><C3B7><EFBFBD><EFBFBD><EFBFBD> Hub ֧<>֣<EFBFBD>ע<EFBFBD>⣺JWT <20><> OnMessageReceived <20><><EFBFBD><EFBFBD> AuthorizationExtension <20>д<EFBFBD><D0B4><EFBFBD><EFBFBD><EFBFBD>
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Atomx.Common.Entities;
|
||||
using Atomx.Common.Models;
|
||||
|
||||
namespace Atomx.Admin.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Token 服务接口(Admin 专用)。
|
||||
/// - 生成 / 刷新 / 撤销 刷新令牌
|
||||
/// - 验证 access token
|
||||
/// - 根据 token 获取 Admin 实体
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
Task<AuthResponse> GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null);
|
||||
Task<AuthResponse> RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null);
|
||||
Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null);
|
||||
Task<bool> ValidateTokenAsync(string token);
|
||||
Task<Atomx.Common.Entities.Admin?> GetAdminFromTokenAsync(string token);
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
using Atomx.Common.Constants;
|
||||
using Atomx.Common.Entities;
|
||||
using Atomx.Common.Models;
|
||||
using Atomx.Data;
|
||||
using Atomx.Data.CacheServices;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Atomx.Admin.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 负责:生成 access token / refresh token、刷新、撤销、验证(Admin 专用)
|
||||
/// 要点:
|
||||
/// - RefreshToken 在数据库中以 SHA256(token + secret) 保存(不可逆)
|
||||
/// - 只查 Admin 表(用户端不考虑 User)
|
||||
/// - 保留每个 Admin 最近 N 个未撤销的刷新令牌(配置项)
|
||||
/// - 不在日志中写入明文 token
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
readonly DataContext _dbContext;
|
||||
readonly ICacheService _cacheService;
|
||||
readonly JwtSetting _jwtSetting;
|
||||
private readonly ILogger<TokenService> _logger;
|
||||
private readonly SecurityKey _securityKey;
|
||||
private readonly SigningCredentials _signingCredentials;
|
||||
|
||||
public TokenService(
|
||||
ILogger<TokenService> logger, DataContext dataContext, ICacheService cacheService, JwtSetting jwtSetting)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbContext = dataContext;
|
||||
_cacheService = cacheService;
|
||||
_jwtSetting = jwtSetting;
|
||||
|
||||
// 防御性默认值(配置缺失时)
|
||||
if (_jwtSetting.AccessTokenExpirationMinutes <= 0) _jwtSetting.AccessTokenExpirationMinutes = 15;
|
||||
if (_jwtSetting.RefreshTokenExpirationMinutes <= 0) _jwtSetting.RefreshTokenExpirationMinutes = 60 * 24 * 30; // 30 天
|
||||
if (_jwtSetting.MaxRefreshTokensPerUser <= 0) _jwtSetting.MaxRefreshTokensPerUser = 7;
|
||||
|
||||
var key = Encoding.UTF8.GetBytes(_jwtSetting.SecurityKey);
|
||||
_securityKey = new SymmetricSecurityKey(key);
|
||||
_signingCredentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256);
|
||||
}
|
||||
|
||||
// helper to avoid analyzer complaining about direct field use in ctor defaulting
|
||||
private int _jwt_setting_max() => _jwtSetting.MaxRefreshTokensPerUser;
|
||||
|
||||
/// <summary>
|
||||
/// 生成一对 token(access + refresh)并将 Refresh 的哈希存库。
|
||||
/// 返回的 RefreshToken 为明文(仅用于客户端存储),数据库只存 Hash。
|
||||
/// </summary>
|
||||
public async Task<AuthResponse> GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null)
|
||||
{
|
||||
if (admin == null)
|
||||
throw new ArgumentNullException(nameof(admin));
|
||||
|
||||
// 检查是否被锁定(Admin 有 LockoutEndTime)
|
||||
if (admin.LockoutEndTime.HasValue && admin.LockoutEndTime > DateTime.UtcNow)
|
||||
throw new InvalidOperationException("账户已被锁定");
|
||||
|
||||
// 生成 access token
|
||||
var accessToken = GenerateAccessToken(admin);
|
||||
|
||||
// 生成 refresh token(明文)
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
|
||||
// 保存 refresh token 的哈希到数据库(不可逆)
|
||||
var refreshTokenEntity = new RefreshToken
|
||||
{
|
||||
Token = HashRefreshToken(refreshToken),
|
||||
UserId = admin.Id, // 虽然叫 UserId,但在 Admin 场景中表示 Admin.Id
|
||||
IssuedTime = DateTime.UtcNow,
|
||||
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
|
||||
Ip = ipAddress,
|
||||
UserAgent = userAgent
|
||||
};
|
||||
|
||||
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
|
||||
await RemoveOldRefreshTokensAsync(admin.Id);
|
||||
|
||||
_dbContext.RefreshTokens.Add(refreshTokenEntity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 缓存 access token(防止重复使用或可用于快速校验)
|
||||
await CacheTokenAsync(accessToken, admin.Id);
|
||||
|
||||
return new AuthResponse
|
||||
{
|
||||
Token = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用已过期的 access token(只用于读取身份信息)+ 明文 refreshToken 来刷新。
|
||||
/// 业务:
|
||||
/// - 验证 access token 签名与 issuer/audience(允许过期)
|
||||
/// - 根据 Claim 中的 admin id 在 Admins 表查找
|
||||
/// - 验证 refresh token 的哈希是否在数据库且未撤销未过期
|
||||
/// - 将该 refresh token 标记为撤销并生成新的对 token 返回
|
||||
/// </summary>
|
||||
public async Task<AuthResponse> RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null)
|
||||
{
|
||||
var principal = GetPrincipalFromExpiredToken(token);
|
||||
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
|
||||
|
||||
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
|
||||
throw new SecurityTokenException("无效的令牌");
|
||||
|
||||
var admin = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
|
||||
if (admin == null)
|
||||
throw new SecurityTokenException("用户不存在或已被禁用");
|
||||
|
||||
// 验证 refresh token(数据库中存储为哈希)
|
||||
var hashedRefreshToken = HashRefreshToken(refreshToken);
|
||||
var storedToken = await _dbContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt =>
|
||||
rt.Token == hashedRefreshToken &&
|
||||
rt.UserId == adminId &&
|
||||
rt.ExpiresTime > DateTime.UtcNow &&
|
||||
!rt.IsRevoked);
|
||||
|
||||
if (storedToken == null)
|
||||
throw new SecurityTokenException("无效的刷新令牌");
|
||||
|
||||
// 标记该 refresh token 为已撤销(一次性)
|
||||
storedToken.IsRevoked = true;
|
||||
storedToken.RevokedTime = DateTime.UtcNow;
|
||||
storedToken.Ip = ipAddress;
|
||||
|
||||
// 生成新的 access/refresh 对
|
||||
var newTokens = await GenerateTokenAsync(admin, ipAddress, storedToken.UserAgent);
|
||||
|
||||
// SaveChanges 已在 GenerateTokenAsync 调用中执行(但我们修改了 storedToken,需要确保保存)
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销某个明文 refresh token(用于登出)
|
||||
/// </summary>
|
||||
public async Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null)
|
||||
{
|
||||
var hashedToken = HashRefreshToken(refreshToken);
|
||||
var token = await _dbContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.Token == hashedToken);
|
||||
|
||||
if (token == null || token.IsRevoked)
|
||||
return false;
|
||||
|
||||
token.IsRevoked = true;
|
||||
token.RevokedTime = DateTime.UtcNow;
|
||||
token.Ip = ipAddress;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 清除与用户相关的缓存(例如 user info)
|
||||
await _cacheService.Remove($"user:{token.UserId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 access token(完整验证:签名、issuer、audience、过期)
|
||||
/// 额外:检查 token 是否在撤销缓存中
|
||||
/// </summary>
|
||||
public async Task<bool> ValidateTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查令牌是否在缓存中(已被撤销)
|
||||
var cacheKey = $"revoked_token:{HashToken(token)}";
|
||||
var cached = await _cacheService.GetCacheString(cacheKey);
|
||||
if (cached != null)
|
||||
return false;
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _securityKey,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSetting.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSetting.Audience,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
tokenHandler.ValidateToken(token, validationParameters, out _);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 access token 获取 Admin(如果 token 合法)。
|
||||
/// - 优先从缓存读取 Admin 对象
|
||||
/// - 如果缓存不存在则从数据库读取并缓存(短期)
|
||||
/// </summary>
|
||||
public async Task<Atomx.Common.Entities.Admin?> GetAdminFromTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var principal = GetPrincipalFromToken(token);
|
||||
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
|
||||
|
||||
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
|
||||
return null;
|
||||
|
||||
// 尝试从缓存获取
|
||||
var cacheKey = $"user:{adminId}";
|
||||
var cachedUser = await _cacheService.GetCacheString(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(cachedUser))
|
||||
{
|
||||
return JsonSerializer.Deserialize<Atomx.Common.Entities.Admin>(cachedUser);
|
||||
}
|
||||
|
||||
// 从数据库获取 Admin
|
||||
var admin = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
|
||||
if (admin != null)
|
||||
{
|
||||
// 缓存 admin 信息(单位:分钟,短期缓存)
|
||||
await _cacheService.SetCacheAsync(cacheKey, JsonSerializer.Serialize(admin), 5);
|
||||
}
|
||||
|
||||
return admin;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成访问令牌,包含必要 claims。
|
||||
/// 使用项目常量 ClaimKeys 以保证前后端一致。
|
||||
/// </summary>
|
||||
private string GenerateAccessToken(Atomx.Common.Entities.Admin admin)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimKeys.Id, admin.Id.ToString()),
|
||||
new Claim("jti", Guid.NewGuid().ToString()),
|
||||
new Claim(ClaimKeys.Name, admin.Username ?? string.Empty),
|
||||
new Claim(ClaimKeys.Email, admin.Email ?? string.Empty),
|
||||
new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
|
||||
};
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
|
||||
Issuer = _jwtSetting.Issuer,
|
||||
Audience = _jwtSetting.Audience,
|
||||
SigningCredentials = _signingCredentials,
|
||||
NotBefore = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成随机 refresh token(明文,由服务返回到客户端,数据库仅存哈希)
|
||||
/// </summary>
|
||||
private string GenerateRefreshToken()
|
||||
{
|
||||
var randomNumber = new byte[64];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(randomNumber);
|
||||
return Convert.ToBase64String(randomNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 access token 验证并返回 ClaimsPrincipal(要求 token 未过期)
|
||||
/// </summary>
|
||||
private ClaimsPrincipal GetPrincipalFromToken(string token)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _securityKey,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSetting.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSetting.Audience,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
}, out _);
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "令牌验证失败");
|
||||
throw new SecurityTokenException("无效的令牌", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已过期的 access token 中读取 ClaimsPrincipal(不验证 lifetime,用于 refresh 操作)
|
||||
/// </summary>
|
||||
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _securityKey,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSetting.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSetting.Audience,
|
||||
ValidateLifetime = false, // 不验证过期以便 refresh
|
||||
ClockSkew = TimeSpan.Zero
|
||||
}, out _);
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "过期令牌验证失败");
|
||||
throw new SecurityTokenException("无效的令牌", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哈希刷新令牌(不可逆):SHA256( refreshToken + secret )
|
||||
/// 数据库仅保存该值,客户端保存明文 refreshToken
|
||||
/// </summary>
|
||||
private string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(refreshToken + _jwtSetting.SecurityKey);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哈希 access token(用于撤销缓存 key)
|
||||
/// </summary>
|
||||
private string HashToken(string token)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(token);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销并保留最近 N 个未撤销的刷新令牌(超出部分标记为已撤销)
|
||||
/// </summary>
|
||||
private async Task RemoveOldRefreshTokensAsync(long userId)
|
||||
{
|
||||
var tokens = await _dbContext.RefreshTokens
|
||||
.Where(rt => rt.UserId == userId && !rt.IsRevoked)
|
||||
.OrderByDescending(rt => rt.IssuedTime)
|
||||
.Skip(Math.Max(0, _jwtSetting.MaxRefreshTokensPerUser - 1))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
token.IsRevoked = true;
|
||||
token.RevokedTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// 注意:调用方需要在适当位置 SaveChangesAsync(GenerateTokenAsync 已经在添加新 token 后保存)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 access token 哈希写入缓存(用于快速拒绝等),过期时间与 access token 保持一致(分钟)
|
||||
/// </summary>
|
||||
private async Task CacheTokenAsync(string token, long userId)
|
||||
{
|
||||
var cacheKey = $"token:{HashToken(token)}";
|
||||
await _cacheService.SetCacheAsync(cacheKey, userId, _jwtSetting.AccessTokenExpirationMinutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using Atomx.Admin.Client.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
|
||||
namespace Atomx.Admin.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Server 模式下的 ITokenProvider 实现(Blazor Server)
|
||||
/// - 从当前 HttpContext 中尝试读取 access token(按优先级)
|
||||
/// 1. Authorization header ("Bearer ...")
|
||||
/// 2. Query string "access_token"(SignalR/WebSocket 使用)
|
||||
/// 3. HttpContext.GetTokenAsync("access_token")(保存 token 的 auth 中间件)
|
||||
/// 4. Cookie "access_token"
|
||||
/// 5. HttpContext.Items["access_token"]
|
||||
/// - 提供快速的 JWT 过期判断(IsTokenValidAsync)
|
||||
/// </summary>
|
||||
public class ServerTokenProvider : ITokenProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public ServerTokenProvider(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<string?> GetTokenAsync()
|
||||
{
|
||||
var ctx = _httpContextAccessor.HttpContext;
|
||||
if (ctx == null)
|
||||
return null;
|
||||
|
||||
// 1) Authorization header
|
||||
if (ctx.Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
|
||||
{
|
||||
var authHeader = authHeaderValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) SignalR / websocket: query string access_token
|
||||
if (ctx.Request.Query.TryGetValue("access_token", out var queryToken))
|
||||
{
|
||||
var token = queryToken.ToString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
return token;
|
||||
}
|
||||
|
||||
// 3) 从认证系统中读取(例如 UseAuthentication + SaveToken = true 的场景)
|
||||
try
|
||||
{
|
||||
var saved = await ctx.GetTokenAsync("access_token");
|
||||
if (!string.IsNullOrEmpty(saved))
|
||||
return saved;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 安全忽略
|
||||
}
|
||||
|
||||
// 4) Cookie(兼容性)
|
||||
if (ctx.Request.Cookies.TryGetValue("access_token", out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
|
||||
{
|
||||
return cookieToken;
|
||||
}
|
||||
|
||||
// 5) Items(中间件临时注入)
|
||||
if (ctx.Items.TryGetValue("access_token", out var itemToken) && itemToken is string sToken && !string.IsNullOrEmpty(sToken))
|
||||
{
|
||||
return sToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> IsTokenValidAsync()
|
||||
{
|
||||
var token = await GetTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
if (handler.CanReadToken(token))
|
||||
{
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
var expClaim = jwt.Claims.FirstOrDefault(c => c.Type == "exp")?.Value;
|
||||
if (long.TryParse(expClaim, out var expSec))
|
||||
{
|
||||
var exp = DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime;
|
||||
return exp > DateTime.UtcNow;
|
||||
}
|
||||
// 没有 exp claim,无法判断过期 -> 视为不可用
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解析失败 -> 视为不可用
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user