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 metadata = context.Description.ActionDescriptor.EndpointMetadata; bool requiresAuth = metadata.OfType().Any() && !metadata.OfType().Any(); if (requiresAuth) { operation.Security = [ new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [], }, ]; } return Task.CompletedTask; }); }); return services; } }