using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.DependencyInjection;
///
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
///
public static class JwtServiceCollectionExtensions
{
///
/// 注册 JWT 认证:
///
/// - 绑定 Jwt 配置节到 ,并在启动时校验签名密钥长度;
/// - 注册 (令牌生成/校验)与 (当前用户访问器);
/// - 注册 认证,认证失败/授权失败统一返回 格式;
/// - 注册 (基于 IMemoryCache 的在线会话存储):单点登录、在线人员查询、退出销毁;
/// - 为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。
///
///
///
/// 需要在请求管道中调用 app.UseAuthentication()(位于 app.UseAuthorization() 之前)。
///
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// JwtOptions:绑定 Jwt 配置节,启动时校验 HS256 签名密钥长度
services.AddOptions()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.Validate(
o => !string.IsNullOrWhiteSpace(o.SigningKey)
&& Encoding.UTF8.GetByteCount(o.SigningKey) >= 32,
"Jwt:SigningKey 长度不足,HS256 签名密钥至少需要 32 字节(256位)。")
.ValidateOnStart();
services.AddHttpContextAccessor();
// 令牌服务与当前用户访问器
services.AddSingleton();
services.AddScoped();
// 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
services.AddMemoryCache();
services.AddSingleton();
// JwtBearer 认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions();
options.TokenValidationParameters = jwt.ToTokenValidationParameters();
options.Events = new JwtBearerEvents
{
OnChallenge = JwtBearerResponseHandler.OnChallenge,
OnForbidden = JwtBearerResponseHandler.OnForbidden,
OnMessageReceived = context =>
{
// 固定测试凭据只在 Development 环境生效,不依赖 JWT 过期时间或在线会话。
var environment = context.HttpContext.RequestServices.GetRequiredService();
var development = configuration.GetSection("Jwt:DevelopmentToken");
var token = development["Token"];
var userName = development["UserName"];
var authorization = context.Request.Headers.Authorization.ToString();
if (environment.IsDevelopment()
&& development.GetValue("Enabled")
&& !string.IsNullOrWhiteSpace(token)
&& !string.IsNullOrWhiteSpace(userName)
&& authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
&& string.Equals(authorization[7..].Trim(), token, StringComparison.Ordinal))
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(JwtClaimTypes.UserName, userName),
new Claim(JwtClaimTypes.NickName, "开发测试用户")],
JwtBearerDefaults.AuthenticationScheme));
context.Success();
}
return Task.CompletedTask;
},
// 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService();
ClaimsPrincipal? principal = context.Principal;
string? userName = principal?.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName)
|| string.IsNullOrWhiteSpace(jti)
|| !onlineUserStore.IsTokenActive(userName, jti))
{
context.Fail(new SecurityTokenException("登录已失效,请重新登录"));
}
return Task.CompletedTask;
},
};
});
// OpenAPI:声明 Bearer(JWT) 安全方案(Scalar 页面即可显示 Authorize/输入 token),
// 并仅对标记了 [Authorize] 的接口声明需要携带令牌(与宿主的 AddOpenApi 配置合并生效)
services.Configure(options =>
{
// 在 OpenAPI 文档中声明 Bearer(JWT) 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList