Compare commits

..

2 Commits

Author SHA1 Message Date
yxw
bd95848972 fix auth 2025-12-04 19:07:04 +08:00
yxw
6217a8ca55 chore 2025-12-04 17:14:46 +08:00
20 changed files with 758 additions and 789 deletions

View File

@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using System.Security.Claims
@inject IPermissionService PermissionService @inject IPermissionService PermissionService
@inject IAuthorizationService AuthorizationService @inject IAuthorizationService AuthorizationService
@@ -24,6 +25,8 @@
</CascadingAuthenticationState> </CascadingAuthenticationState>
@code { @code {
[CascadingParameter] Task<AuthenticationState>? AuthenticationStateTask { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Permission { get; set; } [Parameter] public string? Permission { get; set; }
[Parameter] public string[]? Permissions { get; set; } [Parameter] public string[]? Permissions { get; set; }
@@ -36,25 +39,83 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (!string.IsNullOrEmpty(Policy)) _hasPermission = false;
var authState = AuthenticationStateTask is null
? await Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())))
: await AuthenticationStateTask;
var user = authState.User;
if (user?.Identity is null || !user.Identity.IsAuthenticated)
{ {
var authState = await AuthorizationService.AuthorizeAsync(null, Policy); _hasPermission = false;
_hasPermission = authState.Succeeded; return;
} }
else if (!string.IsNullOrEmpty(Permission))
// 优先基于声明快速判断(适用于 Server 与 WASM
if (!string.IsNullOrEmpty(Permission))
{ {
_hasPermission = await PermissionService.HasPermissionAsync(Permission); if (user.Claims.Any(c => c.Type == ClaimKeys.Permission && c.Value == Permission))
{
_hasPermission = true;
return;
}
// 回退:调用后端权限服务(适用于 Server-side 权限来源于数据库)
_hasPermission = await SafeHasPermissionAsync(Permission);
return;
} }
else if (Permissions != null && Permissions.Length > 0)
if (Permissions != null && Permissions.Length > 0)
{ {
var userPermissions = user.Claims.Where(c => c.Type == ClaimKeys.Permission).Select(c => c.Value).ToHashSet();
if (RequireAll) if (RequireAll)
{ {
_hasPermission = await PermissionService.HasAllPermissionsAsync(Permissions); if (Permissions.All(p => userPermissions.Contains(p)))
{
_hasPermission = true;
return;
}
} }
else else
{ {
_hasPermission = await PermissionService.HasAnyPermissionAsync(Permissions); if (Permissions.Any(p => userPermissions.Contains(p)))
{
_hasPermission = true;
return;
}
} }
// 回退:调用后端权限服务
if (RequireAll)
_hasPermission = await PermissionService.HasAllPermissionsAsync(Permissions);
else
_hasPermission = await PermissionService.HasAnyPermissionAsync(Permissions);
return;
}
if (!string.IsNullOrEmpty(Policy))
{
// 使用 AuthorizationService 并传入当前用户
var result = await AuthorizationService.AuthorizeAsync(user, Policy);
_hasPermission = result.Succeeded;
return;
}
}
private async Task<bool> SafeHasPermissionAsync(string permission)
{
try
{
return await PermissionService.HasPermissionAsync(permission);
}
catch
{
// 出错时默认拒绝
return false;
} }
} }
} }

View File

@@ -1,6 +1,8 @@
@page "/account/login" @page "/account/login"
@using System.Text.Json
@layout EmptyLayout @layout EmptyLayout
@inject ILogger<Login> Logger @inject ILogger<Login> Logger
@inject IJSRuntime JS
<PageTitle>登录</PageTitle> <PageTitle>登录</PageTitle>
@@ -48,7 +50,6 @@ else
</Flex> </Flex>
} }
@code { @code {
string handler = "Server"; string handler = "Server";
@@ -104,30 +105,53 @@ else
try try
{ {
// 请求后端登录接口,后端返回 ApiResult<AuthResponse>
var api = "/api/sign/in"; var api = "/api/sign/in";
var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
if (result.Success && result.Data != null) if (!OperatingSystem.IsBrowser())
{ {
var auth = result.Data; // Server 模式:使用浏览器发起的 fetch通过 JS并携带 credentials: 'include'
Logger.LogInformation("Server 模式,使用浏览器 fetch 登录");
var jsResult = await JS.InvokeAsync<JsonElement>("__atomx_post_json", api, login);
// 保存 access + refresh 到 localStorageWASM 场景) var success = jsResult.TryGetProperty("success", out var sprop) && sprop.GetBoolean();
await localStorage.SetItemAsync("accessToken", auth.Token); if (success && jsResult.TryGetProperty("data", out var dprop) && dprop.ValueKind == JsonValueKind.Object)
await localStorage.SetItemAsync("refreshToken", auth.RefreshToken);
// 更新客户端 AuthenticationState调用自定义 Provider 更新方法)
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
{ {
// provider 仅需要 access token 更新来触发 UI 更新 var token = dprop.TryGetProperty("token", out var t) ? t.GetString() ?? string.Empty : string.Empty;
provider.UpdateAuthenticationState(auth.Token); var refresh = dprop.TryGetProperty("refreshToken", out var r) ? r.GetString() ?? string.Empty : string.Empty;
}
Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl); // WASM 的 localStorage 在 Server Circuit 中无意义,这里不用写 localStorage。
Navigation.NavigateTo(ReturnUrl ?? "/"); // 浏览器已通过 fetch 收到 Set-Cookie强制重载使 Circuit 使用新 Cookie。
Logger.LogInformation("登录成功server 跳转: {ReturnUrl}", ReturnUrl);
Navigation.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
}
else
{
var msg = jsResult.TryGetProperty("message", out var m) ? m.GetString() ?? "登录失败" : "登录失败";
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = msg });
}
} }
else else
{ {
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message }); // Wasm 模式:继续使用 HttpService之前逻辑保存 localStorage 并更新 AuthStateProvider
var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
if (result.Success && result.Data != null)
{
var auth = result.Data;
await localStorage.SetItemAsync(StorageKeys.AccessToken, auth.Token);
await localStorage.SetItemAsync(StorageKeys.RefreshToken, auth.RefreshToken);
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
{
provider.UpdateAuthenticationState(auth.Token);
}
Logger.LogInformation("登录成功wasm 跳转: {ReturnUrl}", ReturnUrl);
Navigation.NavigateTo(ReturnUrl ?? "/");
}
else
{
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message });
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -149,4 +173,26 @@ else
await LoginAsync(); await LoginAsync();
} }
} }
} }
@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie *@
<script>
window.__atomx_post_json = async function (url, data) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { success: res.ok, message: text };
}
} catch (err) {
return { success: false, message: err?.toString() ?? 'network error' };
}
};
</script>

View File

@@ -1,11 +1,76 @@
@page "/logout" @page "/logout"
@layout EmptyLayout @layout EmptyLayout
@inject IJSRuntime JS
@inject ILogger<Logout> Logger
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@inject HttpService HttpService
@using System.Text.Json
@code { @code {
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
await ((PersistentAuthenticationStateProvider)AuthStateProvider).MarkUserAsLoggedOut(); if (!firstRender) return;
Navigation.NavigateTo("/account/login");
await base.OnAfterRenderAsync(firstRender); try
} {
} // 如果运行在浏览器 (WASM),直接调用后端 API 并清除 localStorage / provider
if (OperatingSystem.IsBrowser())
{
Logger.LogInformation("WASM logout: call API and clear local storage");
try
{
await HttpService.Post<ApiResult<string>>("/api/sign/out", null);
}
catch { /* 忽略网络错误,仍继续清理客户端状态 */ }
if (AuthStateProvider is Atomx.Admin.Client.Utils.PersistentAuthenticationStateProvider provider)
{
await provider.MarkUserAsLoggedOut();
}
Navigation.NavigateTo("/account/login");
}
else
{
// Server 模式:通过浏览器 fetch 发起带凭据的请求以便浏览器接收并删除 Cookie然后强制重载
Logger.LogInformation("Server logout: use browser fetch to call /api/sign/out");
var jsResult = await JS.InvokeAsync<JsonElement>("__atomx_post_json", "/api/sign/out", (object?)null);
// 尝试解析返回,忽略细节
var success = jsResult.ValueKind == JsonValueKind.Object && jsResult.TryGetProperty("success", out var sp) && sp.GetBoolean();
Logger.LogInformation("Server logout result: {Success}", success);
// 尽管我们可能已经处理了服务器态,强制重新加载确保 Circuit 更新
Navigation.NavigateTo("/account/login", forceLoad: true);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Logout failed but proceeding to login page");
Navigation.NavigateTo("/account/login", forceLoad: true);
}
}
}
@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie/删除 Cookie *@
<script>
window.__atomx_post_json = async function (url, data) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: data ? JSON.stringify(data) : null
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { success: res.ok, message: text };
}
} catch (err) {
return { success: false, message: err?.toString() ?? 'network error' };
}
};
</script>

