This commit is contained in:
2025-12-04 04:20:59 +08:00
parent 4e2bb49e86
commit 85f0cb613a
16 changed files with 243 additions and 210 deletions

View File

@@ -0,0 +1,10 @@
<Project Sdk="MSTest.Sdk/4.0.1">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View File

@@ -0,0 +1,11 @@
namespace Atomx.Admin.Tests
{
[TestClass]
public sealed class Test1
{
[TestMethod]
public void TestMethod1()
{
}
}
}

View File

@@ -0,0 +1,24 @@
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();
}
}

View File

@@ -1,4 +1,4 @@
using Atomx.Admin.Client.Utils;
using Atomx.Admin.Client.Services;
using Atomx.Common.Models;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components;
@@ -9,16 +9,10 @@ using System.Text.Json;
namespace Atomx.Admin.Client.Utils
{
/// <summary>
/// 请求拦截器(WASM 模式主要运行在浏览器端
/// 功能:
/// - 在每次请求时将 access token 附带到 Authorization header
/// - 在收到 401 或响应头包含 "Token-Expired" 时,尝试使用本地保存的 refresh token 调用 /api/sign/refresh
/// - 刷新成功:更新本地存储中的 accessToken/refreshToken然后重试原请求一次
/// - 刷新失败:跳转到登录页
/// 说明:
/// - 该实现依赖于 Blazored.LocalStoragekey 名称为 "accessToken" 和 "refreshToken"
/// 若你在项目中使用不同的键名,请统一替换。
/// - 为避免并发刷新,使用一个静态 SemaphoreSlim 进行序列化刷新请求。
/// WASM 模式下的请求拦截器DelegatingHandler
/// - 在每次请求时将 access token 附带 Authorization header
/// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh
/// - 防止并发刷新SemaphoreSlim
/// </summary>
public class AuthHeaderHandler : DelegatingHandler
{
@@ -29,7 +23,6 @@ namespace Atomx.Admin.Client.Utils
private readonly IHttpClientFactory _httpClientFactory;
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
// 本地存储键名(可按需修改)
private const string AccessTokenKey = "accessToken";
private const string RefreshTokenKey = "refreshToken";
@@ -52,7 +45,6 @@ namespace Atomx.Admin.Client.Utils
{
try
{
// 1) 尝试从 token provider 获取并添加 Authorization header
var token = await _tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(token))
{
@@ -65,35 +57,29 @@ namespace Atomx.Admin.Client.Utils
var response = await base.SendAsync(request, cancellationToken);
// 2) 检查 401 或 Token-Expired header
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.Headers.Contains("Token-Expired"))
{
_logger.LogInformation("Unauthorized or Token-Expired detected for {Url}", request.RequestUri);
// 仅在浏览器WASM模式下自动刷新在 Server 模式交由服务器端处理
if (OperatingSystem.IsBrowser())
{
var refreshed = await TryRefreshTokenAsync(cancellationToken);
if (refreshed)
{
// 获取新的 token 并重试请求(一次)
var newToken = await _localStorage.GetItemAsync<string>(AccessTokenKey);
if (!string.IsNullOrEmpty(newToken))
{
// 克隆原始请求HttpRequestMessage 只能发送一次)
var clonedRequest = await CloneHttpRequestMessageAsync(request, newToken);
return await base.SendAsync(clonedRequest, cancellationToken);
}
}
// 刷新失败,重定向登录
_navigationManager.NavigateTo("/account/login", true);
}
else
{
// Server 模式:记录日志,允许上层中间件决定下一步(不进行自动跳转)
_logger.LogWarning("Unauthorized in server mode for {Url}", request.RequestUri);
}
}
@@ -107,16 +93,8 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 尝试使用本地保存的 refresh token 调用刷新接口
/// API 约定:
/// POST /api/sign/refresh
/// Body: { token: "...", refreshToken: "..." }
/// 返回: AuthResponse { Token, RefreshToken, TokenExpiry }
/// </summary>
private async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken)
{
// 串行化刷新,防止多请求同时触发重复刷新
await _refreshLock.WaitAsync(cancellationToken);
try
{
@@ -129,8 +107,7 @@ namespace Atomx.Admin.Client.Utils
return false;
}
// 调用刷新接口(使用 IHttpClientFactory 创建短期 HttpClient
var client = _httpClientFactory.CreateClient(); // 默认 client建议在 Program.cs 中配置 BaseAddress
var client = _httpClientFactory.CreateClient();
var reqModel = new
{
token = currentAccess,
@@ -163,7 +140,6 @@ namespace Atomx.Admin.Client.Utils
return false;
}
// 保存新的 token本地存储
await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken);
await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, cancellationToken);
@@ -182,14 +158,10 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 复制 HttpRequestMessage 并替换 Authorization header 为新的 token
/// </summary>
private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage original, string newToken)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
// Copy request content (if any)
if (original.Content != null)
{
var ms = new MemoryStream();
@@ -197,7 +169,6 @@ namespace Atomx.Admin.Client.Utils
ms.Position = 0;
clone.Content = new StreamContent(ms);
// copy content headers
if (original.Content.Headers != null)
{
foreach (var h in original.Content.Headers)
@@ -205,19 +176,11 @@ namespace Atomx.Admin.Client.Utils
}
}
// copy headers
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
// set new auth header
clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken);
// copy properties
foreach (var prop in original.Options)
{
// HttpRequestOptions 不直接序列化拷贝,这里通常无需处理
}
return clone;
}
}

