This commit is contained in:
2025-12-04 05:12:45 +08:00
parent 85f0cb613a
commit 5bdb04da15
8 changed files with 330 additions and 206 deletions

View File

@@ -20,6 +20,6 @@
/// <summary>
/// 是否记住我
/// </summary>
public bool SaveMe { get; set; }
public bool RememberMe { get; set; }
}
}

View File

@@ -2,7 +2,6 @@
@layout EmptyLayout
@inject ILogger<Login> Logger
<PageTitle>登录</PageTitle>
@if (!dataLoaded)
@@ -67,7 +66,6 @@ else
private bool _isLoading = false;
protected override void OnInitialized()
{
if (OperatingSystem.IsBrowser())
@@ -85,12 +83,9 @@ else
if (firstRender)
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity != null)
if (authState.User.Identity != null && authState.User.Identity.IsAuthenticated)
{
if (authState.User.Identity.IsAuthenticated)
{
Navigation.NavigateTo(ReturnUrl ?? "/");
}
Navigation.NavigateTo(ReturnUrl ?? "/");
}
}
if (!dataLoaded)
@@ -102,32 +97,49 @@ else
private async Task LoginAsync()
{
if (form.Validate())
if (!form.Validate()) return;
_isLoading = true;
StateHasChanged();
try
{
// 请求后端登录接口,后端返回 ApiResult<AuthResponse>
var api = "/api/sign/in";
var result = await HttpService.Post<ApiResult<string>>(api, login);
if (result.Success)
var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
if (result.Success && result.Data != null)
{
Console.WriteLine("请求api成功");
if (!string.IsNullOrEmpty(result.Data))
var auth = result.Data;
// 保存 access + refresh 到 localStorageWASM 场景)
await localStorage.SetItemAsync("accessToken", auth.Token);
await localStorage.SetItemAsync("refreshToken", auth.RefreshToken);
// 更新客户端 AuthenticationState调用自定义 Provider 更新方法)
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
{
await localStorage.SetItemAsStringAsync(StorageKeys.AccessToken, result.Data);
await localStorage.SetItemAsStringAsync("refreshToken", result.Data);
var authState = (AuthStateProvider as PersistentAuthenticationStateProvider);
if (authState != null)
{
authState.UpdateAuthenticationState(result.Data);
}
Logger.LogInformation($"登录成功跳转目标,{ReturnUrl}");
Navigation.NavigateTo(ReturnUrl ?? "/");
// provider 仅需要 access token 更新来触发 UI 更新
provider.UpdateAuthenticationState(auth.Token);
}
Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl);
Navigation.NavigateTo(ReturnUrl ?? "/");
}
else
{
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message });
}
}
catch (Exception ex)
{
Logger.LogError(ex, "登录失败");
ModalService.Error(new ConfirmOptions() { Title = "错误", Content = "登录异常,请稍后重试" });
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private async Task OnPasswordKeyDown(KeyboardEventArgs value)

View File

@@ -6,26 +6,26 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// ע<><EFBFBD>ش洢<D8B4><E6B4A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// ע<><EFBFBD>ش洢<D8B4><E6B4A2>WASM ʹ<><CAB9> localStorage <20><><EFBFBD><EFBFBD> tokens<EFBFBD><EFBFBD>
builder.Services.AddBlazoredLocalStorageAsSingleton();
// <20><>Ȩ/<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// <20><>Ȩ/<2F><><EFBFBD>ݣ<EFBFBD>WASM ʹ<><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthenticationStateProvider<EFBFBD><EFBFBD>
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();
// Ȩ<><C8A8> & <20><><EFBFBD>ػ<EFBFBD>
// Ȩ<><C8A8> & <20><><EFBFBD>ػ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͻ<EFBFBD><EFBFBD><EFBFBD>ʵ<EFBFBD>֣<EFBFBD>
builder.Services.AddScoped<IPermissionService, ClientPermissionService>();
builder.Services.AddScoped<IconsExtension>();
builder.Services.AddScoped<ILocalizationService, LocalizationClientService>();
// Token provider<65><72>WASM<53><4D>
// 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
builder.Services.AddScoped<AuthHeaderHandler>();
// ע<EFBFBD><EFBFBD>һ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> HttpClient<6E><74><EFBFBD><EFBFBD>Ӧ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> API <20><><EFBFBD><EFBFBD>ʹ<EFBFBD>ã<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthHeaderHandler <20><><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD>
// <20><><EFBFBD><EFBFBD> HttpClient<6E><74>Ӧ<EFBFBD><D3A6><EFBFBD><EFBFBD>ͳһʹ<EFBFBD><EFBFBD> ApiClient<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthHeaderHandler <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD>
var apiBase = builder.Configuration["WebApi:ServerUrl"] ?? builder.HostEnvironment.BaseAddress;
builder.Services.AddHttpClient("ApiClient", client =>
{
@@ -33,8 +33,14 @@ builder.Services.AddHttpClient("ApiClient", client =>
})
.AddHttpMessageHandler<AuthHeaderHandler>();
// Ϊ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>δ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ֵ<EFBFBD> HttpClient<EFBFBD><EFBFBD>AuthHeaderHandler <20>ڲ<EFBFBD> CreateClient() ʹ<><CAB9>Ĭ<EFBFBD>Ϲ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// Ҳע<EFBFBD><EFBFBD>Ĭ<EFBFBD><EFBFBD> HttpClient <20><> BaseAddress
// ע<><D7A2>һ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthHeaderHandler <20><> HttpClient<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ˢ<EFBFBD><EFBFBD> token<65><6E><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ѭ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ã<EFBFBD>
// <EFBFBD><EFBFBD> client <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthHeaderHandler <20><>ʹ<EFBFBD><CAB9> "RefreshClient"
builder.Services.AddHttpClient("RefreshClient", client =>
{
client.BaseAddress = new Uri(apiBase);
});
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
// <20><> WASM DI <20><>ע<EFBFBD><D7A2> HttpService<63><65>ʹ<EFBFBD><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2><EFBFBD><EFBFBD> HttpClient ʵ<><CAB5>

View File

@@ -13,6 +13,7 @@ namespace Atomx.Admin.Client.Utils
/// - 在每次请求时将 access token 附带 Authorization header
/// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh
/// - 防止并发刷新SemaphoreSlim
/// - 注意:刷新请求必须使用一个不包含本 handler 的 HttpClient避免循环
/// </summary>
public class AuthHeaderHandler : DelegatingHandler
{
@@ -45,6 +46,7 @@ namespace Atomx.Admin.Client.Utils
{
try
{
// 从 ITokenProvider 获取当前 access tokenWASM: ClientTokenProvider 从 localStorage 读取)
var token = await _tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(token))
{
@@ -57,6 +59,7 @@ namespace Atomx.Admin.Client.Utils
var response = await base.SendAsync(request, cancellationToken);
// 当发现未授权或后端标记 token 过期时,尝试刷新
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.Headers.Contains("Token-Expired"))
{
@@ -76,6 +79,7 @@ namespace Atomx.Admin.Client.Utils
}
}
// 刷新失败或无 token跳转到登录页强制刷新页面清除 SPA 状态)
_navigationManager.NavigateTo("/account/login", true);
}
else
@@ -93,6 +97,13 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 尝试刷新 token
/// 关键点:
/// - 使用命名 HttpClient "RefreshClient"(在 WASM Program 中注册,不包含本 handler避免递归
/// - 并发刷新时只有一个请求会实际触发刷新,其余等待结果
/// - 刷新成功后写入 localStorageaccessToken + refreshToken
/// </summary>
private async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken)
{
await _refreshLock.WaitAsync(cancellationToken);
@@ -107,11 +118,13 @@ namespace Atomx.Admin.Client.Utils
return false;
}
var client = _httpClientFactory.CreateClient();
var reqModel = new
// 使用不带本 handler 的 HttpClient 发送刷新请求,避免循环
var client = _httpClientFactory.CreateClient("RefreshClient");
var reqModel = new RefreshRequest
{
token = currentAccess,
refreshToken = currentRefresh
Token = currentAccess,
RefreshToken = currentRefresh
};
var reqJson = JsonSerializer.Serialize(reqModel);
using var req = new HttpRequestMessage(HttpMethod.Post, "api/sign/refresh")
@@ -125,6 +138,9 @@ namespace Atomx.Admin.Client.Utils
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("Refresh request failed with status {Status}", resp.StatusCode);
// 如果刷新失败,移除本地 token防止无限重试
await _localStorage.RemoveItemAsync(AccessTokenKey);
await _localStorage.RemoveItemAsync(RefreshTokenKey);
return false;
}
@@ -137,9 +153,12 @@ namespace Atomx.Admin.Client.Utils
if (authResp == null || string.IsNullOrEmpty(authResp.Token) || string.IsNullOrEmpty(authResp.RefreshToken))
{
_logger.LogWarning("Invalid response from refresh endpoint");
await _localStorage.RemoveItemAsync(AccessTokenKey);
await _localStorage.RemoveItemAsync(RefreshTokenKey);
return false;
}
// 保存新的 tokens 到 localStorage
await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken);
await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, cancellationToken);
@@ -158,10 +177,14 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 复制原始请求并用新的 token 替换 Authorization header
/// </summary>
private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage original, string newToken)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
// 复制内容(如果存在)
if (original.Content != null)
{
var ms = new MemoryStream();
@@ -176,9 +199,11 @@ namespace Atomx.Admin.Client.Utils
}
}
// 复制 headers
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
// 覆盖 Authorization
clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken);
return clone;

