diff --git a/Atomx.Admin.Tests/Atomx.Admin.Tests.csproj b/Atomx.Admin.Tests/Atomx.Admin.Tests.csproj new file mode 100644 index 0000000..4156292 --- /dev/null +++ b/Atomx.Admin.Tests/Atomx.Admin.Tests.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + latest + enable + enable + + + diff --git a/Atomx.Admin.Tests/MSTestSettings.cs b/Atomx.Admin.Tests/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/Atomx.Admin.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/Atomx.Admin.Tests/Test1.cs b/Atomx.Admin.Tests/Test1.cs new file mode 100644 index 0000000..395deb0 --- /dev/null +++ b/Atomx.Admin.Tests/Test1.cs @@ -0,0 +1,11 @@ +namespace Atomx.Admin.Tests +{ + [TestClass] + public sealed class Test1 + { + [TestMethod] + public void TestMethod1() + { + } + } +} diff --git a/Atomx.Admin/Atomx.Admin.Client/Services/ITokenProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Services/ITokenProvider.cs new file mode 100644 index 0000000..c2b049a --- /dev/null +++ b/Atomx.Admin/Atomx.Admin.Client/Services/ITokenProvider.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace Atomx.Admin.Client.Services +{ + /// + /// 统一的 Token 提供器接口(放在共享项目) + /// 目标: + /// - Server 与 WASM 使用相同的接口类型以避免 DI 注入类型不一致 + /// - 仅负责“提供”当前可用的 access token(不承担刷新策略) + /// + public interface ITokenProvider + { + /// + /// 返回当前可用的 access token(如果没有则返回 null) + /// + Task GetTokenAsync(); + + /// + /// 快速判断当前 token 是否存在且(如果可以解析为 JWT)未过期。 + /// 注意:此方法为快速检查,不能替代服务端的完整验证。 + /// + Task IsTokenValidAsync(); + } +} diff --git a/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs b/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs index 2dff5e8..21dbd05 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs @@ -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 { /// - /// 请求拦截器(WASM 模式主要运行在浏览器端) - /// 功能: - /// - 在每次请求时将 access token 附带到 Authorization header - /// - 在收到 401 或响应头包含 "Token-Expired" 时,尝试使用本地保存的 refresh token 调用 /api/sign/refresh - /// - 刷新成功:更新本地存储中的 accessToken/refreshToken,然后重试原请求一次 - /// - 刷新失败:跳转到登录页 - /// 说明: - /// - 该实现依赖于 Blazored.LocalStorage(key 名称为 "accessToken" 和 "refreshToken"), - /// 若你在项目中使用不同的键名,请统一替换。 - /// - 为避免并发刷新,使用一个静态 SemaphoreSlim 进行序列化刷新请求。 + /// WASM 模式下的请求拦截器(DelegatingHandler) + /// - 在每次请求时将 access token 附带 Authorization header + /// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh) + /// - 防止并发刷新(SemaphoreSlim) /// 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(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 } } - /// - /// 尝试使用本地保存的 refresh token 调用刷新接口 - /// API 约定: - /// POST /api/sign/refresh - /// Body: { token: "...", refreshToken: "..." } - /// 返回: AuthResponse { Token, RefreshToken, TokenExpiry } - /// private async Task 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 } } - /// - /// 复制 HttpRequestMessage 并替换 Authorization header 为新的 token - /// private static async Task 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; } } diff --git a/Atomx.Admin/Atomx.Admin.Client/Utils/TokenProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Utils/ClientTokenProvider.cs similarity index 64% rename from Atomx.Admin/Atomx.Admin.Client/Utils/TokenProvider.cs rename to Atomx.Admin/Atomx.Admin.Client/Utils/ClientTokenProvider.cs index 98f5748..4a5a334 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Utils/TokenProvider.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Utils/ClientTokenProvider.cs @@ -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 GetTokenAsync(); - Task IsTokenValidAsync(); - } - + /// + /// WASM 客户端下的 Token 提供器(实现共享的 ITokenProvider) + /// - 直接从浏览器 storage(localStorage/sessionStorage)读取 access token + /// - 设计为轻量,仅负责读取 token;刷新逻辑放在 AuthHeaderHandler / 后端 Refresh 接口 + /// 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("localStorage.getItem", StorageKeys.AccessToken); + return await _jsRuntime.InvokeAsync("localStorage.getItem", "accessToken"); } catch { diff --git a/Atomx.Admin/Atomx.Admin/Controllers/AdminController.cs b/Atomx.Admin/Atomx.Admin/Controllers/AdminController.cs index c8c5294..137ca76 100644 --- a/Atomx.Admin/Atomx.Admin/Controllers/AdminController.cs +++ b/Atomx.Admin/Atomx.Admin/Controllers/AdminController.cs @@ -46,10 +46,8 @@ namespace Atomx.Admin.Controllers /// /// [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) { diff --git a/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs b/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs index 9a60dc3..ede3f70 100644 --- a/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs +++ b/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs @@ -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 _logger; private readonly MonitoringOptions _options; + private readonly IIdentityService _identityService; - public MonitoringMiddleware(RequestDelegate next, ILogger logger, IOptions options) + public MonitoringMiddleware(RequestDelegate next, ILogger logger, IOptions 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, diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs index d962c37..a450b58 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -28,13 +28,13 @@ using System.Text.Unicode; var builder = WebApplication.CreateBuilder(args); -// Serilog +// Configure Serilog Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty("Application", "Atomx.Admin") - .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); // Add services to the container. @@ -42,8 +42,6 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); - - builder.Services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All); @@ -57,23 +55,31 @@ builder.Services.AddMapster(); builder.Services.AddBlazoredLocalStorage(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddHttpContextAccessor(); -// עȨ + +// Ȩ޷ builder.Services.AddScoped(); builder.Services.AddScoped(); +// AuthenticationStateProviderServer ʹÿ֤ʵΪĬע builder.Services.AddScoped(); -builder.Services.AddScoped(); +// Ҫ Server ʹ PersistentAuthenticationStateProvider ľ幦ܣ԰ע +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + +// Server ͳһ ITokenProvider ʵ֣WASM Program.cs ע ClientTokenProvider builder.Services.AddScoped(); builder.Services.AddScoped(); +// SignalR֧ͨ query string access_token websocket/auth +builder.Services.AddSignalR(); + builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost"); builder.Services.AddDataService(); -builder.Services.AddAuthorize(builder.Configuration,builder.Environment); +builder.Services.AddAuthorize(builder.Configuration, builder.Environment); var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); builder.Services.AddDbContext(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory"))); diff --git a/Atomx.Admin/Atomx.Admin/Services/ITokenService.cs b/Atomx.Admin/Atomx.Admin/Services/ITokenService.cs new file mode 100644 index 0000000..c8d6f68 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Services/ITokenService.cs @@ -0,0 +1,20 @@ +using Atomx.Common.Entities; +using Atomx.Common.Models; + +namespace Atomx.Admin.Services +{ + /// + /// Token 服务接口(Admin 专用)。 + /// - 生成 / 刷新 / 撤销 刷新令牌 + /// - 验证 access token + /// - 根据 token 获取 Admin 实体 + /// + public interface ITokenService + { + Task GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null); + Task RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null); + Task RevokeTokenAsync(string refreshToken, string? ipAddress = null); + Task ValidateTokenAsync(string token); + Task GetAdminFromTokenAsync(string token); + } +} diff --git a/Atomx.Admin/Atomx.Admin/Services/TokenService.cs b/Atomx.Admin/Atomx.Admin/Services/TokenService.cs index 8d43876..839e170 100644 --- a/Atomx.Admin/Atomx.Admin/Services/TokenService.cs +++ b/Atomx.Admin/Atomx.Admin/Services/TokenService.cs @@ -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 GenerateTokenAsync(User user, string? ipAddress = null, string? userAgent = null); - Task RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null); - Task RevokeTokenAsync(string refreshToken, string? ipAddress = null); - Task ValidateTokenAsync(string token); - Task GetUserFromTokenAsync(string token); - } - + /// + /// 负责:生成 access token / refresh token、刷新、撤销、验证(Admin 专用) + /// 要点: + /// - RefreshToken 在数据库中以 SHA256(token + secret) 保存(不可逆) + /// - 只查 Admin 表(用户端不考虑 User) + /// - 保留每个 Admin 最近 N 个未撤销的刷新令牌(配置项) + /// - 不在日志中写入明文 token + /// 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 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) + /// + /// 生成一对 token(access + refresh)并将 Refresh 的哈希存库。 + /// 返回的 RefreshToken 为明文(仅用于客户端存储),数据库只存 Hash。 + /// + public async Task 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 }; } + /// + /// 使用已过期的 access token(只用于读取身份信息)+ 明文 refreshToken 来刷新。 + /// 业务: + /// - 验证 access token 签名与 issuer/audience(允许过期) + /// - 根据 Claim 中的 admin id 在 Admins 表查找 + /// - 验证 refresh token 的哈希是否在数据库且未撤销未过期 + /// - 将该 refresh token 标记为撤销并生成新的对 token 返回 + /// public async Task 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; } + /// + /// 撤销某个明文 refresh token(用于登出) + /// public async Task 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; } + /// + /// 验证 access token(完整验证:签名、issuer、audience、过期) + /// 额外:检查 token 是否在撤销缓存中 + /// public async Task ValidateTokenAsync(string token) { try @@ -211,36 +204,39 @@ namespace Atomx.Admin.Services } } - public async Task GetUserFromTokenAsync(string token) + /// + /// 根据 access token 获取 Admin(如果 token 合法)。 + /// - 优先从缓存读取 Admin 对象 + /// - 如果缓存不存在则从数据库读取并缓存(短期) + /// + public async Task 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(cachedUser); + return JsonSerializer.Deserialize(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) + /// + /// 生成访问令牌,包含必要 claims。 + /// 使用项目常量 ClaimKeys 以保证前后端一致。 + /// + private string GenerateAccessToken(Atomx.Common.Entities.Admin admin) { - var claims = new[] + var claims = new List { - new Claim("sub", user.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("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) - }.ToList(); - - // 添加角色声明 - //foreach (var userRole in user.UserRoles) - //{ - // claims.Add(new Claim("role", userRole.Role.ToString())); - //} + 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 { @@ -281,6 +274,9 @@ namespace Atomx.Admin.Services return tokenHandler.WriteToken(token); } + /// + /// 生成随机 refresh token(明文,由服务返回到客户端,数据库仅存哈希) + /// private string GenerateRefreshToken() { var randomNumber = new byte[64]; @@ -289,6 +285,9 @@ namespace Atomx.Admin.Services return Convert.ToBase64String(randomNumber); } + /// + /// 根据 access token 验证并返回 ClaimsPrincipal(要求 token 未过期) + /// private ClaimsPrincipal GetPrincipalFromToken(string token) { var tokenHandler = new JwtSecurityTokenHandler(); @@ -316,6 +315,9 @@ namespace Atomx.Admin.Services } } + /// + /// 从已过期的 access token 中读取 ClaimsPrincipal(不验证 lifetime,用于 refresh 操作) + /// 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 } } + /// + /// 哈希刷新令牌(不可逆):SHA256( refreshToken + secret ) + /// 数据库仅保存该值,客户端保存明文 refreshToken + /// private string HashRefreshToken(string refreshToken) { using var sha256 = SHA256.Create(); @@ -351,6 +357,9 @@ namespace Atomx.Admin.Services return Convert.ToBase64String(hash); } + /// + /// 哈希 access token(用于撤销缓存 key) + /// private string HashToken(string token) { using var sha256 = SHA256.Create(); @@ -359,12 +368,15 @@ namespace Atomx.Admin.Services return Convert.ToBase64String(hash); } + /// + /// 撤销并保留最近 N 个未撤销的刷新令牌(超出部分标记为已撤销) + /// 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; } + + // 注意:调用方需要在适当位置 SaveChangesAsync(GenerateTokenAsync 已经在添加新 token 后保存) } + /// + /// 将 access token 哈希写入缓存(用于快速拒绝等),过期时间与 access token 保持一致(分钟) + /// private async Task CacheTokenAsync(string token, long userId) { var cacheKey = $"token:{HashToken(token)}"; diff --git a/Atomx.Admin/Atomx.Admin/Utils/ServerTokenProvider.cs b/Atomx.Admin/Atomx.Admin/Utils/ServerTokenProvider.cs index da36cd1..d7cb686 100644 --- a/Atomx.Admin/Atomx.Admin/Utils/ServerTokenProvider.cs +++ b/Atomx.Admin/Atomx.Admin/Utils/ServerTokenProvider.cs @@ -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 { /// - /// Server 模式下的 Token 提供器 - /// 目的: - /// - 在 Blazor Server 环境中为后端 HttpClient / 服务提供当前请求的 access token - /// - 支持从 Authorization header、SignalR query(access_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) /// public class ServerTokenProvider : ITokenProvider { @@ -24,16 +23,8 @@ namespace Atomx.Admin.Utils _httpContextAccessor = httpContextAccessor; } - /// - /// 尝试从当前请求中读取 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"](如果中间件/自定义逻辑放在这里) - /// public async Task GetTokenAsync() - { + { var ctx = _httpContextAccessor.HttpContext; if (ctx == null) return null; @@ -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; } - /// - /// 快速判断 token 是否存在且(如果是 JWT)未过期 - /// 注意:此判断为快速检查(不替代服务器端的完整 Token 验证) - /// public async Task 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; } } } diff --git a/Atomx.Data/Migrations/20251203175828_0.1.Designer.cs b/Atomx.Data/Migrations/20251203190956_0.1.Designer.cs similarity index 99% rename from Atomx.Data/Migrations/20251203175828_0.1.Designer.cs rename to Atomx.Data/Migrations/20251203190956_0.1.Designer.cs index 07e1e42..f00a3d1 100644 --- a/Atomx.Data/Migrations/20251203175828_0.1.Designer.cs +++ b/Atomx.Data/Migrations/20251203190956_0.1.Designer.cs @@ -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 { /// @@ -1135,7 +1135,7 @@ namespace Atomx.Data.Migrations b.Property("IssuedTime") .HasColumnType("timestamp with time zone"); - b.Property("RevokedTime") + b.Property("RevokedTime") .HasColumnType("timestamptz"); b.Property("Token") diff --git a/Atomx.Data/Migrations/20251203175828_0.1.cs b/Atomx.Data/Migrations/20251203190956_0.1.cs similarity index 99% rename from Atomx.Data/Migrations/20251203175828_0.1.cs rename to Atomx.Data/Migrations/20251203190956_0.1.cs index 07c6479..bdd4d7c 100644 --- a/Atomx.Data/Migrations/20251203175828_0.1.cs +++ b/Atomx.Data/Migrations/20251203190956_0.1.cs @@ -527,7 +527,7 @@ namespace Atomx.Data.Migrations IssuedTime = table.Column(type: "timestamp with time zone", nullable: false), ExpiresTime = table.Column(type: "timestamp with time zone", nullable: false), IsRevoked = table.Column(type: "boolean", nullable: false), - RevokedTime = table.Column(type: "timestamptz", nullable: false), + RevokedTime = table.Column(type: "timestamptz", nullable: true), Ip = table.Column(type: "varchar(50)", nullable: false), UserAgent = table.Column(type: "varchar(500)", nullable: false) }, diff --git a/Atomx.Data/Migrations/DataContextModelSnapshot.cs b/Atomx.Data/Migrations/DataContextModelSnapshot.cs index 18424b3..01d7ee3 100644 --- a/Atomx.Data/Migrations/DataContextModelSnapshot.cs +++ b/Atomx.Data/Migrations/DataContextModelSnapshot.cs @@ -1132,7 +1132,7 @@ namespace Atomx.Data.Migrations b.Property("IssuedTime") .HasColumnType("timestamp with time zone"); - b.Property("RevokedTime") + b.Property("RevokedTime") .HasColumnType("timestamptz"); b.Property("Token") diff --git a/Atomx.sln b/Atomx.sln index 2b3ddd2..2ab5c18 100644 --- a/Atomx.sln +++ b/Atomx.sln @@ -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