Files
StoreWeb/StoreHub.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs
T
2026-08-14 18:00:58 +08:00

128 lines
5.2 KiB
C#

using Asp.Versioning.OpenApi;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using StoreHub.Framework.Security;
using System.Security.Claims;
using System.Text;
namespace StoreHub.Framework.DependencyInjection;
/// <summary>
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
/// </summary>
public static class JwtServiceCollectionExtensions
{
/// <summary>
/// 注册 JWT 认证:
/// <list type="number">
/// <item>绑定 <c>Jwt</c> 配置节到 <see cref="JwtOptions"/>,并在启动时校验签名密钥长度;</item>
/// <item>注册 <see cref="ITokenService"/>(令牌生成/校验)与 <see cref="ICurrentUser"/>(当前用户访问器);</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="StoreHub.Framework.Commons.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="IOnlineUserStore"/>(基于 <c>IMemoryCache</c> 的在线会话存储):单点登录、在线人员查询、退出销毁;</item>
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
/// </list>
/// </summary>
/// <remarks>
/// 需要在请求管道中调用 <c>app.UseAuthentication()</c>(位于 <c>app.UseAuthorization()</c> 之前)。
/// </remarks>
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// JwtOptions:绑定 Jwt 配置节,启动时校验 HS256 签名密钥长度
services.AddOptions<JwtOptions>()
.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<ITokenService, TokenService>();
services.AddScoped<ICurrentUser, CurrentUser>();
// 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
services.AddMemoryCache();
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// JwtBearer 认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
options.TokenValidationParameters = jwt.ToTokenValidationParameters();
options.Events = new JwtBearerEvents
{
OnChallenge = JwtBearerResponseHandler.OnChallenge,
OnForbidden = JwtBearerResponseHandler.OnForbidden,
// 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService<IOnlineUserStore>();
ClaimsPrincipal? principal = context.Principal;
string? userIdValue = principal?.FindFirst(JwtClaimTypes.UserId)?.Value;
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
if (!int.TryParse(userIdValue, out int userId)
|| string.IsNullOrWhiteSpace(jti)
|| !onlineUserStore.IsTokenActive(userId, jti))
{
context.Fail(new SecurityTokenException("登录已失效,请重新登录"));
}
return Task.CompletedTask;
},
};
});
// OpenAPI:声明 Bearer(JWT) 安全方案(Scalar 页面即可显示 Authorize/输入 token),
// 并仅对标记了 [Authorize] 的接口声明需要携带令牌(与宿主的 AddOpenApi 配置合并生效)
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer(JWT) 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
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<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}