View File

@@ -1,5 +1,4 @@

using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services;
using Atomx.Admin.Utils;
@@ -16,27 +15,42 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace Atomx.Admin.Controllers
{
/// <summary>
/// 登录/刷新/登出控制器(重构)
/// 说明:
/// - 返回标准的 AuthResponseaccess + refresh + expiry便于 WASM 客户端保存到 localStorage
/// - 仍保留 Cookie 登录SignInAsync以兼容 Blazor Server 场景
/// - 使用注入的 ITokenService 负责 token 的生成、刷新与撤销(数据库保存 refresh token 哈希)
/// - 提供 /api/sign/in (POST), /api/sign/refresh (POST), /api/sign/out (POST)
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class SignController : ControllerBase
{
readonly ILogger<SignController> _logger;
readonly IIdentityService _identityService;
readonly IIdCreatorService _idCreator;
readonly IMapper _mapper;
readonly DataContext _dbContext;
readonly JwtSetting _jwtSetting;
readonly ICacheService _cacheService;
readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly ILogger<SignController> _logger;
private readonly IIdentityService _identityService;
private readonly IIdCreatorService _idCreator;
private readonly IMapper _mapper;
private readonly DataContext _dbContext;
private readonly JwtSetting _jwtSetting;
private readonly ICacheService _cacheService;
private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly ITokenService _tokenService;
public SignController(ILogger<SignController> logger, IIdentityService identityService, IIdCreatorService idCreator, IMapper mapper, DataContext dbContext, JwtSetting jwtSetting, ICacheService cacheService, AuthenticationStateProvider authenticationStateProvider)
public SignController(
ILogger<SignController> logger,
IIdentityService identityService,
IIdCreatorService idCreator,
IMapper mapper,
DataContext dbContext,
JwtSetting jwtSetting,
ICacheService cacheService,
AuthenticationStateProvider authenticationStateProvider,
ITokenService tokenService)
{
_logger = logger;
_identityService = identityService;
@@ -46,106 +60,201 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting;
_cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider;
_tokenService = tokenService;
}
/// <summary>
/// 用户登录系统
/// 登录:支持邮箱或用户登录
/// - 返回 AuthResponse 给 WASMaccess + refresh
/// - 在 Server 场景同时创建 Cookie兼容 Blazor Server
/// </summary>
/// <returns></returns>
[HttpPost("in")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginModel model)
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
var validator = new LoginModelValidator();
var validation = validator.Validate(model);
if (!validation.IsValid)
{
var message = validation.Errors.FirstOrDefault()?.ErrorMessage;
var result = new ApiResult<string>().IsFail(message ?? string.Empty, null);
return new JsonResult(result);
var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
return new JsonResult(new ApiResult<AuthResponse>().IsFail(message, null));
}
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);
Common.Entities.Admin? user = null;
// 查询用户(支持 email / username
Atomx.Common.Entities.Admin? user = null;
if (model.Account.Contains("@"))
{
user = _dbContext.Admins.Where(p => p.Email == model.Account).SingleOrDefault();
user = _dbContext.Admins.SingleOrDefault(p => p.Email == model.Account);
}
else
{
user = _dbContext.Admins.Where(p => p.Username == model.Account).SingleOrDefault();
user = _dbContext.Admins.SingleOrDefault(p => p.Username == model.Account);
}
if (user == null)
{
var result = new ApiResult<string>().IsFail("用户不存在", null);
return new JsonResult(result);
return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null));
}
// 简单密码校验(项目 uses MD5 存储示例)
if (user.Password != model.Password.ToMd5Password())
{
var result = new ApiResult<string>().IsFail("账号密码不正确", null);
return new JsonResult(result);
return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null));
}
var role = _dbContext.Roles.Where(p => p.Id == user.RoleId).SingleOrDefault();
// 生成 access + refreshTokenService 会把 refresh 的哈希保存到数据库)
var ip = _identityService.GetClientIp();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
var authResponse = await _tokenService.GenerateTokenAsync(user, ip, userAgent);
var claims = new List<Claim>()
{
new Claim(ClaimKeys.Id, user.Id.ToString()),
new Claim(ClaimKeys.Email, user.Email),
new Claim(ClaimKeys.Name, user.Username),
new Claim(ClaimKeys.Role, user.RoleId.ToString()),
new Claim(ClaimKeys.Permission, role?.Permission??string.Empty)
};
// 更新用户登录统计信息
user.LastLogin = DateTime.UtcNow;
user.LastIp = ip;
user.LoginCount++;
_dbContext.Admins.Update(user);
await _dbContext.SaveChangesAsync();
// 为 Blazor Server 场景创建 CookieClaims 中包含必要角色/权限)
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 claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// SignInAsync 创建 HttpOnly Cookie便于 Server-side 认证
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
new AuthenticationProperties
{
IsPersistent = model.RememberMe,
AllowRefresh = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
});
var tokenDescriptor = new SecurityTokenDescriptor
// 另外将 tokens 写入 HttpOnly Cookie增强与传统中间件的兼容性
try
{
Subject = claimsIdentity,
var cookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(), // 本地调试时允许 http
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("access_token", authResponse.Token, cookieOptions);
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
SigningCredentials = credentials,
Issuer = issuer,
Audience = audience
};
var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
var loginResult = new ApiResult<string>().IsSuccess(tokenString);
user.LastLogin = DateTime.UtcNow;
user.LastIp = _identityService.GetClientIp();
user.LoginCount++;
_dbContext.Admins.Update(user);
_dbContext.SaveChanges();
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity));
return new JsonResult(loginResult);
var refreshCookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("refresh_token", authResponse.RefreshToken, refreshCookieOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "设置 token cookie 失败(非致命)");
}
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
}
/// <summary>
/// 用户退出系统
/// 刷新客户端传入可能已过期的access token 与 refresh token明文
/// - TokenService.RefreshTokenAsync 会验证并一次性撤销旧的 refresh token生成新的对
/// </summary>
/// <returns></returns>
[HttpGet("out")]
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> LogoutAsync()
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
await HttpContext.SignOutAsync();
return new JsonResult(new ApiResult<string>());
if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
{
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null));
}
try
{
var ip = _identityService.GetClientIp();
var newTokens = await _tokenService.RefreshTokenAsync(request.Token, request.RefreshToken, ip);
// 更新 cookie如存在
try
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("access_token", newTokens.Token, cookieOptions);
var refreshCookieOptions = new CookieOptions
{
HttpOnly = true,
//Secure = !Request.IsLocal(),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
Path = "/"
};
Response.Cookies.Append("refresh_token", newTokens.RefreshToken, refreshCookieOptions);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "刷新 token 时写 cookie 失败(允许)");
}
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(newTokens));
}
catch (SecurityTokenException ex)
{
_logger.LogWarning(ex, "刷新失败:刷新令牌无效或 access token 无效");
return Unauthorized(new ApiResult<AuthResponse>().IsFail("刷新令牌无效或已过期", null));
}
catch (Exception ex)
{
_logger.LogError(ex, "刷新令牌时发生内部错误");
return StatusCode(500, new ApiResult<AuthResponse>().IsFail("服务器内部错误", null));
}
}
/// <summary>
/// 登出:可传入 refresh token 以撤销WASM 前端应同时清除 localStorage
/// - 本接口会 SignOut CookieServer 场景)并尝试撤销 refresh token
/// </summary>
[HttpPost("out")]
[AllowAnonymous]
public async Task<IActionResult> LogoutAsync([FromBody] RevokeRequest? revokeRequest = null)
{
if (revokeRequest != null && !string.IsNullOrWhiteSpace(revokeRequest.RefreshToken))
{
try
{
var ip = _identityService.GetClientIp();
await _tokenService.RevokeTokenAsync(revokeRequest.RefreshToken, ip);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "撤销 refresh token 失败(允许)");
}
}
// 清理 Cookie
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Response.Cookies.Delete("access_token");
Response.Cookies.Delete("refresh_token");
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
}
}
}

