背景

ASP.NET Core 内置了功能强大的 JWT Bearer 认证中间件(JwtBearerHandler),在大部分场景下开箱即用。但在实际项目中,我们经常会遇到一些"标准流程之外"的需求,比如:

  • 希望从 Header / QueryString / Form 等多个位置灵活获取 token,而不仅仅是 Authorization
  • 需要在验证失败时记录详细的日志或做额外审计
  • 某些内部接口需要自定义的校验逻辑,不想走标准的 TokenValidationParameters
  • 特殊场景下需要对过期 token 做额外"续命"或提示处理

这些需求通过 JwtBearerEvents 也能实现,但逻辑分散、可测试性差。本文将展示如何通过继承 AuthenticationHandler<TOptions> 实现一个完全自定义的 JWT 认证处理器,让认证逻辑从中间件管道中抽离出来,形成独立、可复用、可测试的组件。

整体思路

ASP.NET Core 的认证体系是基于 Scheme 模式的。我们只需:

  1. 自定义 Options 类(可继承 JwtBearerOptions 保留原有配置能力)
  2. 自定义 Handler 类(继承 AuthenticationHandler<TOptions>,重写 HandleAuthenticateAsync
  3. 在 DI 中通过 AddScheme<TOptions, THandler> 注册为某个 Scheme 的处理器
  4. 标准的 [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 的方式就是架构层的正确投资

小结

  1. 继承 AuthenticationHandler<TOptions> 重写 HandleAuthenticateAsync 是自定义认证的标准姿势
  2. Options 继承官方 JwtBearerOptions,既能保留既有配置,又能做平滑迁移
  3. 把 Token 验证拆到独立 Service,让核心逻辑脱离 HTTP 上下文,可单独测试
  4. 三种 token 提取策略一次写全:Header → Form → Query,几乎覆盖所有业务场景
  5. 别忘了 ClockSkew = TimeSpan.Zero,否则你调试"为什么过期 token 还能登录"时会想砸键盘 🫠

这套方案已在 SwitchData 内部稳定运行,后续我们会接着聊:如何在自定义 Handler 基础上扩展 Token 黑名单刷新 Token 自动续期分布式缓存共享会话 等更高级的能力,敬请关注。