View File

@@ -33,7 +33,7 @@
帐号列表 帐号列表
<div> <div>
<AuthorizePermissionView Permission="@Permissions.User.Create"> <AuthorizePermissionView Permission="@Permissions.User.Create">
<button class="btn btn-primary">创建用户</button> <Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</AuthorizePermissionView> </AuthorizePermissionView>
@* <AuthorizeView Policy="@Permissions.Admin.Edit"> @* <AuthorizeView Policy="@Permissions.Admin.Edit">
<Authorized> <Authorized>

View File

@@ -3,6 +3,7 @@ using Atomx.Admin.Client.Utils;
using Blazored.LocalStorage; using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Http;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -19,9 +20,6 @@ builder.Services.AddScoped<IPermissionService, ClientPermissionService>();
builder.Services.AddScoped<IconsExtension>(); builder.Services.AddScoped<IconsExtension>();
builder.Services.AddScoped<ILocalizationService, LocalizationClientService>(); builder.Services.AddScoped<ILocalizationService, LocalizationClientService>();
// Token provider<65><72>WASM<53><4D>: <20><> localStorage <20><>ȡ access token
builder.Services.AddScoped<ITokenProvider, ClientTokenProvider>();
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Զ<EFBFBD><D4B6><EFBFBD><EFBFBD><EFBFBD> token & ˢ<>µ<EFBFBD> DelegatingHandler // ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Զ<EFBFBD><D4B6><EFBFBD><EFBFBD><EFBFBD> token & ˢ<>µ<EFBFBD> DelegatingHandler
builder.Services.AddScoped<AuthHeaderHandler>(); builder.Services.AddScoped<AuthHeaderHandler>();
@@ -43,8 +41,13 @@ builder.Services.AddHttpClient("RefreshClient", client =>
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74> // Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
// <20><> WASM DI <EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD> HttpService<63><65>ʹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD><EFBFBD><EFBFBD> HttpClient ʵ<EFBFBD><EFBFBD> // <20><> WASM <EFBFBD><EFBFBD> Program.cs<63><73><EFBFBD>ͻ<EFBFBD><CDBB>ˣ<EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD> HttpService ʱ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܴ<EFBFBD><EFBFBD>ڵ<EFBFBD> IHttpContextAccessor<EFBFBD><EFBFBD>Server <20><EFBFBD><E1B9A9>WASM Ϊ null<EFBFBD><EFBFBD>
builder.Services.AddScoped<HttpService>(sp => new HttpService(sp.GetRequiredService<HttpClient>())); builder.Services.AddScoped<HttpService>(sp =>
{
var httpClient = sp.GetRequiredService<HttpClient>();
var httpContextAccessor = sp.GetService<IHttpContextAccessor>();
return new HttpService(httpClient, httpContextAccessor);
});
builder.Services.AddAntDesign(); builder.Services.AddAntDesign();

View File

@@ -3,6 +3,7 @@
@code { @code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
Console.WriteLine("blazor跳转登录页");
Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
} }
} }

View File

@@ -2,14 +2,26 @@
using Atomx.Utils.Json; using Atomx.Utils.Json;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Http;
namespace Atomx.Admin.Client.Services namespace Atomx.Admin.Client.Services
{ {
public class HttpService(HttpClient httpClient) public class HttpService
{ {
private readonly HttpClient _httpClient;
private readonly IHttpContextAccessor? _httpContextAccessor;
public HttpService(HttpClient httpClient, IHttpContextAccessor? httpContextAccessor = null)
{
_httpClient = httpClient;
_httpContextAccessor = httpContextAccessor;
}
public async Task<T> Get<T>(string url) public async Task<T> Get<T>(string url)
{ {
var response = await httpClient.GetAsync(url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
AttachCookieIfServer(request);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
@@ -24,8 +36,13 @@ namespace Atomx.Admin.Client.Services
public async Task<T> Post<T>(string url, object data) public async Task<T> Post<T>(string url, object data)
{ {
var json = data.ToJson(); var json = data.ToJson();
var content = new StringContent(json, Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, url)
var response = await httpClient.PostAsync(url, content); {
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
AttachCookieIfServer(request);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
@@ -46,7 +63,14 @@ namespace Atomx.Admin.Client.Services
page = 1; page = 1;
} }
url = $"{url}?page={page}&size={size}"; url = $"{url}?page={page}&size={size}";
var response = await httpClient.PostAsJsonAsync(url, data);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(data)
};
AttachCookieIfServer(request);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
@@ -54,6 +78,7 @@ namespace Atomx.Admin.Client.Services
} }
else else
{ {
// 明确抛 Unauthorized 以便上层按需处理
throw new Exception($"Error: {response.StatusCode}"); throw new Exception($"Error: {response.StatusCode}");
} }
} }
@@ -63,5 +88,32 @@ namespace Atomx.Admin.Client.Services
throw new Exception($"api {url} service failure"); throw new Exception($"api {url} service failure");
} }
} }
/// <summary>
/// 如果在 Server 环境并且 IHttpContextAccessor 可用,则把浏览器请求的 Cookie 转发到后端请求中
/// </summary>
private void AttachCookieIfServer(HttpRequestMessage request)
{
try
{
if (!OperatingSystem.IsBrowser())
{
var ctx = _httpContextAccessor?.HttpContext;
if (ctx != null && ctx.Request.Headers.TryGetValue("Cookie", out var cookie) && !string.IsNullOrEmpty(cookie))
{
// 覆盖或添加 Cookie header
if (request.Headers.Contains("Cookie"))
{
request.Headers.Remove("Cookie");
}
request.Headers.Add("Cookie", (string)cookie);
}
}
}
catch
{
// 忽略任何转发异常,保持健壮性
}
}
} }
} }

View File

@@ -1,24 +0,0 @@
using System.Threading.Tasks;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// 统一的 Token 提供器接口(放在共享项目)
/// 目标:
/// - Server 与 WASM 使用相同的接口类型以避免 DI 注入类型不一致
/// - 仅负责“提供”当前可用的 access token不承担刷新策略
/// </summary>
public interface ITokenProvider
{
/// <summary>
/// 返回当前可用的 access token如果没有则返回 null
/// </summary>
Task<string?> GetTokenAsync();
/// <summary>
/// 快速判断当前 token 是否存在且(如果可以解析为 JWT未过期。
/// 注意:此方法为快速检查,不能替代服务端的完整验证。
/// </summary>
Task<bool> IsTokenValidAsync();
}
}

View File

@@ -18,7 +18,6 @@ namespace Atomx.Admin.Client.Utils
/// </summary> /// </summary>
public class AuthHeaderHandler : DelegatingHandler public class AuthHeaderHandler : DelegatingHandler
{ {
private readonly ITokenProvider _tokenProvider;
private readonly NavigationManager _navigationManager; private readonly NavigationManager _navigationManager;
private readonly ILogger<AuthHeaderHandler> _logger; private readonly ILogger<AuthHeaderHandler> _logger;
private readonly ILocalStorageService _localStorage; private readonly ILocalStorageService _localStorage;
@@ -26,13 +25,11 @@ namespace Atomx.Admin.Client.Utils
private static readonly SemaphoreSlim _refreshLock = new(1, 1); private static readonly SemaphoreSlim _refreshLock = new(1, 1);
public AuthHeaderHandler( public AuthHeaderHandler(
ITokenProvider tokenProvider,
NavigationManager navigationManager, NavigationManager navigationManager,
ILogger<AuthHeaderHandler> logger, ILogger<AuthHeaderHandler> logger,
ILocalStorageService localStorage, ILocalStorageService localStorage,
IHttpClientFactory httpClientFactory) IHttpClientFactory httpClientFactory)
{ {
_tokenProvider = tokenProvider;
_navigationManager = navigationManager; _navigationManager = navigationManager;
_logger = logger; _logger = logger;
_localStorage = localStorage; _localStorage = localStorage;
@@ -45,7 +42,12 @@ namespace Atomx.Admin.Client.Utils
try try
{ {
// 从 ITokenProvider 获取当前 access tokenWASM: ClientTokenProvider 从 localStorage 读取) // 从 ITokenProvider 获取当前 access tokenWASM: ClientTokenProvider 从 localStorage 读取)
var token = await _tokenProvider.GetTokenAsync(); var token = string.Empty;
try
{
token = await _localStorage.GetItemAsync<string>(StorageKeys.AccessToken);
}
catch { }
if (!string.IsNullOrEmpty(token)) if (!string.IsNullOrEmpty(token))
{ {
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
@@ -206,5 +208,20 @@ namespace Atomx.Admin.Client.Utils
return clone; return clone;
} }
private async Task HandleUnauthorizedAsync()
{
// 在WASM模式下重定向到登录页
if (OperatingSystem.IsBrowser())
{
_navigationManager.NavigateTo("/account/login", true);
}
// 在Server模式下可以执行其他操作
else
{
// Server端的处理逻辑
_logger.LogWarning("Unauthorized access detected in server mode");
}
}
} }
} }

View File

@@ -1,38 +0,0 @@
using Atomx.Admin.Client.Services;
using Microsoft.JSInterop;
namespace Atomx.Admin.Client.Utils
{
/// <summary>
/// WASM 客户端下的 Token 提供器(实现共享的 ITokenProvider
/// - 直接从浏览器 storagelocalStorage/sessionStorage读取 access token
/// - 设计为轻量,仅负责读取 token刷新逻辑放在 AuthHeaderHandler / 后端 Refresh 接口
/// </summary>
public class ClientTokenProvider : ITokenProvider
{
private readonly IJSRuntime _jsRuntime;
public ClientTokenProvider(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string?> GetTokenAsync()
{
try
{
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "accessToken");
}
catch
{
return null;
}
}
public async Task<bool> IsTokenValidAsync()
{
var token = await GetTokenAsync();
return !string.IsNullOrEmpty(token);
}
}
}

View File

@@ -1,11 +1,10 @@
using Blazored.LocalStorage; using Atomx.Common.Constants;
using Atomx.Common.Configuration;
using Atomx.Utils.Extension; using Atomx.Utils.Extension;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Atomx.Common.Constants;
namespace Atomx.Admin.Client.Utils namespace Atomx.Admin.Client.Utils
{ {
@@ -13,59 +12,62 @@ namespace Atomx.Admin.Client.Utils
{ {
readonly ClaimsPrincipal anonymous = new(new ClaimsIdentity()); readonly ClaimsPrincipal anonymous = new(new ClaimsIdentity());
static readonly Task<AuthenticationState> defaultUnauthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); // 如果运行在 Server 且在 prerender 时有 Persisted UserInfo则存储预设的 AuthenticationState
readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask; private Task<AuthenticationState>? _preRenderedAuthState;
readonly ILocalStorageService _localStorage; readonly ILocalStorageService _localStorage;
public PersistentAuthenticationStateProvider(PersistentComponentState state, ILocalStorageService localStorageService) public PersistentAuthenticationStateProvider(IServiceProvider serviceProvider, ILocalStorageService localStorageService)
{ {
_localStorage = localStorageService; _localStorage = localStorageService;
if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null) // 尝试有条件解析 PersistedComponentState仅在 Server 交互渲染时可用)
var state = serviceProvider.GetService<PersistentComponentState>();
if (state != null)
{ {
return; if (state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) && userInfo is not null)
}
var claims = new List<Claim>
{ {
new(ClaimKeys.Id, userInfo.Id.ToString()), var claims = new List<Claim>
new(ClaimKeys.Name, userInfo.Name), {
new(ClaimKeys.Email, userInfo.Email), new(ClaimKeys.Id, userInfo.Id.ToString()),
new(ClaimKeys.Mobile, userInfo.MobilePhone), new(ClaimKeys.Name, userInfo.Name),
new(ClaimKeys.Role, userInfo.Role), new(ClaimKeys.Email, userInfo.Email),
}; new(ClaimKeys.Mobile, userInfo.MobilePhone),
foreach (var role in userInfo.Permissions) new(ClaimKeys.Role, userInfo.Role),
{ };
claims.Add(new Claim(ClaimKeys.Permission, role)); foreach (var role in userInfo.Permissions ?? Array.Empty<string>())
} {
claims.Add(new Claim(ClaimKeys.Permission, role));
}
authenticationStateTask = Task.FromResult( var cp = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider))))); _preRenderedAuthState = Task.FromResult(new AuthenticationState(cp));
}
}
} }
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
// 如果在 prerender 阶段已从 PersistentComponentState 恢复用户优先返回该状态Server prerender
if (_preRenderedAuthState != null)
return await _preRenderedAuthState;
try try
{ {
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken); var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken);
if (string.IsNullOrEmpty(jwtToken)) if (string.IsNullOrEmpty(jwtToken))
return await Task.FromResult(new AuthenticationState(anonymous)); return new AuthenticationState(anonymous);
else
{ var getUserClaims = DecryptToken(jwtToken);
var getUserClaims = DecryptToken(jwtToken); if (getUserClaims == null || string.IsNullOrEmpty(getUserClaims.Name))
if (getUserClaims == null) return new AuthenticationState(anonymous);
return await Task.FromResult(new AuthenticationState(anonymous));
else var claimsPrincipal = SetClaimPrincipal(getUserClaims);
{ return new AuthenticationState(claimsPrincipal);
var claimsPrincipal = SetClaimPrincipal(getUserClaims);
return await Task.FromResult(new AuthenticationState(claimsPrincipal));
}
}
} }
catch catch
{ {
return await Task.FromResult(new AuthenticationState(anonymous)); return new AuthenticationState(anonymous);
} }
} }
@@ -83,13 +85,12 @@ namespace Atomx.Admin.Client.Utils
new(ClaimKeys.Mobile, customUserClaims.MobilePhone), new(ClaimKeys.Mobile, customUserClaims.MobilePhone),
new(ClaimKeys.Role, customUserClaims.Role.ToString()), new(ClaimKeys.Role, customUserClaims.Role.ToString()),
}; };
foreach (var role in customUserClaims.Permissions) foreach (var role in customUserClaims.Permissions ?? Array.Empty<string>())
{ {
claims.Add(new Claim(ClaimKeys.Permission, role)); claims.Add(new Claim(ClaimKeys.Permission, role));
} }
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider))); return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
} }
} }
public void UpdateAuthenticationState(string jwtToken = "") public void UpdateAuthenticationState(string jwtToken = "")
@@ -113,8 +114,6 @@ namespace Atomx.Admin.Client.Utils
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(jwtToken); var token = handler.ReadJwtToken(jwtToken);
var id = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Id)?.Value ?? string.Empty; var id = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Id)?.Value ?? string.Empty;
var name = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Name)?.Value ?? string.Empty; var name = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Name)?.Value ?? string.Empty;
var email = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Email)?.Value ?? string.Empty; var email = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Email)?.Value ?? string.Empty;
@@ -132,7 +131,6 @@ namespace Atomx.Admin.Client.Utils
await _localStorage.RemoveItemAsync(StorageKeys.RefreshToken); await _localStorage.RemoveItemAsync(StorageKeys.RefreshToken);
var authState = Task.FromResult(new AuthenticationState(anonymous)); var authState = Task.FromResult(new AuthenticationState(anonymous));
NotifyAuthenticationStateChanged(authState); NotifyAuthenticationStateChanged(authState);
} }
} }

View File

@@ -11,11 +11,11 @@
<link rel="stylesheet" href="@Assets["Atomx.Admin.styles.css"]" /> <link rel="stylesheet" href="@Assets["Atomx.Admin.styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveAuto" /> <HeadOutlet @rendermode="InteractiveServer" />
</head> </head>
<body> <body>
<Routes @rendermode="InteractiveAuto" /> <Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>

View File

@@ -1,6 +1,4 @@
 using Atomx.Admin.Client.Models;
using AntDesign;
using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Validators; using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services; using Atomx.Admin.Services;
using Atomx.Common.Constants; using Atomx.Common.Constants;
@@ -9,16 +7,16 @@ using Atomx.Common.Models;
using Atomx.Data; using Atomx.Data;
using Atomx.Data.CacheServices; using Atomx.Data.CacheServices;
using Atomx.Data.Services; using Atomx.Data.Services;
using Atomx.Utils.Json;
using MapsterMapper; using MapsterMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace Atomx.Admin.Controllers namespace Atomx.Admin.Controllers
{ {
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize]
public class RoleController : ControllerBase public class RoleController : ControllerBase
{ {
readonly ILogger<RoleController> _logger; readonly ILogger<RoleController> _logger;

View File

@@ -2,6 +2,7 @@
using Atomx.Admin.Client.Validators; using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services; using Atomx.Admin.Services;
using Atomx.Common.Constants; using Atomx.Common.Constants;
using Atomx.Common.Entities;
using Atomx.Common.Models; using Atomx.Common.Models;
using Atomx.Data; using Atomx.Data;
using Atomx.Data.CacheServices; using Atomx.Data.CacheServices;
@@ -13,8 +14,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace Atomx.Admin.Controllers namespace Atomx.Admin.Controllers
{ {
@@ -38,7 +43,6 @@ namespace Atomx.Admin.Controllers
private readonly JwtSetting _jwtSetting; private readonly JwtSetting _jwtSetting;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly AuthenticationStateProvider _authenticationStateProvider; private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly ITokenService _tokenService;
public SignController( public SignController(
ILogger<SignController> logger, ILogger<SignController> logger,
@@ -48,8 +52,7 @@ namespace Atomx.Admin.Controllers
DataContext dbContext, DataContext dbContext,
JwtSetting jwtSetting, JwtSetting jwtSetting,
ICacheService cacheService, ICacheService cacheService,
AuthenticationStateProvider authenticationStateProvider, AuthenticationStateProvider authenticationStateProvider)
ITokenService tokenService)
{ {
_logger = logger; _logger = logger;
_identityService = identityService; _identityService = identityService;
@@ -59,7 +62,6 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting; _jwtSetting = jwtSetting;
_cacheService = cacheService; _cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider; _authenticationStateProvider = authenticationStateProvider;
_tokenService = tokenService;
} }
/// <summary> /// <summary>
@@ -101,19 +103,20 @@ namespace Atomx.Admin.Controllers
return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null)); return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null));
} }
// 生成 access + refreshTokenService 会把 refresh 的哈希保存到数据库) if (user.LockoutEndTime.HasValue && user.LockoutEndTime.Value > DateTime.UtcNow)
var ip = _identityService.GetClientIp(); {
var userAgent = Request.Headers["User-Agent"].FirstOrDefault(); return new JsonResult(new ApiResult<AuthResponse>().IsFail($"账号已锁定,解锁时间:{user.LockoutEndTime.Value.ToLocalTime()}", null));
var authResponse = await _tokenService.GenerateTokenAsync(user, ip, userAgent); }
// 更新用户登录统计信息 var tokenHandler = new JwtSecurityTokenHandler();
user.LastLogin = DateTime.UtcNow; var issuer = _jwtSetting.Issuer;
user.LastIp = ip; var audience = _jwtSetting.Audience;
user.LoginCount++; var securityKey = _jwtSetting.SecurityKey;
_dbContext.Admins.Update(user);
await _dbContext.SaveChangesAsync();
// 为 Blazor Server 场景创建 CookieClaims 中包含必要角色/权限) var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// Claims 中包含必要角色/权限
var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId); var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId);
var claims = new List<Claim> var claims = new List<Claim>
{ {
@@ -124,9 +127,61 @@ namespace Atomx.Admin.Controllers
new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty) new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty)
}; };
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
SigningCredentials = credentials,
Issuer = issuer,
Audience = audience
};
// 生成 access token
var accessToken = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
// 生成 refresh token明文
var refreshToken = GenerateRefreshToken();
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// SignInAsync 创建 HttpOnly Cookie便于 Server-side 认证 // 保存 refresh token 的哈希到数据库(不可逆)
var refreshTokenEntity = new RefreshToken
{
Token = HashRefreshToken(refreshToken),
UserId = user.Id,
IssuedTime = DateTime.UtcNow,
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Ip = _identityService.GetClientIp(),
UserAgent = _identityService.GetUserAgent()
};
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
await RemoveOldRefreshTokensAsync(user.Id);
// 更新用户登录统计信息
user.LastLogin = DateTime.UtcNow;
user.LastIp = _identityService.GetClientIp();
user.LoginCount++;
_dbContext.Admins.Update(user);
_dbContext.RefreshTokens.Add(refreshTokenEntity);
await _dbContext.SaveChangesAsync();
//将 access token 哈希写入缓存(防止重复使用或可用于快速校验,用于快速拒绝等),过期时间与 access token 保持一致(分钟)
var cacheKey = $"token:{HashToken(accessToken)}";
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
var authResponse = new AuthResponse
{
Token = accessToken,
RefreshToken = refreshToken,
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
};
//SignInAsync 创建 HttpOnly Cookie便于 Server-side 认证
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity), new ClaimsPrincipal(claimsIdentity),
new AuthenticationProperties new AuthenticationProperties
@@ -136,6 +191,18 @@ namespace Atomx.Admin.Controllers
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes) ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
}); });
// 设置Cookie用于Server模式—— 明确指定与 Logout 同样的属性,便于删除
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Expires = DateTime.UtcNow.AddDays(7),
SameSite = SameSiteMode.Lax,
Secure = Request.IsHttps,
Path = "/"
};
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse)); return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
} }
@@ -148,17 +215,120 @@ namespace Atomx.Admin.Controllers
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request) public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{ {
var uid = _identityService.GetUserId();
if (uid == 0)
{
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的令牌请求", null));
}
if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken)) if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
{ {
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null)); return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null));
} }
var user = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == uid);
if (user == null)
throw new SecurityTokenException("用户不存在或已被禁用");
try try
{ {
var ip = _identityService.GetClientIp(); // 验证 refresh token数据库中存储为哈希
var newTokens = await _tokenService.RefreshTokenAsync(request.Token, request.RefreshToken, ip); var hashedRefreshToken = HashRefreshToken(request.RefreshToken);
var storedToken = await _dbContext.RefreshTokens
.FirstOrDefaultAsync(rt =>
rt.Token == hashedRefreshToken &&
rt.UserId == uid &&
rt.ExpiresTime > DateTime.UtcNow &&
!rt.IsRevoked);
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(newTokens)); if (storedToken == null)
throw new SecurityTokenException("无效的刷新令牌");
// 标记该 refresh token 为已撤销(一次性)
storedToken.IsRevoked = true;
storedToken.RevokedTime = DateTime.UtcNow;
storedToken.Ip = _identityService.GetClientIp();
var tokenHandler = new JwtSecurityTokenHandler();
var issuer = _jwtSetting.Issuer;
var audience = _jwtSetting.Audience;
var securityKey = _jwtSetting.SecurityKey;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// Claims 中包含必要角色/权限
var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId);
var claims = new List<Claim>
{
new Claim(ClaimKeys.Id, user.Id.ToString()),
new Claim(ClaimKeys.Email, user.Email ?? string.Empty),
new Claim(ClaimKeys.Name, user.Username ?? string.Empty),
new Claim(ClaimKeys.Role, user.RoleId.ToString()),
new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
SigningCredentials = credentials,
Issuer = issuer,
Audience = audience
};
// 生成 access token
var accessToken = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
// 生成 refresh token明文
var refreshToken = GenerateRefreshToken();
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// 保存 refresh token 的哈希到数据库(不可逆)
var refreshTokenEntity = new RefreshToken
{
Token = HashRefreshToken(refreshToken),
UserId = user.Id,
IssuedTime = DateTime.UtcNow,
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Ip = _identityService.GetClientIp(),
UserAgent = _identityService.GetUserAgent()
};
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
await RemoveOldRefreshTokensAsync(user.Id);
_dbContext.RefreshTokens.Add(refreshTokenEntity);
await _dbContext.SaveChangesAsync();
//将 access token 哈希写入缓存(防止重复使用或可用于快速校验,用于快速拒绝等),过期时间与 access token 保持一致(分钟)
var cacheKey = $"token:{HashToken(accessToken)}";
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
var authResponse = new AuthResponse
{
Token = accessToken,
RefreshToken = refreshToken,
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
};
// 设置Cookie用于Server模式
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Expires = DateTime.UtcNow.AddDays(7),
SameSite = SameSiteMode.Lax,
Secure = Request.IsHttps,
Path = "/"
};
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
} }
catch (SecurityTokenException ex) catch (SecurityTokenException ex)
{ {
@@ -184,8 +354,21 @@ namespace Atomx.Admin.Controllers
{ {
try try
{ {
var ip = _identityService.GetClientIp(); var hashedToken = HashRefreshToken(revokeRequest.RefreshToken);
await _tokenService.RevokeTokenAsync(revokeRequest.RefreshToken, ip); var token = await _dbContext.RefreshTokens
.FirstOrDefaultAsync(rt => rt.Token == hashedToken);
if (token == null || token.IsRevoked)
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow;
token.Ip = _identityService.GetClientIp();
await _dbContext.SaveChangesAsync();
// 清除与用户相关的缓存(例如 user info
await _cacheService.Remove($"user:{token.UserId}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -193,12 +376,83 @@ namespace Atomx.Admin.Controllers
} }
} }
// 清理 Cookie try
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); {
Response.Cookies.Delete("access_token"); // 清理 CookieSignOutAsync 清除认证会话)
Response.Cookies.Delete("refresh_token"); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// 显式覆写并过期曾设置的 token cookies确保浏览器删除与 Login 写入时使用的选项一致)
var expiredOptions = new CookieOptions
{
HttpOnly = true,
Expires = DateTime.UtcNow.AddDays(-1),
SameSite = SameSiteMode.Lax,
Secure = Request.IsHttps,
Path = "/"
};
Response.Cookies.Append("accessToken", string.Empty, expiredOptions);
Response.Cookies.Append("refreshToken", string.Empty, expiredOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "登出时清除 Cookie 失败(允许)");
}
return new JsonResult(new ApiResult<string>().IsSuccess("已退出")); return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
} }
/// <summary>
/// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希
/// </summary>
private string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
/// <summary>
/// 哈希刷新令牌不可逆SHA256( refreshToken + secret )
/// 数据库仅保存该值,客户端保存明文 refreshToken
/// </summary>
private string HashRefreshToken(string refreshToken)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(refreshToken + _jwtSetting.SecurityKey);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
/// <summary>
/// 撤销并保留最近 N 个未撤销的刷新令牌(超出部分标记为已撤销)
/// </summary>
private async Task RemoveOldRefreshTokensAsync(long userId)
{
var tokens = await _dbContext.RefreshTokens
.Where(rt => rt.UserId == userId && !rt.IsRevoked)
.OrderByDescending(rt => rt.IssuedTime)
.Skip(Math.Max(0, _jwtSetting.MaxRefreshTokensPerUser - 1))
.ToListAsync();
foreach (var token in tokens)
{
token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow;
}
}
/// <summary>
/// 哈希 access token用于撤销缓存 key
/// </summary>
private string HashToken(string token)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(token);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
} }
} }

View File

@@ -1,19 +1,20 @@
using Atomx.Admin.Utils; using Atomx.Admin.Utils;
using Atomx.Common.Constants; using Atomx.Common.Constants;
using Atomx.Common.Models; using Atomx.Common.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.Text; using System.Text;
using System.Text.Json;
namespace Atomx.Admin.Extensions namespace Atomx.Admin.Extensions
{ {
/// <summary> /// <summary>
/// AuthorizationExtension 重构说明: /// AuthorizationExtension 重构说明:
/// - 配置 Cookie + JwtBearer 双方案JwtBearer 的 Events 中添加对 SignalR / WebSocket 的 query string access_token 读取OnMessageReceived /// - 为混合部署Blazor Server + WASM与跨站点场景优化 Challenge 行为:
/// - 保持 OnChallenge 的重定向行为(对于浏览器访问 API 时友好 /// * 对浏览器导航/页面请求执行重定向到登录页(保持友好体验
/// - 将 JwtSetting 注入为 Singleton 供 TokenService 使用 /// * 对 API / XHR / Fetch / SignalR 请求返回 401 JSON避免 HTML 重定向导致前端错误)
/// - 保持 JwtBearer 对 SignalR query string 的读取
/// - Cookie 的 SecurePolicy 根据环境设置SameSite 使用 Lax可在跨站点场景改为 None 并开启 AllowCredentials
/// </summary> /// </summary>
public static class AuthorizationExtension public static class AuthorizationExtension
{ {
@@ -26,51 +27,14 @@ namespace Atomx.Admin.Extensions
} }
services.AddSingleton(jwtSetting); services.AddSingleton(jwtSetting);
// Cookie 配置读取
var cookieConf = Configuration.GetSection("Authentication:Cookie");
var cookieName = cookieConf.GetValue<string>("Name") ?? ".Atomx.Auth";
var cookiePath = cookieConf.GetValue<string>("Path") ?? "/";
var cookieDomain = cookieConf.GetValue<string>("Domain");
var sameSiteStr = cookieConf.GetValue<string>("SameSite");
var securePolicyStr = cookieConf.GetValue<string>("SecurePolicy");
var expireMinutes = cookieConf.GetValue<int?>("ExpireMinutes") ?? 60;
SameSiteMode sameSiteMode;
if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse<SameSiteMode>(sameSiteStr, true, out sameSiteMode))
{
sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None;
}
CookieSecurePolicy securePolicy;
if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse<CookieSecurePolicy>(securePolicyStr, true, out securePolicy))
{
securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
}
services.AddAuthentication(options => services.AddAuthentication(options =>
{ {
// 默认用于 API 的认证方案为 JwtBearer // 为混合 Server + API 场景选择:默认用于认证/读取身份的方案为 CookieServer 端 Circuit 使用)
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// 当需要挑战Challenge时使用 JwtBearer 的行为(其 OnChallenge 可做重定向处理)
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}) })
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => // JwtBearer 保持用于 API token 校验
{
options.Cookie.Name = cookieName;
options.Cookie.Path = cookiePath;
if (!string.IsNullOrWhiteSpace(cookieDomain))
{
options.Cookie.Domain = cookieDomain;
}
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = sameSiteMode;
options.Cookie.SecurePolicy = securePolicy;
options.ExpireTimeSpan = TimeSpan.FromMinutes(expireMinutes);
options.SlidingExpiration = true;
options.LoginPath = "/account/login";
options.LogoutPath = "/api/sign/out";
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{ {
options.RequireHttpsMetadata = !environment.IsDevelopment(); options.RequireHttpsMetadata = !environment.IsDevelopment();
@@ -94,22 +58,47 @@ namespace Atomx.Admin.Extensions
{ {
OnMessageReceived = context => OnMessageReceived = context =>
{ {
// SignalR 客户端常把 token 放在 query string 参数 access_token
var accessToken = context.Request.Query["access_token"].FirstOrDefault(); var accessToken = context.Request.Query["access_token"].FirstOrDefault();
var path = context.HttpContext.Request.Path; var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub"))) if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api")))
{ {
context.Token = accessToken; context.Token = accessToken;
} }
return Task.CompletedTask; return Task.CompletedTask;
}, },
OnChallenge = context => OnChallenge = async context =>
{ {
// 浏览器端访问 API 并无 token 时重定向到登录页 // 当 JwtChallenge 触发时,根据请求类型决定行为:
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"; // - 浏览器导航 / Accept:text/html页面请求 => 重定向到登录页
context.HandleResponse(); // - API / XHR / JSON 请求 => 返回 401 JSON
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}"); try
return Task.CompletedTask; {
var request = context.Request;
var accept = request.Headers["Accept"].FirstOrDefault() ?? string.Empty;
var isApiRequest = request.Path.StartsWithSegments("/api") || request.Path.StartsWithSegments("/hubs") || request.Headers["X-Requested-With"] == "XMLHttpRequest";
var expectsHtml = accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
context.HandleResponse();
if (!isApiRequest && expectsHtml)
{
var absoluteUri = $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
}
else
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new { success = false, message = "Unauthorized" };
await context.Response.WriteAsJsonAsync(payload);
}
}
catch
{
// 兜底:返回 401
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
}, },
OnAuthenticationFailed = context => OnAuthenticationFailed = context =>
{ {
@@ -120,6 +109,31 @@ namespace Atomx.Admin.Extensions
return Task.CompletedTask; return Task.CompletedTask;
} }
}; };
})
// 明确配置 Cookie用于 Server 交互渲染Circuit 使用 Cookie 进行认证)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
// LoginPath/AccessDeniedPath 可根据需要指定路由
options.LoginPath = "/account/login";
options.AccessDeniedPath = "/account/login";
// Cookie 安全策略HttpOnly + SameSite = Lax避免 Strict 在重定向/导航时不发送)
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
// 根据环境调整 SecurePolicy生产环境强制 HTTPS
options.Cookie.SecurePolicy = environment.IsDevelopment()
? CookieSecurePolicy.SameAsRequest
: CookieSecurePolicy.Always;
options.Cookie.Path = "/";
// 当使用 Blazor Server 时Cookie 名称可以指定(可选)
// options.Cookie.Name = "AtomxAuth";
// 控制到期与滑动过期
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
}); });
// 注册基于权限的策略 // 注册基于权限的策略

View File

@@ -70,16 +70,8 @@ builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<ILocalizationService, LocalizationService>(); builder.Services.AddScoped<ILocalizationService, LocalizationService>();
builder.Services.AddScoped<LocalizationFile, LocalizationFile>(); builder.Services.AddScoped<LocalizationFile, LocalizationFile>();
// Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Provider<65><72>Server ʹ<><CAB9> ServerTokenProvider<65><72>
builder.Services.AddScoped<ITokenProvider, ServerTokenProvider>();
builder.Services.AddScoped<ITokenService, TokenService>(); // ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> TokenService
builder.Services.AddScoped<AuthHeaderHandler>(); builder.Services.AddScoped<AuthHeaderHandler>();
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><><CBA2>/<2F><><EFBFBD><EFBFBD> access & refresh token<65><6E>
// - TokenService ʵ<><CAB5> ITokenService<63><65><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> DataContext<78><74>ICacheService<63><65>JwtSetting <20><>
// - <20><> Server <20><>ע<EFBFBD>룬SignController <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>񽫴<EFBFBD> DI <20><>ȡ<EFBFBD><C8A1>ʵ<EFBFBD><CAB5>
builder.Services.AddScoped<ITokenService, TokenService>();
// SignalR<6C><52><EFBFBD><EFBFBD><EFBFBD>÷<EFBFBD><C3B7><EFBFBD><EFBFBD><EFBFBD> Hub ֧<>֣<EFBFBD>ע<EFBFBD>⣺JWT <20><> OnMessageReceived <20><><EFBFBD><EFBFBD> AuthorizationExtension <20>д<EFBFBD><D0B4><EFBFBD><EFBFBD><EFBFBD> // SignalR<6C><52><EFBFBD><EFBFBD><EFBFBD>÷<EFBFBD><C3B7><EFBFBD><EFBFBD><EFBFBD> Hub ֧<>֣<EFBFBD>ע<EFBFBD>⣺JWT <20><> OnMessageReceived <20><><EFBFBD><EFBFBD> AuthorizationExtension <20>д<EFBFBD><D0B4><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -92,9 +84,7 @@ builder.Services.AddAuthorize(builder.Configuration, builder.Environment); //
var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); 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"))); builder.Services.AddDbContext<DataContext>(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
// Redis <20><><EFBFBD><EFBFBD>Ѵ<EFBFBD><EFBFBD>ڣ<EFBFBD> // Redis <20><><EFBFBD><EFBFBD>
// ... <20><><EFBFBD><EFBFBD>ԭ<EFBFBD><D4AD> Redis ע<><D7A2>
var redisConnection = builder.Configuration.GetConnectionString("cache"); var redisConnection = builder.Configuration.GetConnectionString("cache");
builder.Services.AddStackExchangeRedisCache(options => builder.Services.AddStackExchangeRedisCache(options =>
{ {
@@ -113,6 +103,37 @@ builder.Services.AddResponseCompression(options =>
.ToArray(); .ToArray();
}); });
// CORS<52><53><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ<EFBFBD><D2AA><EFBFBD><EFBFBD>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4>֧<EFBFBD><D6A7> Cookie<69><65>AllowCredentials<6C><73>
var corsOrigins = builder.Configuration["Cors:AllowedOrigins"];
if (!string.IsNullOrEmpty(corsOrigins))
{
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
builder.Services.AddCors(options =>
{
options.AddPolicy("DefaultCors", policy =>
{
policy.WithOrigins(origins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
}
else
{
// <20><><EFBFBD><EFBFBD>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> origin<69><6E><EFBFBD><EFBFBD><EFBFBD>Ƽ<EFBFBD><C6BC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddCors(options =>
{
options.AddPolicy("DefaultCors", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
// ע<>⣺AllowAnyOrigin <20><> AllowCredentials <20><><EFBFBD><EFBFBD>ͬʱʹ<CAB1><CAB9>
});
});
}
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddAntDesign(); builder.Services.AddAntDesign();
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring")); builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
@@ -140,13 +161,8 @@ else
app.UseResponseCompression(); app.UseResponseCompression();
app.UseCors(option => // ʹ<><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> CORS <20><><EFBFBD><EFBFBD>
{ app.UseCors("DefaultCors");
// ע<><EFBFBD><E2A3BA><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9> Cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ AllowCredentials <20><>ָ<EFBFBD><D6B8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4><EFBFBD>˴<EFBFBD>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧ<EFBFBD><D3A6><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
option.AllowAnyOrigin();
option.AllowAnyMethod();
option.AllowAnyHeader();
});
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions
{ {

View File

@@ -1,20 +0,0 @@
using Atomx.Common.Entities;
using Atomx.Common.Models;
namespace Atomx.Admin.Services
{
/// <summary>
/// Token 服务接口Admin 专用)。
/// - 生成 / 刷新 / 撤销 刷新令牌
/// - 验证 access token
/// - 根据 token 获取 Admin 实体
/// </summary>
public interface ITokenService
{
Task<AuthResponse> GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null);
Task<AuthResponse> RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null);
Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null);
Task<bool> ValidateTokenAsync(string token);
Task<Atomx.Common.Entities.Admin?> GetAdminFromTokenAsync(string token);
}
}

View File

@@ -1,400 +0,0 @@
using Atomx.Common.Constants;
using Atomx.Common.Entities;
using Atomx.Common.Models;
using Atomx.Data;
using Atomx.Data.CacheServices;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Atomx.Admin.Services
{
/// <summary>
/// 负责:生成 access token / refresh token、刷新、撤销、验证Admin 专用)
/// 要点:
/// - RefreshToken 在数据库中以 SHA256(token + secret) 保存(不可逆)
/// - 只查 Admin 表(用户端不考虑 User
/// - 保留每个 Admin 最近 N 个未撤销的刷新令牌(配置项)
/// - 不在日志中写入明文 token
/// </summary>
public class TokenService : ITokenService
{
readonly DataContext _dbContext;
readonly ICacheService _cacheService;
readonly JwtSetting _jwtSetting;
private readonly ILogger<TokenService> _logger;
private readonly SecurityKey _securityKey;
private readonly SigningCredentials _signingCredentials;
public TokenService(
ILogger<TokenService> logger, DataContext dataContext, ICacheService cacheService, JwtSetting jwtSetting)
{
_logger = logger;
_dbContext = dataContext;
_cacheService = cacheService;
_jwtSetting = jwtSetting;
// 防御性默认值(配置缺失时)
if (_jwtSetting.AccessTokenExpirationMinutes <= 0) _jwtSetting.AccessTokenExpirationMinutes = 15;
if (_jwtSetting.RefreshTokenExpirationMinutes <= 0) _jwtSetting.RefreshTokenExpirationMinutes = 60 * 24 * 30; // 30 天
if (_jwtSetting.MaxRefreshTokensPerUser <= 0) _jwtSetting.MaxRefreshTokensPerUser = 7;
var key = Encoding.UTF8.GetBytes(_jwtSetting.SecurityKey);
_securityKey = new SymmetricSecurityKey(key);
_signingCredentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256);
}
// helper to avoid analyzer complaining about direct field use in ctor defaulting
private int _jwt_setting_max() => _jwtSetting.MaxRefreshTokensPerUser;
/// <summary>
/// 生成一对 tokenaccess + refresh并将 Refresh 的哈希存库。
/// 返回的 RefreshToken 为明文(仅用于客户端存储),数据库只存 Hash。
/// </summary>
public async Task<AuthResponse> GenerateTokenAsync(Atomx.Common.Entities.Admin admin, string? ipAddress = null, string? userAgent = null)
{
if (admin == null)
throw new ArgumentNullException(nameof(admin));
// 检查是否被锁定Admin 有 LockoutEndTime
if (admin.LockoutEndTime.HasValue && admin.LockoutEndTime > DateTime.UtcNow)
throw new InvalidOperationException("账户已被锁定");
// 生成 access token
var accessToken = GenerateAccessToken(admin);
// 生成 refresh token明文
var refreshToken = GenerateRefreshToken();
// 保存 refresh token 的哈希到数据库(不可逆)
var refreshTokenEntity = new RefreshToken
{
Token = HashRefreshToken(refreshToken),
UserId = admin.Id, // 虽然叫 UserId但在 Admin 场景中表示 Admin.Id
IssuedTime = DateTime.UtcNow,
ExpiresTime = DateTime.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Ip = ipAddress,
UserAgent = userAgent
};
// 保留最新 N 个未撤销的刷新令牌,其余标记为撤销
await RemoveOldRefreshTokensAsync(admin.Id);
_dbContext.RefreshTokens.Add(refreshTokenEntity);
await _dbContext.SaveChangesAsync();
// 缓存 access token防止重复使用或可用于快速校验
await CacheTokenAsync(accessToken, admin.Id);
return new AuthResponse
{
Token = accessToken,
RefreshToken = refreshToken,
TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
};
}
/// <summary>
/// 使用已过期的 access token只用于读取身份信息+ 明文 refreshToken 来刷新。
/// 业务:
/// - 验证 access token 签名与 issuer/audience允许过期
/// - 根据 Claim 中的 admin id 在 Admins 表查找
/// - 验证 refresh token 的哈希是否在数据库且未撤销未过期
/// - 将该 refresh token 标记为撤销并生成新的对 token 返回
/// </summary>
public async Task<AuthResponse> RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null)
{
var principal = GetPrincipalFromExpiredToken(token);
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
throw new SecurityTokenException("无效的令牌");
var admin = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
if (admin == null)
throw new SecurityTokenException("用户不存在或已被禁用");
// 验证 refresh token数据库中存储为哈希
var hashedRefreshToken = HashRefreshToken(refreshToken);
var storedToken = await _dbContext.RefreshTokens
.FirstOrDefaultAsync(rt =>
rt.Token == hashedRefreshToken &&
rt.UserId == adminId &&
rt.ExpiresTime > DateTime.UtcNow &&
!rt.IsRevoked);
if (storedToken == null)
throw new SecurityTokenException("无效的刷新令牌");
// 标记该 refresh token 为已撤销(一次性)
storedToken.IsRevoked = true;
storedToken.RevokedTime = DateTime.UtcNow;
storedToken.Ip = ipAddress;
// 生成新的 access/refresh 对
var newTokens = await GenerateTokenAsync(admin, ipAddress, storedToken.UserAgent);
// SaveChanges 已在 GenerateTokenAsync 调用中执行(但我们修改了 storedToken需要确保保存
await _dbContext.SaveChangesAsync();
return newTokens;
}
/// <summary>
/// 撤销某个明文 refresh token用于登出
/// </summary>
public async Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null)
{
var hashedToken = HashRefreshToken(refreshToken);
var token = await _dbContext.RefreshTokens
.FirstOrDefaultAsync(rt => rt.Token == hashedToken);
if (token == null || token.IsRevoked)
return false;
token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow;
token.Ip = ipAddress;
await _dbContext.SaveChangesAsync();
// 清除与用户相关的缓存(例如 user info
await _cacheService.Remove($"user:{token.UserId}");
return true;
}
/// <summary>
/// 验证 access token完整验证签名、issuer、audience、过期
/// 额外:检查 token 是否在撤销缓存中
/// </summary>
public async Task<bool> ValidateTokenAsync(string token)
{
try
{
// 检查令牌是否在缓存中(已被撤销)
var cacheKey = $"revoked_token:{HashToken(token)}";
var cached = await _cacheService.GetCacheString(cacheKey);
if (cached != null)
return false;
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = _securityKey,
ValidateIssuer = true,
ValidIssuer = _jwtSetting.Issuer,
ValidateAudience = true,
ValidAudience = _jwtSetting.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
tokenHandler.ValidateToken(token, validationParameters, out _);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// 根据 access token 获取 Admin如果 token 合法)。
/// - 优先从缓存读取 Admin 对象
/// - 如果缓存不存在则从数据库读取并缓存(短期)
/// </summary>
public async Task<Atomx.Common.Entities.Admin?> GetAdminFromTokenAsync(string token)
{
try
{
var principal = GetPrincipalFromToken(token);
var idClaim = principal.FindFirst(ClaimKeys.Id)?.Value;
if (!long.TryParse(idClaim, out var adminId) || adminId == 0)
return null;
// 尝试从缓存获取
var cacheKey = $"user:{adminId}";
var cachedUser = await _cacheService.GetCacheString(cacheKey);
if (!string.IsNullOrEmpty(cachedUser))
{
return JsonSerializer.Deserialize<Atomx.Common.Entities.Admin>(cachedUser);
}
// 从数据库获取 Admin
var admin = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
if (admin != null)
{
// 缓存 admin 信息(单位:分钟,短期缓存)
await _cacheService.SetCacheAsync(cacheKey, JsonSerializer.Serialize(admin), 5);
}
return admin;
}
catch
{
return null;
}
}
/// <summary>
/// 生成访问令牌,包含必要 claims。
/// 使用项目常量 ClaimKeys 以保证前后端一致。
/// </summary>
private string GenerateAccessToken(Atomx.Common.Entities.Admin admin)
{
var claims = new List<Claim>
{
new Claim(ClaimKeys.Id, admin.Id.ToString()),
new Claim("jti", Guid.NewGuid().ToString()),
new Claim(ClaimKeys.Name, admin.Username ?? string.Empty),
new Claim(ClaimKeys.Email, admin.Email ?? string.Empty),
new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
Issuer = _jwtSetting.Issuer,
Audience = _jwtSetting.Audience,
SigningCredentials = _signingCredentials,
NotBefore = DateTime.UtcNow
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
/// <summary>
/// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希
/// </summary>
private string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
/// <summary>
/// 根据 access token 验证并返回 ClaimsPrincipal要求 token 未过期)
/// </summary>
private ClaimsPrincipal GetPrincipalFromToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
try
{
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = _securityKey,
ValidateIssuer = true,
ValidIssuer = _jwtSetting.Issuer,
ValidateAudience = true,
ValidAudience = _jwtSetting.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out _);
return principal;
}
catch (Exception ex)
{
_logger.LogError(ex, "令牌验证失败");
throw new SecurityTokenException("无效的令牌", ex);
}
}
/// <summary>
/// 从已过期的 access token 中读取 ClaimsPrincipal不验证 lifetime用于 refresh 操作)
/// </summary>
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
try
{
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = _securityKey,
ValidateIssuer = true,
ValidIssuer = _jwtSetting.Issuer,
ValidateAudience = true,
ValidAudience = _jwtSetting.Audience,
ValidateLifetime = false, // 不验证过期以便 refresh
ClockSkew = TimeSpan.Zero
}, out _);
return principal;
}
catch (Exception ex)
{
_logger.LogError(ex, "过期令牌验证失败");
throw new SecurityTokenException("无效的令牌", ex);
}
}
/// <summary>
/// 哈希刷新令牌不可逆SHA256( refreshToken + secret )
/// 数据库仅保存该值,客户端保存明文 refreshToken
/// </summary>
private string HashRefreshToken(string refreshToken)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(refreshToken + _jwtSetting.SecurityKey);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
/// <summary>
/// 哈希 access token用于撤销缓存 key
/// </summary>
private string HashToken(string token)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(token);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
/// <summary>
/// 撤销并保留最近 N 个未撤销的刷新令牌(超出部分标记为已撤销)
/// </summary>
private async Task RemoveOldRefreshTokensAsync(long userId)
{
var tokens = await _dbContext.RefreshTokens
.Where(rt => rt.UserId == userId && !rt.IsRevoked)
.OrderByDescending(rt => rt.IssuedTime)
.Skip(Math.Max(0, _jwtSetting.MaxRefreshTokensPerUser - 1))
.ToListAsync();
foreach (var token in tokens)
{
token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow;
}
// 注意:调用方需要在适当位置 SaveChangesAsyncGenerateTokenAsync 已经在添加新 token 后保存)
}
/// <summary>
/// 将 access token 哈希写入缓存(用于快速拒绝等),过期时间与 access token 保持一致(分钟)
/// </summary>
private async Task CacheTokenAsync(string token, long userId)
{
var cacheKey = $"token:{HashToken(token)}";
await _cacheService.SetCacheAsync(cacheKey, userId, _jwtSetting.AccessTokenExpirationMinutes);
}
}
}

