chore
This commit is contained in:
24
Atomx.Admin/Atomx.Admin.Client/Services/ITokenProvider.cs
Normal file
24
Atomx.Admin/Atomx.Admin.Client/Services/ITokenProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.LocalStorage(key 名称为 "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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
/// - 直接从浏览器 storage(localStorage/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
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,13 +28,13 @@ 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()
|
||||
.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();
|
||||
// ע<><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);
|
||||
builder.Services.AddAuthorize(builder.Configuration, builder.Environment);
|
||||
|
||||
var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||
builder.Services.AddDbContext<DataContext>(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
|
||||
|
||||
20
Atomx.Admin/Atomx.Admin/Services/ITokenService.cs
Normal file
20
Atomx.Admin/Atomx.Admin/Services/ITokenService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
/// 生成一对 token(access + refresh)并将 Refresh 的哈希存库。
|
||||
/// 返回的 RefreshToken 为明文(仅用于客户端存储),数据库只存 Hash。
|
||||
/// </summary>
|
||||
public async Task<AuthResponse> GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null)
|
||||
{
|
||||
if (admin == null)
|
||||
throw new ArgumentNullException(nameof(admin));
|
||||
|
||||
// 检查是否被锁定(Admin 有 LockoutEndTime)
|
||||
if (admin.LockoutEndTime.HasValue && admin.LockoutEndTime > DateTime.UtcNow)
|
||||
throw new InvalidOperationException("账户已被锁定");
|
||||
|
||||
// 生成访问令牌
|
||||
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("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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// 注意:调用方需要在适当位置 SaveChangesAsync(GenerateTokenAsync 已经在添加新 token 后保存)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 access token 哈希写入缓存(用于快速拒绝等),过期时间与 access token 保持一致(分钟)
|
||||
/// </summary>
|
||||
private async Task CacheTokenAsync(string token, long userId)
|
||||
{
|
||||
var cacheKey = $"token:{HashToken(token)}";
|
||||
|
||||
@@ -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 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)
|
||||
/// </summary>
|
||||
public class ServerTokenProvider : ITokenProvider
|
||||
{
|
||||
@@ -24,16 +23,8 @@ 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user