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>
/// 是否记住我 /// 是否记住我
/// </summary> /// </summary>
public bool SaveMe { get; set; } public bool RememberMe { get; set; }
} }
} }

View File

@@ -2,7 +2,6 @@
@layout EmptyLayout @layout EmptyLayout
@inject ILogger<Login> Logger @inject ILogger<Login> Logger
<PageTitle>登录</PageTitle> <PageTitle>登录</PageTitle>
@if (!dataLoaded) @if (!dataLoaded)
@@ -67,7 +66,6 @@ else
private bool _isLoading = false; private bool _isLoading = false;
protected override void OnInitialized() protected override void OnInitialized()
{ {
if (OperatingSystem.IsBrowser()) if (OperatingSystem.IsBrowser())
@@ -85,14 +83,11 @@ else
if (firstRender) if (firstRender)
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); 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) if (!dataLoaded)
{ {
dataLoaded = true; dataLoaded = true;
@@ -102,32 +97,49 @@ else
private async Task LoginAsync() private async Task LoginAsync()
{ {
if (form.Validate()) if (!form.Validate()) return;
_isLoading = true;
StateHasChanged();
try
{ {
// 请求后端登录接口,后端返回 ApiResult<AuthResponse>
var api = "/api/sign/in"; var api = "/api/sign/in";
var result = await HttpService.Post<ApiResult<string>>(api, login); var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
if (result.Success) if (result.Success && result.Data != null)
{ {
Console.WriteLine("请求api成功"); var auth = result.Data;
if (!string.IsNullOrEmpty(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); // provider 仅需要 access token 更新来触发 UI 更新
await localStorage.SetItemAsStringAsync("refreshToken", result.Data); provider.UpdateAuthenticationState(auth.Token);
var authState = (AuthStateProvider as PersistentAuthenticationStateProvider);
if (authState != null)
{
authState.UpdateAuthenticationState(result.Data);
} }
Logger.LogInformation($"登录成功跳转目标,{ReturnUrl}");
Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl);
Navigation.NavigateTo(ReturnUrl ?? "/"); Navigation.NavigateTo(ReturnUrl ?? "/");
} }
}
else else
{ {
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message }); 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) private async Task OnPasswordKeyDown(KeyboardEventArgs value)

View File

@@ -6,26 +6,26 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args); 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(); 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.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>(); 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<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> // Token provider<65><72>WASM<53><4D>: <20><> localStorage <20><>ȡ access token
builder.Services.AddScoped<ITokenProvider, ClientTokenProvider>(); 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>();
// ע<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; var apiBase = builder.Configuration["WebApi:ServerUrl"] ?? builder.HostEnvironment.BaseAddress;
builder.Services.AddHttpClient("ApiClient", client => builder.Services.AddHttpClient("ApiClient", client =>
{ {
@@ -33,8 +33,14 @@ builder.Services.AddHttpClient("ApiClient", client =>
}) })
.AddHttpMessageHandler<AuthHeaderHandler>(); .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> // ע<><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>Ĭ<EFBFBD><EFBFBD> HttpClient <20><> BaseAddress // <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")); 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> // <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 /// - 在每次请求时将 access token 附带 Authorization header
/// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh /// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh
/// - 防止并发刷新SemaphoreSlim /// - 防止并发刷新SemaphoreSlim
/// - 注意:刷新请求必须使用一个不包含本 handler 的 HttpClient避免循环
/// </summary> /// </summary>
public class AuthHeaderHandler : DelegatingHandler public class AuthHeaderHandler : DelegatingHandler
{ {
@@ -45,6 +46,7 @@ namespace Atomx.Admin.Client.Utils
{ {
try try
{ {
// 从 ITokenProvider 获取当前 access tokenWASM: ClientTokenProvider 从 localStorage 读取)
var token = await _tokenProvider.GetTokenAsync(); var token = await _tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(token)) if (!string.IsNullOrEmpty(token))
{ {
@@ -57,6 +59,7 @@ namespace Atomx.Admin.Client.Utils
var response = await base.SendAsync(request, cancellationToken); var response = await base.SendAsync(request, cancellationToken);
// 当发现未授权或后端标记 token 过期时,尝试刷新
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.Headers.Contains("Token-Expired")) response.Headers.Contains("Token-Expired"))
{ {
@@ -76,6 +79,7 @@ namespace Atomx.Admin.Client.Utils
} }
} }
// 刷新失败或无 token跳转到登录页强制刷新页面清除 SPA 状态)
_navigationManager.NavigateTo("/account/login", true); _navigationManager.NavigateTo("/account/login", true);
} }
else 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) private async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken)
{ {
await _refreshLock.WaitAsync(cancellationToken); await _refreshLock.WaitAsync(cancellationToken);
@@ -107,11 +118,13 @@ namespace Atomx.Admin.Client.Utils
return false; return false;
} }
var client = _httpClientFactory.CreateClient(); // 使用不带本 handler 的 HttpClient 发送刷新请求,避免循环
var reqModel = new var client = _httpClientFactory.CreateClient("RefreshClient");
var reqModel = new RefreshRequest
{ {
token = currentAccess, Token = currentAccess,
refreshToken = currentRefresh RefreshToken = currentRefresh
}; };
var reqJson = JsonSerializer.Serialize(reqModel); var reqJson = JsonSerializer.Serialize(reqModel);
using var req = new HttpRequestMessage(HttpMethod.Post, "api/sign/refresh") using var req = new HttpRequestMessage(HttpMethod.Post, "api/sign/refresh")
@@ -125,6 +138,9 @@ namespace Atomx.Admin.Client.Utils
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
_logger.LogWarning("Refresh request failed with status {Status}", resp.StatusCode); _logger.LogWarning("Refresh request failed with status {Status}", resp.StatusCode);
// 如果刷新失败,移除本地 token防止无限重试
await _localStorage.RemoveItemAsync(AccessTokenKey);
await _localStorage.RemoveItemAsync(RefreshTokenKey);
return false; return false;
} }
@@ -137,9 +153,12 @@ namespace Atomx.Admin.Client.Utils
if (authResp == null || string.IsNullOrEmpty(authResp.Token) || string.IsNullOrEmpty(authResp.RefreshToken)) if (authResp == null || string.IsNullOrEmpty(authResp.Token) || string.IsNullOrEmpty(authResp.RefreshToken))
{ {
_logger.LogWarning("Invalid response from refresh endpoint"); _logger.LogWarning("Invalid response from refresh endpoint");
await _localStorage.RemoveItemAsync(AccessTokenKey);
await _localStorage.RemoveItemAsync(RefreshTokenKey);
return false; return false;
} }
// 保存新的 tokens 到 localStorage
await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken); await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken);
await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, 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) private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage original, string newToken)
{ {
var clone = new HttpRequestMessage(original.Method, original.RequestUri); var clone = new HttpRequestMessage(original.Method, original.RequestUri);
// 复制内容(如果存在)
if (original.Content != null) if (original.Content != null)
{ {
var ms = new MemoryStream(); var ms = new MemoryStream();
@@ -176,9 +199,11 @@ namespace Atomx.Admin.Client.Utils
} }
} }
// 复制 headers
foreach (var header in original.Headers) foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value); clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
// 覆盖 Authorization
clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken); clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken);
return clone; 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.Client.Validators;
using Atomx.Admin.Services; using Atomx.Admin.Services;
using Atomx.Admin.Utils; using Atomx.Admin.Utils;
@@ -16,27 +15,42 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace Atomx.Admin.Controllers 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]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public class SignController : ControllerBase public class SignController : ControllerBase
{ {
readonly ILogger<SignController> _logger; private readonly ILogger<SignController> _logger;
readonly IIdentityService _identityService; private readonly IIdentityService _identityService;
readonly IIdCreatorService _idCreator; private readonly IIdCreatorService _idCreator;
readonly IMapper _mapper; private readonly IMapper _mapper;
readonly DataContext _dbContext; private readonly DataContext _dbContext;
readonly JwtSetting _jwtSetting; private readonly JwtSetting _jwtSetting;
readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
readonly AuthenticationStateProvider _authenticationStateProvider; 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; _logger = logger;
_identityService = identityService; _identityService = identityService;
@@ -46,106 +60,201 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting; _jwtSetting = jwtSetting;
_cacheService = cacheService; _cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider; _authenticationStateProvider = authenticationStateProvider;
_tokenService = tokenService;
} }
/// <summary> /// <summary>
/// 用户登录系统 /// 登录:支持邮箱或用户登录
/// - 返回 AuthResponse 给 WASMaccess + refresh
/// - 在 Server 场景同时创建 Cookie兼容 Blazor Server
/// </summary> /// </summary>
/// <returns></returns>
[HttpPost("in")] [HttpPost("in")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Login(LoginModel model) public async Task<IActionResult> Login([FromBody] LoginModel model)
{ {
var validator = new LoginModelValidator(); var validator = new LoginModelValidator();
var validation = validator.Validate(model); var validation = validator.Validate(model);
if (!validation.IsValid) if (!validation.IsValid)
{ {
var message = validation.Errors.FirstOrDefault()?.ErrorMessage; var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
var result = new ApiResult<string>().IsFail(message ?? string.Empty, null); return new JsonResult(new ApiResult<AuthResponse>().IsFail(message, null));
return new JsonResult(result);
} }
var tokenHandler = new JwtSecurityTokenHandler(); // 查询用户(支持 email / username
var issuer = _jwtSetting.Issuer; Atomx.Common.Entities.Admin? user = null;
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;
if (model.Account.Contains("@")) if (model.Account.Contains("@"))
{ {
user = _dbContext.Admins.Where(p => p.Email == model.Account).SingleOrDefault(); user = _dbContext.Admins.SingleOrDefault(p => p.Email == model.Account);
} }
else else
{ {
user = _dbContext.Admins.Where(p => p.Username == model.Account).SingleOrDefault(); user = _dbContext.Admins.SingleOrDefault(p => p.Username == model.Account);
} }
if (user == null) if (user == null)
{ {
var result = new ApiResult<string>().IsFail("用户不存在", null); return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null));
return new JsonResult(result);
} }
// 简单密码校验(项目 uses MD5 存储示例)
if (user.Password != model.Password.ToMd5Password()) if (user.Password != model.Password.ToMd5Password())
{ {
var result = new ApiResult<string>().IsFail("账号密码不正确", null); return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null));
return new JsonResult(result);
} }
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>() // 更新用户登录统计信息
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.Id, user.Id.ToString()),
new Claim(ClaimKeys.Email, user.Email), new Claim(ClaimKeys.Email, user.Email ?? string.Empty),
new Claim(ClaimKeys.Name, user.Username), new Claim(ClaimKeys.Name, user.Username ?? string.Empty),
new Claim(ClaimKeys.Role, user.RoleId.ToString()), new Claim(ClaimKeys.Role, user.RoleId.ToString()),
new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty) new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty)
}; };
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// SignInAsync 创建 HttpOnly Cookie便于 Server-side 认证
var tokenDescriptor = new SecurityTokenDescriptor await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
new AuthenticationProperties
{ {
Subject = claimsIdentity, IsPersistent = model.RememberMe,
AllowRefresh = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
});
Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes), // 另外将 tokens 写入 HttpOnly Cookie增强与传统中间件的兼容性
SigningCredentials = credentials, try
Issuer = issuer, {
Audience = audience 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);
var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); var refreshCookieOptions = new CookieOptions
{
var loginResult = new ApiResult<string>().IsSuccess(tokenString); HttpOnly = true,
//Secure = !Request.IsLocal(),
user.LastLogin = DateTime.UtcNow; SameSite = SameSiteMode.Lax,
user.LastIp = _identityService.GetClientIp(); Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes),
user.LoginCount++; Path = "/"
_dbContext.Admins.Update(user); };
_dbContext.SaveChanges(); Response.Cookies.Append("refresh_token", authResponse.RefreshToken, refreshCookieOptions);
}
catch (Exception ex)
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); {
_logger.LogWarning(ex, "设置 token cookie 失败(非致命)");
return new JsonResult(loginResult); }
return new JsonResult(new ApiResult<AuthResponse>().IsSuccess(authResponse));
} }
/// <summary> /// <summary>
/// 用户退出系统 /// 刷新客户端传入可能已过期的access token 与 refresh token明文
/// - TokenService.RefreshTokenAsync 会验证并一次性撤销旧的 refresh token生成新的对
/// </summary> /// </summary>
/// <returns></returns> [HttpPost("refresh")]
[HttpGet("out")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> LogoutAsync() public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{ {
await HttpContext.SignOutAsync(); if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
return new JsonResult(new ApiResult<string>()); {
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 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 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) public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
{ {
var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get<JwtSetting>(); var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get<JwtSetting>();
if (jwtSetting == null) if (jwtSetting == null)
{ {
throw new Exception("缺少配置信息"); throw new Exception("缺少配置信息 Authentication:JwtBearer");
} }
services.AddSingleton(jwtSetting); services.AddSingleton(jwtSetting);
// 从配置读取 Cookie 设置(可在 appsettings.json 的 Authentication:Cookie 节点配置) // Cookie 配置读取
var cookieConf = Configuration.GetSection("Authentication:Cookie"); var cookieConf = Configuration.GetSection("Authentication:Cookie");
var cookieName = cookieConf.GetValue<string>("Name") ?? ".Atomx.Auth"; var cookieName = cookieConf.GetValue<string>("Name") ?? ".Atomx.Auth";
var cookiePath = cookieConf.GetValue<string>("Path") ?? "/"; var cookiePath = cookieConf.GetValue<string>("Path") ?? "/";
@@ -36,30 +35,26 @@ namespace Atomx.Admin.Extensions
var securePolicyStr = cookieConf.GetValue<string>("SecurePolicy"); var securePolicyStr = cookieConf.GetValue<string>("SecurePolicy");
var expireMinutes = cookieConf.GetValue<int?>("ExpireMinutes") ?? 60; var expireMinutes = cookieConf.GetValue<int?>("ExpireMinutes") ?? 60;
// 解析 SameSite默认开发环境 Strict生产环境 None 用于跨站点场景比如前后端分离)
SameSiteMode sameSiteMode; SameSiteMode sameSiteMode;
if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse<SameSiteMode>(sameSiteStr, true, out sameSiteMode)) if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse<SameSiteMode>(sameSiteStr, true, out sameSiteMode))
{ {
sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None; sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None;
} }
// 解析 SecurePolicy默认开发 SameAsRequest生产 Always
CookieSecurePolicy securePolicy; CookieSecurePolicy securePolicy;
if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse<CookieSecurePolicy>(securePolicyStr, true, out securePolicy)) if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse<CookieSecurePolicy>(securePolicyStr, true, out securePolicy))
{ {
securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
} }
//认证配置:注册 Cookie用于 SignIn/SignOut和 JwtBearer用于 API 授权)
services.AddAuthentication(options => services.AddAuthentication(options =>
{ {
// 默认用于 API 的认证/挑战方案使用 JwtBearer // 默认用于 API 的认证方案为 JwtBearer
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}) })
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{ {
// Cookie 配置,确保 SignInAsync 能找到处理器
options.Cookie.Name = cookieName; options.Cookie.Name = cookieName;
options.Cookie.Path = cookiePath; options.Cookie.Path = cookiePath;
if (!string.IsNullOrWhiteSpace(cookieDomain)) if (!string.IsNullOrWhiteSpace(cookieDomain))
@@ -88,25 +83,37 @@ namespace Atomx.Admin.Extensions
ValidateAudience = true, ValidateAudience = true,
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidAudience = jwtSetting.Audience,//Audience ValidAudience = jwtSetting.Audience,
ValidIssuer = jwtSetting.Issuer, 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)) IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.SecurityKey))
}; };
// 允许从 query string 中获取 access_token用于 SignalR / WebSocket
options.Events = new JwtBearerEvents 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 => OnChallenge = context =>
{ {
// 浏览器端访问 API 并无 token 时重定向到登录页
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"; var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
context.HandleResponse(); context.HandleResponse();
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}"); context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
return Task.CompletedTask; return Task.CompletedTask;
}, },
OnAuthenticationFailed = context => OnAuthenticationFailed = context =>
{ {
//Token expired if (context.Exception?.GetType() == typeof(SecurityTokenExpiredException))
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{ {
context.Response.Headers.Append("Token-Expired", "true"); context.Response.Headers.Append("Token-Expired", "true");
} }
@@ -115,13 +122,13 @@ namespace Atomx.Admin.Extensions
}; };
}); });
// 注册基于权限的策略
services.AddAuthorization(options => services.AddAuthorization(options =>
{ {
// 基于权限的策略
var allPermissions = Permissions.GetAllPermissions(); var allPermissions = Permissions.GetAllPermissions();
foreach (var permission in allPermissions) 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 namespace Atomx.Admin.Middlewares
{ {
/// <summary>
/// 请求监控中间件
/// 变更点:
/// - 不再在构造函数注入作用域scoped服务 IIdentityService避免在应用启动时从根 provider 解析 scoped 服务导致异常。
/// - 在每次请求处理时通过 HttpContext.RequestServices 获取 IIdentityService请求作用域内解析
/// </summary>
public class MonitoringMiddleware public class MonitoringMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly ILogger<MonitoringMiddleware> _logger; private readonly ILogger<MonitoringMiddleware> _logger;
private readonly MonitoringOptions _options; 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; _next = next;
_logger = logger; _logger = logger;
_options = options.Value; _options = options.Value;
_identityService = identityService;
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
// 在请求作用域内解析 IIdentityService避免在中间件构造时从根 provider 解析 scoped 服务
var identityService = context.RequestServices.GetService<IIdentityService>();
// 检查是否应该跳过监控 // 检查是否应该跳过监控
if (ShouldSkipMonitoring(context)) if (ShouldSkipMonitoring(context))
{ {
@@ -32,7 +39,7 @@ namespace Atomx.Admin.Middlewares
var logInfo = new var logInfo = new
{ {
UserId = _identityService.GetUserId(), UserId = identityService?.GetUserId(),
Path = context.Request.Path, Path = context.Request.Path,
Method = context.Request.Method, Method = context.Request.Method,
StartTime = DateTime.UtcNow, StartTime = DateTime.UtcNow,
@@ -50,7 +57,6 @@ namespace Atomx.Admin.Middlewares
{ {
stopwatch.Stop(); stopwatch.Stop();
var logData = new var logData = new
{ {
Path = context.Request.Path, Path = context.Request.Path,

View File

@@ -28,7 +28,7 @@ using System.Text.Unicode;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Configure Serilog // Serilog <20><><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD>
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext() .Enrich.FromLogContext()
@@ -37,7 +37,7 @@ Log.Logger = new LoggerConfiguration()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger(); .CreateLogger();
// Add services to the container. // <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
@@ -56,101 +56,93 @@ builder.Services.AddBlazoredLocalStorage();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Ȩ<>޷<EFBFBD><DEB7><EFBFBD> // Ȩ<>޷<EFBFBD><DEB7><EFBFBD> & <20><>Ȩ<EFBFBD><C8A8><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddScoped<IPermissionService, PermissionService>(); builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>(); 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>(); 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>(); builder.Services.AddScoped<PersistentAuthenticationStateProvider>();
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>߷<EFBFBD><DFB7><EFBFBD>
builder.Services.AddScoped<IIdCreatorService, IdCreatorService>(); builder.Services.AddScoped<IIdCreatorService, IdCreatorService>();
builder.Services.AddScoped<IIdentityService, IdentityService>(); 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>();
// 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<ITokenProvider, ServerTokenProvider>();
builder.Services.AddScoped<ITokenService, TokenService>(); // ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD> TokenService
builder.Services.AddScoped<AuthHeaderHandler>(); 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(); builder.Services.AddSignalR();
// HttpClient <20><><EFBFBD><EFBFBD><EFBFBD>ݷ<EFBFBD><DDB7><EFBFBD>
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost"); builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
builder.Services.AddDataService(); 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."); 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><D1B4>ڣ<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 =>
{ {
#region
options.Configuration = redisConnection; options.Configuration = redisConnection;
options.InstanceName = builder.Configuration["RedisCache:InstanceName"]; options.InstanceName = builder.Configuration["RedisCache:InstanceName"];
#endregion
}); });
//// Redis<69>ֲ<EFBFBD>ʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD> // <20><>Ӧѹ<D3A6><D1B9><EFBFBD><EFBFBD><EFBFBD><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>
builder.Services.AddResponseCompression(options => builder.Services.AddResponseCompression(options =>
{ {
options.EnableForHttps = true; options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>(); options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>(); 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 options.MimeTypes = ResponseCompressionDefaults.MimeTypes
.Where(m => !string.Equals(m, "text/html", StringComparison.OrdinalIgnoreCase)) .Where(m => !string.Equals(m, "text/html", StringComparison.OrdinalIgnoreCase))
.ToArray(); .ToArray();
}); });
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"));
var app = builder.Build(); var app = builder.Build();
app.AddDataMigrate(); 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 app.UseForwardedHeaders(new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
}); });
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseWebAssemblyDebugging(); app.UseWebAssemblyDebugging();
app.MapScalarApiReference(); // ӳ<><D3B3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ο<EFBFBD>·<EFBFBD><C2B7> app.MapScalarApiReference();
app.MapOpenApi(); // ӳ<><D3B3> OpenAPI <20>ĵ<EFBFBD>·<EFBFBD><C2B7> app.MapOpenApi();
} }
else else
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
} }
// <20><>ȫͷ
//app.UseSecurityHeaders();
// <20><>Ӧѹ<D3A6><D1B9>
app.UseResponseCompression(); app.UseResponseCompression();
app.UseCors(option => 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.AllowAnyOrigin();
option.AllowAnyMethod(); option.AllowAnyMethod();
option.AllowAnyHeader(); option.AllowAnyHeader();
@@ -160,63 +152,30 @@ app.UseStaticFiles(new StaticFileOptions
{ {
OnPrepareResponse = ctx => 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> // <20>м<EFBFBD><EFBFBD><EFBFBD>˳<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>֤ -> <20><>Ȩ
//app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Antiforgery & <20><><EFBFBD><EFBFBD><EFBFBD>м<EFBFBD><D0BC><EFBFBD>
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.UseMiddleware<MonitoringMiddleware>(); app.UseMiddleware<MonitoringMiddleware>();
// 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><EFBFBD>˵<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.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<ChatHub>("/hubs/chat");
// app.MapHub<NotificationHub>("/hubs/notification"); // app.MapHub<NotificationHub>("/hubs/notification");
app.MapControllers(); app.MapControllers();
// Blazor <20><><EFBFBD>ã<EFBFBD>Server + WASM render modes<65><73>
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode() .AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly); .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(); app.Run();