View File

@@ -1,15 +1,13 @@
using Atomx.Common.Configuration;
using Atomx.Common.Constants;
using Atomx.Admin.Client.Services;
using Microsoft.JSInterop;
namespace Atomx.Admin.Client.Utils
{
public interface ITokenProvider
{
Task<string?> GetTokenAsync();
Task<bool> IsTokenValidAsync();
}
/// <summary>
/// WASM 客户端下的 Token 提供器(实现共享的 ITokenProvider
/// - 直接从浏览器 storagelocalStorage/sessionStorage读取 access token
/// - 设计为轻量,仅负责读取 token刷新逻辑放在 AuthHeaderHandler / 后端 Refresh 接口
/// </summary>
public class ClientTokenProvider : ITokenProvider
{
private readonly IJSRuntime _jsRuntime;
@@ -23,8 +21,7 @@ namespace Atomx.Admin.Client.Utils
{
try
{
// 从localStorage或sessionStorage获取token
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", StorageKeys.AccessToken);
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "accessToken");
}
catch
{

View File

@@ -46,10 +46,8 @@ namespace Atomx.Admin.Controllers
/// <param name="size"></param>
/// <returns></returns>
[HttpPost("search")]
public IActionResult Search(AdminSearch search, int page, int size = 20)
{
Console.WriteLine($"Search Admin: {_identityService.GetUserId()}, page: {page}, size: {size}");
var startTime = search.RangeTime[0];
if (startTime != null)
{

View File

@@ -1,4 +1,5 @@
using Atomx.Admin.Models;
using Atomx.Admin.Services;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Text.RegularExpressions;
@@ -10,12 +11,14 @@ namespace Atomx.Admin.Middlewares
private readonly RequestDelegate _next;
private readonly ILogger<MonitoringMiddleware> _logger;
private readonly MonitoringOptions _options;
private readonly IIdentityService _identityService;
public MonitoringMiddleware(RequestDelegate next, ILogger<MonitoringMiddleware> logger, IOptions<MonitoringOptions> options)
public MonitoringMiddleware(RequestDelegate next, ILogger<MonitoringMiddleware> logger, IOptions<MonitoringOptions> options, IIdentityService identityService)
{
_next = next;
_logger = logger;
_options = options.Value;
_identityService = identityService;
}
public async Task InvokeAsync(HttpContext context)
@@ -29,6 +32,7 @@ namespace Atomx.Admin.Middlewares
var logInfo = new
{
UserId = _identityService.GetUserId(),
Path = context.Request.Path,
Method = context.Request.Method,
StartTime = DateTime.UtcNow,

View File

@@ -28,7 +28,7 @@ using System.Text.Unicode;
var builder = WebApplication.CreateBuilder(args);
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD> Serilog
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
@@ -42,8 +42,6 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All);
@@ -57,20 +55,28 @@ builder.Services.AddMapster();
builder.Services.AddBlazoredLocalStorage();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
// ע<><D7A2><EFBFBD><EFBFBD>Ȩ<EFBFBD><C8A8><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// Ȩ<>޷<EFBFBD><DEB7><EFBFBD>
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
// AuthenticationStateProvider<65><72>Server <20><><EFBFBD><EFBFBD>ʹ<EFBFBD>ÿ<EFBFBD><C3BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>֤<EFBFBD><D6A4>ʵ<EFBFBD><CAB5><EFBFBD><EFBFBD>ΪĬ<CEAA><C4AC>ע<EFBFBD><D7A2>
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ<EFBFBD><D2AA> Server <20><>ʹ<EFBFBD><CAB9> PersistentAuthenticationStateProvider <20>ľ<EFBFBD><C4BE><EFBFBD>ܣ<EFBFBD><DCA3><EFBFBD><EFBFBD>԰<EFBFBD><D4B0><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2>
builder.Services.AddScoped<PersistentAuthenticationStateProvider>();
builder.Services.AddScoped<IIdCreatorService, IdCreatorService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<ILocalizationService, LocalizationService>();
builder.Services.AddScoped<LocalizationFile, LocalizationFile>();
// Server <20><>ͳһ ITokenProvider <20><>ʵ<EFBFBD>֣<EFBFBD>WASM <20><><EFBFBD><EFBFBD> Program.cs <20><>ע<EFBFBD><D7A2> ClientTokenProvider
builder.Services.AddScoped<ITokenProvider, ServerTokenProvider>();
builder.Services.AddScoped<AuthHeaderHandler>();
// <20><><EFBFBD><EFBFBD> SignalR<6C><52>֧<EFBFBD><D6A7>ͨ<EFBFBD><CDA8> query string <20><><EFBFBD><EFBFBD> access_token<65><6E><EFBFBD><EFBFBD><EFBFBD><EFBFBD> websocket/auth<74><68>
builder.Services.AddSignalR();
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
builder.Services.AddDataService();
builder.Services.AddAuthorize(builder.Configuration, builder.Environment);

View File

@@ -0,0 +1,20 @@
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);
}
}

View File

@@ -1,11 +1,9 @@
using Atomx.Common.Entities;
using Atomx.Common.Constants;
using Atomx.Common.Entities;
using Atomx.Common.Models;
using Atomx.Data;
using Atomx.Data.CacheServices;
using Atomx.Utils.Extension;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
@@ -15,15 +13,14 @@ using System.Text.Json;
namespace Atomx.Admin.Services
{
public interface ITokenService
{
Task<AuthResponse> GenerateTokenAsync(User user, 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<User?> GetUserFromTokenAsync(string token);
}
/// <summary>
/// 负责:生成 access token / refresh token、刷新、撤销、验证Admin 专用)
/// 要点:
/// - RefreshToken 在数据库中以 SHA256(token + secret) 保存(不可逆)
/// - 只查 Admin 表(用户端不考虑 User
/// - 保留每个 Admin 最近 N 个未撤销的刷新令牌(配置项)
/// - 不在日志中写入明文 token
/// </summary>
public class TokenService : ITokenService
{
readonly DataContext _dbContext;
@@ -41,58 +38,57 @@ namespace Atomx.Admin.Services
_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);
}
public async Task<AuthResponse> GenerateTokenAsync(User user, string? ipAddress = null, string? userAgent = null)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
// helper to avoid analyzer complaining about direct field use in ctor defaulting
private int _jwt_setting_max() => _jwtSetting.MaxRefreshTokensPerUser;
// 检查用户是否被锁定
if (user.LockoutEndTime.HasValue && user.LockoutEndTime > DateTime.UtcNow)
/// <summary>
/// 生成一对 tokenaccess + 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("账户已被锁定");
// 生成访问令牌
var accessToken = GenerateAccessToken(user);
// 生成 access token
var accessToken = GenerateAccessToken(admin);
// 生成刷新令牌
// 生成 refresh token明文
var refreshToken = GenerateRefreshToken();
// 保存刷新令牌到数据库
// 保存 refresh token 的哈希到数据库(不可逆)
var refreshTokenEntity = new RefreshToken
{
Token = HashRefreshToken(refreshToken),
UserId = user.Id,
UserId = admin.Id, // 虽然叫 UserId但在 Admin 场景中表示 Admin.Id
IssuedTime = DateTime.UtcNow,
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Ip = ipAddress,
UserAgent = userAgent
};
// 移除旧的刷新令牌(安全策略)
await RemoveOldRefreshTokensAsync(user.Id);
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
await RemoveOldRefreshTokensAsync(admin.Id);
_dbContext.RefreshTokens.Add(refreshTokenEntity);
await _dbContext.SaveChangesAsync();
// 记录审计日志
//await _auditService.LogAsync(new AuditLog
//{
// UserId = user.Id,
// Action = "GenerateToken",
// Timestamp = DateTime.UtcNow,
// IpAddress = ipAddress,
// UserAgent = userAgent,
// Details = $"Token generated for user {user.Username}"
//});
// 缓存令牌(防止重复使用)
await CacheTokenAsync(accessToken, user.Id);
// 缓存 access token防止重复使用或可用于快速校验
await CacheTokenAsync(accessToken, admin.Id);
return new AuthResponse
{
@@ -102,53 +98,55 @@ namespace Atomx.Admin.Services
};
}
/// <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 userId = principal.FindFirst("sub")?.Value.ToLong();
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
if (userId == 0)
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
throw new SecurityTokenException("无效的令牌");
var user = await _dbContext.Users
.SingleOrDefaultAsync(u => u.Id == userId && u.IsActive);
if (user == null)
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 == userId &&
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;
// 生成新的令牌
var newTokenResponse = await GenerateTokenAsync(user, ipAddress, storedToken.UserAgent);
// 生成新的 access/refresh
var newTokens = await GenerateTokenAsync(admin, ipAddress, storedToken.UserAgent);
//// 记录审计日志
//await _auditService.LogAsync(new AuditLog
//{
// UserId = user.Id,
// Action = "RefreshToken",
// Timestamp = DateTime.UtcNow,
// IpAddress = ipAddress,
// Details = "Token refreshed successfully"
//});
// SaveChanges 已在 GenerateTokenAsync 调用中执行(但我们修改了 storedToken需要确保保存
await _dbContext.SaveChangesAsync();
return newTokenResponse;
return newTokens;
}
/// <summary>
/// 撤销某个明文 refresh token用于登出
/// </summary>
public async Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null)
{
var hashedToken = HashRefreshToken(refreshToken);
@@ -164,21 +162,16 @@ namespace Atomx.Admin.Services
await _dbContext.SaveChangesAsync();
// 清除缓存
// 清除与用户相关的缓存(例如 user info
await _cacheService.Remove($"user:{token.UserId}");
//await _auditService.LogAsync(new AuditLog
//{
// UserId = token.UserId,
// Action = "RevokeToken",
// Timestamp = DateTime.UtcNow,
// IpAddress = ipAddress,
// Details = "Token revoked"
//});
return true;
}
/// <summary>
/// 验证 access token完整验证签名、issuer、audience、过期
/// 额外:检查 token 是否在撤销缓存中
/// </summary>
public async Task<bool> ValidateTokenAsync(string token)
{
try
@@ -211,36 +204,39 @@ namespace Atomx.Admin.Services
}
}
public async Task<User?> GetUserFromTokenAsync(string token)
/// <summary>
/// 根据 access token 获取 Admin如果 token 合法)。
/// - 优先从缓存读取 Admin 对象
/// - 如果缓存不存在则从数据库读取并缓存(短期)
/// </summary>
public async Task<Atomx.Common.Entities.Admin?> GetAdminFromTokenAsync(string token)
{
try
{
var principal = GetPrincipalFromToken(token);
var userId = principal.FindFirst("sub")?.Value.ToLong();
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
if (userId == 0)
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
return null;
// 尝试从缓存获取
var cacheKey = $"user:{userId}";
var cacheKey = $"user:{adminId}";
var cachedUser = await _cacheService.GetCacheString(cacheKey);
if (!string.IsNullOrEmpty(cachedUser))
{
return JsonSerializer.Deserialize<User>(cachedUser);
return JsonSerializer.Deserialize<Atomx.Common.Entities.Admin>(cachedUser);
}
// 从数据库获取
var user = await _dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
if (user != null)
// 从数据库获取 Admin
var admin = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
if (admin != null)
{
// 缓存用户信息5分钟
await _cacheService.SetCacheAsync(cacheKey, JsonSerializer.Serialize(user), 5);
// 缓存 admin 信息(单位:分钟,短期缓存
await _cacheService.SetCacheAsync(cacheKey, JsonSerializer.Serialize(admin), 5);
}
return user;
return admin;
}
catch
{
@@ -248,23 +244,20 @@ namespace Atomx.Admin.Services
}
}
private string GenerateAccessToken(User user)
/// <summary>
/// 生成访问令牌,包含必要 claims。
/// 使用项目常量 ClaimKeys 以保证前后端一致。
/// </summary>
private string GenerateAccessToken(Atomx.Common.Entities.Admin admin)
{
var claims = new[]
var claims = new List<Claim>
{
new Claim("sub", user.Id.ToString()),
new Claim(ClaimKeys.Id, admin.Id.ToString()),
new Claim("jti", Guid.NewGuid().ToString()),
new Claim("name", user.Name),
new Claim("email", user.Email),
new Claim("email_confirmed", user.EmailConfirmed.ToString().ToLower()),
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)
}.ToList();
// 添加角色声明
//foreach (var userRole in user.UserRoles)
//{
// claims.Add(new Claim("role", userRole.Role.ToString()));
//}
};
var tokenDescriptor = new SecurityTokenDescriptor
{
@@ -281,6 +274,9 @@ namespace Atomx.Admin.Services
return tokenHandler.WriteToken(token);
}
/// <summary>
/// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希
/// </summary>
private string GenerateRefreshToken()
{
var randomNumber = new byte[64];
@@ -289,6 +285,9 @@ namespace Atomx.Admin.Services
return Convert.ToBase64String(randomNumber);
}
/// <summary>
/// 根据 access token 验证并返回 ClaimsPrincipal要求 token 未过期)
/// </summary>
private ClaimsPrincipal GetPrincipalFromToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
@@ -316,6 +315,9 @@ namespace Atomx.Admin.Services
}
}
/// <summary>
/// 从已过期的 access token 中读取 ClaimsPrincipal不验证 lifetime用于 refresh 操作)
/// </summary>
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
@@ -330,7 +332,7 @@ namespace Atomx.Admin.Services
ValidIssuer = _jwtSetting.Issuer,
ValidateAudience = true,
ValidAudience = _jwtSetting.Audience,
ValidateLifetime = false, // 注意:这里不验证过期时间
ValidateLifetime = false, // 不验证过期以便 refresh
ClockSkew = TimeSpan.Zero
}, out _);
@@ -343,6 +345,10 @@ namespace Atomx.Admin.Services
}
}
/// <summary>
/// 哈希刷新令牌不可逆SHA256( refreshToken + secret )
/// 数据库仅保存该值,客户端保存明文 refreshToken
/// </summary>
private string HashRefreshToken(string refreshToken)
{
using var sha256 = SHA256.Create();
@@ -351,6 +357,9 @@ namespace Atomx.Admin.Services
return Convert.ToBase64String(hash);
}
/// <summary>
/// 哈希 access token用于撤销缓存 key
/// </summary>
private string HashToken(string token)
{
using var sha256 = SHA256.Create();
@@ -359,12 +368,15 @@ namespace Atomx.Admin.Services
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.ExpiresTime)
.Skip(_jwtSetting.MaxRefreshTokensPerUser - 1)
.OrderByDescending(rt => rt.IssuedTime)
.Skip(Math.Max(0, _jwtSetting.MaxRefreshTokensPerUser - 1))
.ToListAsync();
foreach (var token in tokens)
@@ -372,8 +384,13 @@ namespace Atomx.Admin.Services
token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow;
}
// 注意:调用方需要在适当位置 SaveChangesAsyncGenerateTokenAsync 已经在添加新 token 后保存)
}
/// <summary>
/// 将 access token 哈希写入缓存(用于快速拒绝等),过期时间与 access token 保持一致(分钟)
/// </summary>
private async Task CacheTokenAsync(string token, long userId)
{
var cacheKey = $"token:{HashToken(token)}";

View File

@@ -1,19 +1,18 @@
using Atomx.Admin.Client.Utils;
using Atomx.Admin.Client.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using System.IdentityModel.Tokens.Jwt;
namespace Atomx.Admin.Utils
{
/// <summary>
/// Server 模式下的 Token 提供器
/// 目的:
/// - 在 Blazor Server 环境中为后端 HttpClient / 服务提供当前请求的 access token
/// - 支持从 Authorization header、SignalR queryaccess_token、cookie 或 HttpContext 的身份令牌中读取
/// - 对 JWT 做基本的过期检查(如果是 JWT 格式),以便快速判断 token 是否可用
/// 说明:
/// - 这个类只负责从当前 HttpContext 中“读取”token不做刷新之类的动作刷新留给专门的 TokenService / 客户端逻辑)。
/// - 如果没有 HttpContext例如后台任务则返回 null。
/// 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
{
@@ -24,14 +23,6 @@ namespace Atomx.Admin.Utils
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 尝试从当前请求中读取 access token按优先级
/// 1. Authorization header ("Bearer ...")
/// 2. Query string "access_token"SignalR 客户端会把 token 放在这里)
/// 3. HttpContext 的认证 token (HttpContext.GetTokenAsync("access_token")) - 适配 cookie/token 保存的场景
/// 4. Cookies["access_token"]
/// 5. HttpContext.Items["access_token"](如果中间件/自定义逻辑放在这里)
/// </summary>
public async Task<string?> GetTokenAsync()
{
var ctx = _httpContextAccessor.HttpContext;
@@ -67,16 +58,16 @@ namespace Atomx.Admin.Utils
}
catch
{
// 安全忽略错误GetTokenAsync 在某些场景下可能为 null 或抛异常)
// 安全忽略
}
// 4) Cookies如果你的系统将 token 写入 cookie通常不建议但为兼容性保留
// 4) Cookie兼容性)
if (ctx.Request.Cookies.TryGetValue("access_token", out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
{
return cookieToken;
}
// 5) Items / 特殊存储点(某些中间件可能会放在这里
// 5) Items(中间件临时注入
if (ctx.Items.TryGetValue("access_token", out var itemToken) && itemToken is string sToken && !string.IsNullOrEmpty(sToken))
{
return sToken;
@@ -85,42 +76,33 @@ namespace Atomx.Admin.Utils
return null;
}
/// <summary>
/// 快速判断 token 是否存在且(如果是 JWT未过期
/// 注意:此判断为快速检查(不替代服务器端的完整 Token 验证)
/// </summary>
public async Task<bool> IsTokenValidAsync()
{
var token = await GetTokenAsync();
if (string.IsNullOrEmpty(token))
return false;
// 如果是 JWT可以解析 exp 做快速过期检查
try
{
var handler = new JwtSecurityTokenHandler();
if (handler.CanReadToken(token))
{
var jwt = handler.ReadJwtToken(token);
// exp claim 是 unix 时间seconds
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 true;
// 没有 exp claim无法判断过期 -> 视为不可用
return false;
}
}
catch
{
// 解析错误 -> 不影响业务,判为不可用(若不是 JWT 则无法判断)
// 解析失败 -> 视为不可用
}
// 如果不是 JWT简单返回 true存在 token 即可)
return true;
return false;
}
}
}

View File

@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Atomx.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20251203175828_0.1")]
[Migration("20251203190956_0.1")]
partial class _01
{
/// <inheritdoc />
@@ -1135,7 +1135,7 @@ namespace Atomx.Data.Migrations
b.Property<DateTime>("IssuedTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("RevokedTime")
b.Property<DateTime?>("RevokedTime")
.HasColumnType("timestamptz");
b.Property<string>("Token")

View File

@@ -527,7 +527,7 @@ namespace Atomx.Data.Migrations
IssuedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExpiresTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
RevokedTime = table.Column<DateTime>(type: "timestamptz", nullable: false),
RevokedTime = table.Column<DateTime>(type: "timestamptz", nullable: true),
Ip = table.Column<string>(type: "varchar(50)", nullable: false),
UserAgent = table.Column<string>(type: "varchar(500)", nullable: false)
},

View File

@@ -1132,7 +1132,7 @@ namespace Atomx.Data.Migrations
b.Property<DateTime>("IssuedTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("RevokedTime")
b.Property<DateTime?>("RevokedTime")
.HasColumnType("timestamptz");
b.Property<string>("Token")

View File

@@ -17,7 +17,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Utils", "Atomx.Utils\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.WebAPI", "Atomx.WebAPI\Atomx.WebAPI.csproj", "{D214046F-0D80-4361-9964-395234C6FF11}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Test", "Atomx.Test\Atomx.Test.csproj", "{60D4714E-1DBE-4381-9B22-5894F1310561}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Admin.Tests", "Atomx.Admin.Tests\Atomx.Admin.Tests.csproj", "{23D52214-1385-4268-AC99-9853E15E7A91}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -53,10 +53,10 @@ Global
{D214046F-0D80-4361-9964-395234C6FF11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D214046F-0D80-4361-9964-395234C6FF11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D214046F-0D80-4361-9964-395234C6FF11}.Release|Any CPU.Build.0 = Release|Any CPU
{60D4714E-1DBE-4381-9B22-5894F1310561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60D4714E-1DBE-4381-9B22-5894F1310561}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60D4714E-1DBE-4381-9B22-5894F1310561}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60D4714E-1DBE-4381-9B22-5894F1310561}.Release|Any CPU.Build.0 = Release|Any CPU
{23D52214-1385-4268-AC99-9853E15E7A91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{23D52214-1385-4268-AC99-9853E15E7A91}.Debug|Any CPU.Build.0 = Debug|Any CPU
{23D52214-1385-4268-AC99-9853E15E7A91}.Release|Any CPU.ActiveCfg = Release|Any CPU
{23D52214-1385-4268-AC99-9853E15E7A91}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE