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