View File

@@ -9,25 +9,24 @@ using System.Text.Json;
namespace Atomx.Admin.Extensions
{
/// <summary>
/// AuthorizationExtension 重构说明:
/// - 配置 Cookie + JwtBearer 双方案JwtBearer 的 Events 中添加对 SignalR / WebSocket 的 query string access_token 读取OnMessageReceived
/// - 保持 OnChallenge 的重定向行为(对于浏览器访问 API 时友好)
/// - 将 JwtSetting 注入为 Singleton 供 TokenService 使用
/// </summary>
public static class AuthorizationExtension
{
/// <summary>
/// 添加身份验证服务
/// </summary>
/// <param name="services"></param>
/// <param name="Configuration"></param>
/// <param name="environment"></param>
/// <exception cref="Exception"></exception>
public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
{
var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get<JwtSetting>();
if (jwtSetting == null)
{
throw new Exception("缺少配置信息");
throw new Exception("缺少配置信息 Authentication:JwtBearer");
}
services.AddSingleton(jwtSetting);
// 从配置读取 Cookie 设置(可在 appsettings.json 的 Authentication:Cookie 节点配置)
// Cookie 配置读取
var cookieConf = Configuration.GetSection("Authentication:Cookie");
var cookieName = cookieConf.GetValue<string>("Name") ?? ".Atomx.Auth";
var cookiePath = cookieConf.GetValue<string>("Path") ?? "/";
@@ -36,30 +35,26 @@ namespace Atomx.Admin.Extensions
var securePolicyStr = cookieConf.GetValue<string>("SecurePolicy");
var expireMinutes = cookieConf.GetValue<int?>("ExpireMinutes") ?? 60;
// 解析 SameSite默认开发环境 Strict生产环境 None 用于跨站点场景比如前后端分离)
SameSiteMode sameSiteMode;
if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse<SameSiteMode>(sameSiteStr, true, out sameSiteMode))
{
sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None;
}
// 解析 SecurePolicy默认开发 SameAsRequest生产 Always
CookieSecurePolicy securePolicy;
if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse<CookieSecurePolicy>(securePolicyStr, true, out securePolicy))
{
securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
}
//认证配置:注册 Cookie用于 SignIn/SignOut和 JwtBearer用于 API 授权)
services.AddAuthentication(options =>
{
// 默认用于 API 的认证/挑战方案使用 JwtBearer
// 默认用于 API 的认证方案为 JwtBearer
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
// Cookie 配置,确保 SignInAsync 能找到处理器
options.Cookie.Name = cookieName;
options.Cookie.Path = cookiePath;
if (!string.IsNullOrWhiteSpace(cookieDomain))
@@ -88,25 +83,37 @@ namespace Atomx.Admin.Extensions
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = jwtSetting.Audience,//Audience
ValidAudience = jwtSetting.Audience,
ValidIssuer = jwtSetting.Issuer,
ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)), //过期时钟偏差
ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.SecurityKey))
};
// 允许从 query string 中获取 access_token用于 SignalR / WebSocket
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// SignalR 客户端常把 token 放在 query string 参数 access_token
var accessToken = context.Request.Query["access_token"].FirstOrDefault();
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub")))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnChallenge = context =>
{
// 浏览器端访问 API 并无 token 时重定向到登录页
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
context.HandleResponse();
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
//Token expired
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
if (context.Exception?.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Append("Token-Expired", "true");
}
@@ -115,13 +122,13 @@ namespace Atomx.Admin.Extensions
};
});
// 注册基于权限的策略
services.AddAuthorization(options =>
{
// 基于权限的策略
var allPermissions = Permissions.GetAllPermissions();
foreach (var permission in allPermissions)
{
options.AddPolicy(permission, policy => { policy.Requirements.Add(new PermissionRequirement(permission)); });
options.AddPolicy(permission, policy => policy.Requirements.Add(new PermissionRequirement(permission)));
}
});
}

