背景
ASP.NET Core 内置了功能强大的 JWT Bearer 认证中间件(JwtBearerHandler),在大部分场景下开箱即用。但在实际项目中,我们经常会遇到一些"标准流程之外"的需求,比如:
- 希望从 Header / QueryString / Form 等多个位置灵活获取 token,而不仅仅是
Authorization头 - 需要在验证失败时记录详细的日志或做额外审计
- 某些内部接口需要自定义的校验逻辑,不想走标准的
TokenValidationParameters - 特殊场景下需要对过期 token 做额外"续命"或提示处理
这些需求通过 JwtBearerEvents 也能实现,但逻辑分散、可测试性差。本文将展示如何通过继承 AuthenticationHandler<TOptions> 实现一个完全自定义的 JWT 认证处理器,让认证逻辑从中间件管道中抽离出来,形成独立、可复用、可测试的组件。
整体思路
ASP.NET Core 的认证体系是基于 Scheme 模式的。我们只需:
- 自定义 Options 类(可继承
JwtBearerOptions保留原有配置能力) - 自定义 Handler 类(继承
AuthenticationHandler<TOptions>,重写HandleAuthenticateAsync) - 在 DI 中通过
AddScheme<TOptions, THandler>注册为某个 Scheme 的处理器 - 标准的
[Authorize]即可自动走我们自定义的逻辑
下面结合 SwitchData 项目中的实际代码,一步一步拆解。
核心代码结构
1. 自定义 Options
public class CustomJwtBearerOptions : JwtBearerOptions
{
}
public class CustomJwtBearerPostConfigureOptions : IPostConfigureOptions<CustomJwtBearerOptions>
{
public void PostConfigure(string name, CustomJwtBearerOptions options)
{
// 在这里做默认值/配置合并,需要时再填
}
}
Options 继承自 JwtBearerOptions,这意味着我们保留了官方 JWT 处理器的全部配置项(Authority、TokenValidationParameters、Events 等),未来即便切换回官方 Handler 也不用改配置。
💡 小技巧:
IPostConfigureOptions<T>是 ASP.NET Core Options 体系中"最后一道配置钩子",常用于在用户配置完成后补默认值。
2. 自定义 Handler(重点)
public class CustomJwtBearerHandler(
IOptionsMonitor<CustomJwtBearerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<CustomJwtBearerOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = GetTokenFromRequest(Context.Request);
if (string.IsNullOrEmpty(token))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
try
{
var principal = AccountService.ValidateToken(token);
if (principal == null)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid token"));
}
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch (Exception ex)
{
return Task.FromResult(AuthenticateResult.Fail(ex));
}
}
private static string GetTokenFromRequest(HttpRequest request)
{
// 1. 从 Authorization header 获取
if (request.Headers.TryGetValue("Authorization", out var authHeader))
{
var headerValue = authHeader.ToString();
if (!string.IsNullOrEmpty(headerValue) &&
headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return headerValue["Bearer ".Length..].Trim();
}
}
// 2. 从 Form 数据获取(application/x-www-form-urlencoded)
if (request.HasFormContentType && request.Form.TryGetValue("access_token", out var formToken))
{
return formToken.ToString();
}
// 3. 从 QueryString 获取(例如 WebSocket / 图片下载场景)
if (request.Query.TryGetValue("access_token", out var queryToken))
{
return queryToken.ToString();
}
return null;
}
}
这段代码是整个自定义 Handler 的灵魂,几个关键点:
| 返回值 | 使用场景 |
|---|---|
AuthenticateResult.NoResult() |
当前请求"没有携带认证信息",交给下一个 Handler(或挑战 401) |
AuthenticateResult.Fail(...) |
请求有 token 但无效,明确认证失败 |
AuthenticateResult.Success(ticket) |
认证成功,后续 MVC 会把 ClaimsPrincipal赋值给 HttpContext.User |
3. 多位置 Token 提取策略
GetTokenFromRequest 是实际项目中非常实用的部分。在下面这些场景里,浏览器/客户端没办法设置 Authorization header,只能走 Query 或 Form:
- 📡 WebSocket 连接:浏览器端
new WebSocket(url)无法自定义 header - 🖼️
<img src>或<a download>标签:浏览器原生请求,不允许加自定义 header - 📮 第三方系统回调:对方平台只支持在 URL 或 Form 里塞 token
一个方法同时支持 3 种读取方式,避免了在 Action 里写 Request.Query["access_token"] 这种"跨层代码"。
验证逻辑解耦:AccountService.ValidateToken
Handler 本身只做"管道适配",真正的 Token 解析/校验逻辑放在独立的 Service 静态方法里,这样单元测试时可以完全脱离 HTTP 上下文:
public static ClaimsPrincipal ValidateToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(ApiAppSettings.Jwt.SecretKey);
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidIssuer = ApiAppSettings.Jwt.Issuer,
ValidateAudience = true,
ValidAudience = ApiAppSettings.Jwt.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out _);
return principal;
}
⚠️ 关键设置:ClockSkew = TimeSpan.Zero。JWT 官方库默认有 5 分钟的"时钟漂移容忍",也就是说 token 即使过期了 5 分钟内仍会被判定有效。对业务强一致性场景(比如"必须准时登出"),建议显式设为 Zero。
注册到 DI 容器
最后一步,在 Program.cs 里把 Handler 注册到 ASP.NET Core 的认证管道:
private static void ConfigureAuthentication(
IHostApplicationBuilder builder,
IConfiguration configuration,
IHostEnvironment environment)
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddScheme<CustomJwtBearerOptions, CustomJwtBearerHandler>(
JwtBearerDefaults.AuthenticationScheme,
options =>
{
var authority = ApiAppSettings.Jwt.Issuer;
options.Authority = authority;
options.RequireHttpsMetadata = !environment.IsDevelopment();
options.Audience = ApiAppSettings.Jwt.Audience;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = ApiAppSettings.Jwt.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(ApiAppSettings.Jwt.SecretKey)),
ValidTypes = ["JWT"]
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
if (context.Exception is SecurityTokenMalformedException)
{
var token = context.Request.Headers["Authorization"]
.FirstOrDefault()
?? context.Request.Query["access_token"]
.FirstOrDefault()
?? (context.Request.HasFormContentType
? context.Request.Form["access_token"].FirstOrDefault()
: null);
Console.WriteLine($"Token received: {token}");
Console.WriteLine($"Token length: {token?.Length}");
}
return Task.CompletedTask;
},
OnTokenValidated = _ =>
{
Console.WriteLine("Token validated successfully");
return Task.CompletedTask;
}
};
});
}
这里有一个有趣的细节:因为我们的 Options 继承自 JwtBearerOptions,所以 JwtBearerEvents 这种配置即使在自定义 Handler 里也完全保留,调试时可以直接从 Events 里看到失败堆栈。
这种方式 vs 官方 JwtBearerHandler 对比
| 维度 | 官方 JwtBearerHandler |
自定义 AuthenticationHandler |
|---|---|---|
| 多位置读取 token | 需要在 Events 里改 | Handler 内部统一处理 |
| 自定义校验入口 | 只能在 TokenValidationParameters的各个委托 |
直接写在 try/catch 里,完全可控 |
| 可测试性 | 依赖 HTTP 上下文 | 校验逻辑可拆到独立 Service |
| 代码量 | 少 | 多写几百行,但更灵活 |
| 与 OpenIddict/OIDC 集成 | ✅ 原生支持 | ⚠️ 需要自己处理 metadata |
我的建议:90% 的项目直接用官方 Handler + Events 就够了,但如果你遇到:
- 客户端非常奇怪,token 到处传
- 需要做自定义缓存/黑名单
- 未来可能要和"非标准 SSO 系统"对接
那么这种继承 AuthenticationHandler 的方式就是架构层的正确投资。
小结
- 继承
AuthenticationHandler<TOptions>重写HandleAuthenticateAsync是自定义认证的标准姿势 - Options 继承官方
JwtBearerOptions,既能保留既有配置,又能做平滑迁移 - 把 Token 验证拆到独立 Service,让核心逻辑脱离 HTTP 上下文,可单独测试
- 三种 token 提取策略一次写全:Header → Form → Query,几乎覆盖所有业务场景
- 别忘了
ClockSkew = TimeSpan.Zero,否则你调试"为什么过期 token 还能登录"时会想砸键盘 🫠
这套方案已在 SwitchData 内部稳定运行,后续我们会接着聊:如何在自定义 Handler 基础上扩展 Token 黑名单、刷新 Token 自动续期、分布式缓存共享会话 等更高级的能力,敬请关注。