View File

@@ -17,7 +17,7 @@ namespace Atomx.Admin.Utils
private readonly PersistentComponentState _state; private readonly PersistentComponentState _state;
private readonly IdentityOptions _options; private readonly IdentityOptions _options;
private readonly PersistingComponentStateSubscription _subscription; private readonly List<PersistingComponentStateSubscription> _subscriptions = new();
private Task<AuthenticationState>? _authenticationStateTask; private Task<AuthenticationState>? _authenticationStateTask;
@@ -33,7 +33,28 @@ namespace Atomx.Admin.Utils
_options = options.Value; _options = options.Value;
AuthenticationStateChanged += OnAuthenticationStateChanged; AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
// 注册为在 InteractiveServer 和 InteractiveWebAssembly 场景下持久化认证状态。
// 原实现仅在 InteractiveWebAssembly 下注册,导致 InteractiveServer 模式无法持久化认证信息,
// 从而出现 Server 模式下不断跳转到登录页的问题。
// 为了兼容不同渲染模式,这里为两种 Interactive 渲染模式都注册回调并保存订阅,以便正确释放。
try
{
_subscriptions.Add(_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly));
}
catch
{
// 如果特定的 RenderMode 在运行时不可用则忽略异常,仍尽可能注册其它模式。
}
try
{
_subscriptions.Add(_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveServer));
}
catch
{
// 忽略注册异常,防御性编程以适应不同运行时与库版本。
}
} }
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
@@ -41,7 +62,9 @@ namespace Atomx.Admin.Utils
protected override async Task<bool> ValidateAuthenticationStateAsync( protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken) AuthenticationState authenticationState, CancellationToken cancellationToken)
{ {
// Get the user manager from a new scope to ensure it fetches fresh data // 保持简单的验证逻辑:只在已认证时返回 true。
// 如果需要更严格的验证(例如 security stamp、数据库比对可以在此扩展
// 并使用 _scopeFactory.CreateAsyncScope() 获取作用域服务进行验证。
await using var scope = _scopeFactory.CreateAsyncScope(); await using var scope = _scopeFactory.CreateAsyncScope();
return ValidateSecurityStampAsync(authenticationState.User); return ValidateSecurityStampAsync(authenticationState.User);
} }
@@ -80,7 +103,7 @@ namespace Atomx.Admin.Utils
var role = principal.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Role)?.Value ?? string.Empty; var role = principal.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Role)?.Value ?? string.Empty;
var permission = principal.Claims.Where(x => x.Type == ClaimKeys.Permission).Select(s => s.Value).ToArray(); var permission = principal.Claims.Where(x => x.Type == ClaimKeys.Permission).Select(s => s.Value).ToArray();
if (id != null && name != null) if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
{ {
_state.PersistAsJson(nameof(UserInfo), new UserInfo _state.PersistAsJson(nameof(UserInfo), new UserInfo
{ {
@@ -97,7 +120,18 @@ namespace Atomx.Admin.Utils
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
_subscription.Dispose(); foreach (var sub in _subscriptions)
{
try
{
sub.Dispose();
}
catch
{
// 忽略单个订阅释放异常,确保其他订阅能够正确释放
}
}
AuthenticationStateChanged -= OnAuthenticationStateChanged; AuthenticationStateChanged -= OnAuthenticationStateChanged;
base.Dispose(disposing); base.Dispose(disposing);
} }
@@ -105,4 +139,4 @@ namespace Atomx.Admin.Utils
public record UserInfo(long Id = 0, string Name = null!, string Email = null!, string MobilePhone = null!, string Role = null!, string[] Permissions = null!); public record UserInfo(long Id = 0, string Name = null!, string Email = null!, string MobilePhone = null!, string Role = null!, string[] Permissions = null!);
} }

View File

@@ -1,108 +0,0 @@
using Atomx.Admin.Client.Services;
using Microsoft.AspNetCore.Authentication;
using System.IdentityModel.Tokens.Jwt;
namespace Atomx.Admin.Utils
{
/// <summary>
/// Server 模式下的 ITokenProvider 实现Blazor Server
/// - 从当前 HttpContext 中尝试读取 access token按优先级
/// 1. Authorization header ("Bearer ...")
/// 2. Query string "access_token"SignalR/WebSocket 使用)
/// 3. HttpContext.GetTokenAsync("access_token")(保存 token 的 auth 中间件)
/// 4. Cookie "access_token"
/// 5. HttpContext.Items["access_token"]
/// - 提供快速的 JWT 过期判断IsTokenValidAsync
/// </summary>
public class ServerTokenProvider : ITokenProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerTokenProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public async Task<string?> GetTokenAsync()
{
var ctx = _httpContextAccessor.HttpContext;
if (ctx == null)
return null;
// 1) Authorization header
if (ctx.Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
{
var authHeader = authHeaderValues.ToString();
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
if (!string.IsNullOrEmpty(token))
return token;
}
}
// 2) SignalR / websocket: query string access_token
if (ctx.Request.Query.TryGetValue("access_token", out var queryToken))
{
var token = queryToken.ToString();
if (!string.IsNullOrEmpty(token))
return token;
}
// 3) 从认证系统中读取(例如 UseAuthentication + SaveToken = true 的场景)
try
{
var saved = await ctx.GetTokenAsync("access_token");
if (!string.IsNullOrEmpty(saved))
return saved;
}
catch
{
// 安全忽略
}
// 4) Cookie兼容性
if (ctx.Request.Cookies.TryGetValue("access_token", out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
{
return cookieToken;
}
// 5) Items中间件临时注入
if (ctx.Items.TryGetValue("access_token", out var itemToken) && itemToken is string sToken && !string.IsNullOrEmpty(sToken))
{
return sToken;
}
return null;
}
public async Task<bool> IsTokenValidAsync()
{
var token = await GetTokenAsync();
if (string.IsNullOrEmpty(token))
return false;
try
{
var handler = new JwtSecurityTokenHandler();
if (handler.CanReadToken(token))
{
var jwt = handler.ReadJwtToken(token);
var expClaim = jwt.Claims.FirstOrDefault(c => c.Type == "exp")?.Value;
if (long.TryParse(expClaim, out var expSec))
{
var exp = DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime;
return exp > DateTime.UtcNow;
}
// 没有 exp claim无法判断过期 -> 视为不可用
return false;
}
}
catch
{
// 解析失败 -> 视为不可用
}
return false;
}
}
}