View File

@@ -6,23 +6,30 @@ using System.Text.RegularExpressions;
namespace Atomx.Admin.Middlewares
{
/// <summary>
/// 请求监控中间件
/// 变更点:
/// - 不再在构造函数注入作用域scoped服务 IIdentityService避免在应用启动时从根 provider 解析 scoped 服务导致异常。
/// - 在每次请求处理时通过 HttpContext.RequestServices 获取 IIdentityService请求作用域内解析
/// </summary>
public class MonitoringMiddleware
{
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, IIdentityService identityService)
public MonitoringMiddleware(RequestDelegate next, ILogger<MonitoringMiddleware> logger, IOptions<MonitoringOptions> options)
{
_next = next;
_logger = logger;
_options = options.Value;
_identityService = identityService;
}
public async Task InvokeAsync(HttpContext context)
{
// 在请求作用域内解析 IIdentityService避免在中间件构造时从根 provider 解析 scoped 服务
var identityService = context.RequestServices.GetService<IIdentityService>();
// 检查是否应该跳过监控
if (ShouldSkipMonitoring(context))
{
@@ -32,7 +39,7 @@ namespace Atomx.Admin.Middlewares
var logInfo = new
{
UserId = _identityService.GetUserId(),
UserId = identityService?.GetUserId(),
Path = context.Request.Path,
Method = context.Request.Method,
StartTime = DateTime.UtcNow,
@@ -50,7 +57,6 @@ namespace Atomx.Admin.Middlewares
{
stopwatch.Stop();
var logData = new
{
Path = context.Request.Path,

View File

@@ -28,7 +28,7 @@ using System.Text.Unicode;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
// Serilog <20><><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD>
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
@@ -37,7 +37,7 @@ Log.Logger = new LoggerConfiguration()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
// Add services to the container.
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
@@ -56,101 +56,93 @@ builder.Services.AddBlazoredLocalStorage();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
// Ȩ<>޷<EFBFBD><DEB7><EFBFBD>
// Ȩ<>޷<EFBFBD><DEB7><EFBFBD> & <20><>Ȩ<EFBFBD><C8A8><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
// AuthenticationStateProvider<65><72>Server <EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD>ÿ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>֤<EFBFBD><EFBFBD>ʵ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ΪĬ<EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>
// AuthenticationStateProvider<65><72>Server ʹ<>ÿ<EFBFBD><C3BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>֤<EFBFBD><D6A4>ʵ<EFBFBD><CAB5>
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();
// <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>();
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>߷<EFBFBD><DFB7><EFBFBD>
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
// Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Provider<65><72>Server ʹ<><CAB9> ServerTokenProvider<EFBFBD><EFBFBD>
builder.Services.AddScoped<ITokenProvider, ServerTokenProvider>();
builder.Services.AddScoped<ITokenService, TokenService>(); // ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> TokenService
builder.Services.AddScoped<AuthHeaderHandler>();
// <20><><EFBFBD><EFBFBD> SignalR<EFBFBD><EFBFBD>֧<EFBFBD><EFBFBD>ͨ<EFBFBD><EFBFBD> query string <20><><EFBFBD><EFBFBD> access_token<65><6E><EFBFBD><EFBFBD><EFBFBD><EFBFBD> websocket/auth<EFBFBD><EFBFBD>
// ע<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Token <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><><CBA2>/<2F><><EFBFBD><EFBFBD> access & refresh token<EFBFBD><EFBFBD>
// - 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>
builder.Services.AddSignalR();
// HttpClient <20><><EFBFBD><EFBFBD><EFBFBD>ݷ<EFBFBD><DDB7><EFBFBD>
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); // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>úõ<C3BA><C3B5><EFBFBD>֤/<2F><>Ȩ
// EF Core DbContext
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")));
// Redis <20><><EFBFBD><EFBFBD>Ѵ<EFBFBD><D1B4>ڣ<EFBFBD>
// ... <20><><EFBFBD><EFBFBD>ԭ<EFBFBD><D4AD> Redis ע<><D7A2>
var redisConnection = builder.Configuration.GetConnectionString("cache");
builder.Services.AddStackExchangeRedisCache(options =>
{
#region
options.Configuration = redisConnection;
options.InstanceName = builder.Configuration["RedisCache:InstanceName"];
#endregion
});
//// Redis<69>ֲ<EFBFBD>ʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
//builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
// ConnectionMultiplexer.Connect(redisConnection));
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧѹ<D3A6><D1B9>
// Ϊ<><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD> BrowserRefresh ע<><D7A2><EFBFBD>ű<EFBFBD><C5B1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> HTML <20><>Ӧ<EFBFBD><D3A6><EFBFBD><EFBFBD>ѹ<EFBFBD><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Content-Encoding: br <20><><EFBFBD><EFBFBD>ע<EFBFBD><D7A2>ʧ<EFBFBD>ܣ<EFBFBD>
// <20><>Ӧѹ<D3A6><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
// <20>ų<EFBFBD> text/html<6D><6C>BrowserRefresh <20><>Ҫ<EFBFBD><D2AA>δѹ<CEB4><D1B9><EFBFBD><EFBFBD> HTML <20><>ע<EFBFBD><D7A2><EFBFBD>ű<EFBFBD>
options.MimeTypes = ResponseCompressionDefaults.MimeTypes
.Where(m => !string.Equals(m, "text/html", StringComparison.OrdinalIgnoreCase))
.ToArray();
});
builder.Services.AddOpenApi();
builder.Services.AddAntDesign();
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
var app = builder.Build();
app.AddDataMigrate();
// <20><><EFBFBD><EFBFBD>HTTP<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD>
// Forwarded headers<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.MapScalarApiReference(); // ӳ<><D3B3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ο<EFBFBD>·<EFBFBD><C2B7>
app.MapOpenApi(); // ӳ<><D3B3> OpenAPI <20>ĵ<EFBFBD>·<EFBFBD><C2B7>
app.MapScalarApiReference();
app.MapOpenApi();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
// <20><>ȫͷ
//app.UseSecurityHeaders();
// <20><>Ӧѹ<D3A6><D1B9>
app.UseResponseCompression();
app.UseCors(option =>
{
// ע<><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();
@@ -160,63 +152,30 @@ app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// <20><><EFBFBD>澲̬<E6BEB2>ļ<EFBFBD>
ctx.Context.Response.Headers.Append(
"Cache-Control", $"public, max-age={31536000}");
ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age={31536000}");
}
});
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
//app.UseRateLimiter();
// <20>м<EFBFBD><EFBFBD><EFBFBD>˳<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>֤ -> <20><>Ȩ
app.UseAuthentication();
app.UseAuthorization();
// Antiforgery & <20><><EFBFBD><EFBFBD><EFBFBD>м<EFBFBD><D0BC><EFBFBD>
app.UseAntiforgery();
app.MapStaticAssets();
app.UseMiddleware<MonitoringMiddleware>();
//// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˵<EFBFBD>
//app.MapHealthChecks("/health", new HealthCheckOptions
//{
// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
// Predicate = _ => true,
// AllowCachingResponses = false
//});
//app.MapHealthChecks("/health/ready", new HealthCheckOptions
//{
// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
// Predicate = check => check.Tags.Contains("ready")
//});
//app.MapHealthChecks("/health/live", new HealthCheckOptions
//{
// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
// Predicate = _ => false
//});
// SignalR<6C>˵<EFBFBD>
//app.MapHub<ChatHub>("/hubs/chat");
//app.MapHub<NotificationHub>("/hubs/notification");
// SignalR endpoints<74><73><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD> Hub<75><62><EFBFBD><EFBFBD><EFBFBD>ڴ˴<DAB4>ӳ<EFBFBD>
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Hub <20><><EFBFBD><EFBFBD> ChatHub<75><62>NotificationHub<75><62><EFBFBD><EFBFBD><EFBFBD>ڴ<EFBFBD>ȡ<EFBFBD><C8A1>ע<EFBFBD>Ͳ<EFBFBD>ӳ<EFBFBD><EFBFBD>
// app.MapHub<ChatHub>("/hubs/chat");
// app.MapHub<NotificationHub>("/hubs/notification");
app.MapControllers();
// Blazor <20><><EFBFBD>ã<EFBFBD>Server + WASM render modes<65><73>
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly);
//// ȷ<><C8B7><EFBFBD><EFBFBD><EFBFBD>ݿⴴ<DDBF><E2B4B4><EFBFBD><EFBFBD>Ǩ<EFBFBD><C7A8>
//await using (var scope = app.Services.CreateAsyncScope())
//{
// var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// await dbContext.Database.MigrateAsync();
// var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
// await seeder.SeedAsync();
//}
app.Run();