151 lines
7.6 KiB
C#
151 lines
7.6 KiB
C#
using Atomx.Admin.Utils;
|
||
using Atomx.Common.Constants;
|
||
using Atomx.Common.Models;
|
||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using System.Text;
|
||
|
||
namespace Atomx.Admin.Extensions
|
||
{
|
||
/// <summary>
|
||
/// AuthorizationExtension 重构说明:
|
||
/// - 为混合部署(Blazor Server + WASM)与跨站点场景优化 Challenge 行为:
|
||
/// * 对浏览器导航/页面请求执行重定向到登录页(保持友好体验)
|
||
/// * 对 API / XHR / Fetch / SignalR 请求返回 401 JSON(避免 HTML 重定向导致前端错误)
|
||
/// - 保持 JwtBearer 对 SignalR query string 的读取
|
||
/// - Cookie 的 SecurePolicy 根据环境设置,SameSite 使用 Lax(可在跨站点场景改为 None 并开启 AllowCredentials)
|
||
/// </summary>
|
||
public static class AuthorizationExtension
|
||
{
|
||
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("缺少配置信息 Authentication:JwtBearer");
|
||
}
|
||
services.AddSingleton(jwtSetting);
|
||
|
||
services.AddAuthentication(options =>
|
||
{
|
||
// 为混合 Server + API 场景选择:默认用于认证/读取身份的方案为 Cookie(Server 端 Circuit 使用)
|
||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||
// 当需要挑战(Challenge)时使用 JwtBearer 的行为(其 OnChallenge 可做重定向处理)
|
||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
})
|
||
// JwtBearer 保持用于 API token 校验
|
||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||
{
|
||
options.RequireHttpsMetadata = !environment.IsDevelopment();
|
||
options.SaveToken = true;
|
||
|
||
options.ClaimsIssuer = jwtSetting.Issuer;
|
||
options.TokenValidationParameters = new TokenValidationParameters
|
||
{
|
||
ValidateIssuer = true,
|
||
ValidateAudience = true,
|
||
ValidateLifetime = true,
|
||
ValidateIssuerSigningKey = true,
|
||
ValidAudience = jwtSetting.Audience,
|
||
ValidIssuer = jwtSetting.Issuer,
|
||
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 =>
|
||
{
|
||
var accessToken = context.Request.Query["access_token"].FirstOrDefault();
|
||
var path = context.HttpContext.Request.Path;
|
||
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api")))
|
||
{
|
||
context.Token = accessToken;
|
||
}
|
||
return Task.CompletedTask;
|
||
},
|
||
OnChallenge = async context =>
|
||
{
|
||
// 当 JwtChallenge 触发时,根据请求类型决定行为:
|
||
// - 浏览器导航 / Accept:text/html(页面请求) => 重定向到登录页
|
||
// - API / XHR / JSON 请求 => 返回 401 JSON
|
||
try
|
||
{
|
||
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 =>
|
||
{
|
||
if (context.Exception?.GetType() == typeof(SecurityTokenExpiredException))
|
||
{
|
||
context.Response.Headers.Append("Token-Expired", "true");
|
||
}
|
||
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;
|
||
});
|
||
|
||
// 注册基于权限的策略
|
||
services.AddAuthorization(options =>
|
||
{
|
||
var allPermissions = Permissions.GetAllPermissions();
|
||
foreach (var permission in allPermissions)
|
||
{
|
||
options.AddPolicy(permission, policy => policy.Requirements.Add(new PermissionRequirement(permission)));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|