JWT Bearer 认证
本页说明如何用 auth/jwt 让宿主 API 接受 Authorization: Bearer <JWT> 形式的访问令牌,令牌可以来自 wicket 自己,也可以来自其他签发方。
它不是登录流程:没有跳转、没有 cookie,只在每个请求上验证一次令牌并给出身份。
可运行示例
下面的装配在 v0.1.4 上编译并实测:合法令牌返回 200;从自定义头取令牌返回 200;缺少业务 claim、或不带令牌均返回 401。
import (
"fmt"
"log/slog"
"net/http"
"github.com/gonewx/godi"
wicket "github.com/gonewx/wicket"
"github.com/gonewx/wicket/auth"
"github.com/gonewx/wicket/auth/jwt"
"github.com/gonewx/wicket/claims"
"github.com/gonewx/wicket/di"
optspkg "github.com/gonewx/wicket/di/options"
"github.com/gonewx/wicket/hosting"
"github.com/gonewx/wicket/services"
"github.com/lestrrat-go/jwx/v3/jwk"
)
type fixedMonitor[T optspkg.Entity[T]] struct{ opts T }
func (m fixedMonitor[T]) CurrentValue() (T, error) { return m.opts, nil }
func (m fixedMonitor[T]) Get(string) (T, error) { return m.opts, nil }
// keys 是签发方的公钥集合(jwk.Set),例如用 jwk.Fetch 从对方的 JWKS 地址取得。
func wireBearer(root *di.Root, logger *slog.Logger, keys jwk.Set) error {
cfg := root.ServerConfig()
cfg.SetAuthenticationHandlerProvider(wicket.JwtBearerAuthenticationScheme, func(p godi.Provider) auth.Handler {
o := jwt.NewOptions()
o.TokenValidationParameters.ValidIssuer = "https://issuer.example.com"
o.TokenValidationParameters.ValidAudience = "orders-api"
o.TokenValidationParameters.Keys = keys
o.SaveToken = true
ev := jwt.NewEvents() // 从 NewEvents 出发,未覆盖的钩子保持空操作
ev.OnMessageReceived = func(ctx *jwt.MessageReceivedContext) error {
ctx.Token = ctx.Request.Header.Get("X-Api-Token") // 留空则回落到 Authorization 头
return nil
}
ev.OnTokenValidated = func(ctx *jwt.TokenValidatedContext) error {
if ctx.Principal.FindFirst("tenant") == nil {
ctx.Fail(fmt.Errorf("missing tenant claim"))
}
return nil
}
o.SetEvents(ev)
return jwt.NewHandler(fixedMonitor[*jwt.Options]{o}, logger,
godi.GetRequired[services.SystemClock](p))
})
return root.Ptr_SchemeProvider().AddScheme(auth.NewScheme(wicket.JwtBearerAuthenticationScheme, "jwt"))
}
// 在宿主路由里按需认证
func ordersHandler(w http.ResponseWriter, r *http.Request) {
svc := godi.GetRequired[auth.Service](hosting.ScopeFrom(r.Context()))
result, err := svc.Authenticate(r, w, wicket.JwtBearerAuthenticationScheme)
if err != nil || !result.Succeeded() {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sub := result.Principal.FindFirst(claims.NameIdentifier)
fmt.Fprintf(w, "hello %s\n", sub.Value)
}要点:
wireBearer在cfg.Freeze()之前调用;宿主 handler 要挂在hosting.ScopeMiddleware(root)(srv.UseWicket()(app))之内,才能从请求上下文取到作用域。- 方案名是
Bearer(wicket.JwtBearerAuthenticationScheme)。容器的默认方案固定为wicket,所以 Bearer 认证需要像上面这样显式调用Authenticate,它不会自动作用于每个请求。 - 容器自带的
Bearerhandler 没有任何验证密钥,必须通过SetAuthenticationHandlerProvider换成你自己构造的 handler。
令牌从哪里取
- 先触发
OnMessageReceived。钩子可以给ctx.Token赋值(从其他头、cookie、查询参数取令牌),也可以直接设置ctx.Result结束处理。 - 钩子没有给出令牌时,读取
Authorization头,前缀bearer不区分大小写。 - 两处都没有令牌:返回“无结果”(匿名),不是失败。
验证参数(TokenValidationParameters)
真正决定验证行为的是 Options.TokenValidationParameters。jwt.NewOptions() 已给它一份安全默认值:
| 字段 | 默认 | 说明 |
|---|---|---|
RequireSignedTokens | true | 要求验签;为 true 时必须提供 Keys |
Keys | nil | 验签公钥集合(jwk.Set),按 kid 选钥;每把钥都必须带 alg,见下方提示 |
ValidIssuer | 空 | 非空时要求 iss 完全相等 |
ValidAudience | 空 | 非空时要求 aud 包含该值 |
ValidateLifetime | true | 校验 exp / nbf / iat;关掉后仍校验签发方与受众 |
ClockSkew | 5 分钟(jwt.DefaultClockSkew) | 时间比较的容差;0 表示精确比较 |
Clock | nil | 为空时 handler 注入容器的 SystemClock |
ValidateOptions | 空 | 追加的 jwx 校验选项(如自定义 claim 校验) |
Options 字段:哪些在 v0.1.4 中生效
jwt.Options 声明的字段较多,但 v0.1.4 的 handler 只读取其中一部分。未生效的字段设置了也不会有任何效果,请不要依赖:
| 字段 | 是否生效 | 说明 |
|---|---|---|
TokenValidationParameters | 生效 | 见上表 |
SecurityTokenValidators | 生效 | 验证器链,默认只有 jwt.NewSecurityTokenHandler();依次尝试,任一成功即可 |
SaveToken | 生效 | 为 true 时把原始令牌以 access_token 名存入认证属性 |
事件(SetEvents) | 部分生效 | 见下节 |
Authority、MetadataAddress、RequireHttpsMetadata | 未生效 | handler 不做元数据发现,不会自动拉取 JWKS |
RefreshOnIssuerKeyNotFound、AutomaticRefreshInterval、RefreshInterval | 未生效 | 同上,不存在自动刷新 |
Audience | 未生效 | 受众请设在 TokenValidationParameters.ValidAudience |
ValidateOptions(Options 级) | 未生效 | 请用 TokenValidationParameters.ValidateOptions |
Challenge、IncludeErrorDetails | 未生效 | 质询时只写 401 状态码,不写 WWW-Authenticate 头 |
BackchannelHttpHandler | 未生效 | 没有出站请求 |
另外,jwt.NewOptions() 不会给这些字段赋非零默认值——布尔值为 false、时长为 0。
Keys 中的每把公钥都必须带 alg
验证器按 kid 选钥后,使用钥上声明的算法验签,不会根据钥的类型推断算法。v0.1.4 实测:同一组 RSA 公钥去掉 alg 字段后,所有合法令牌都验证失败(401)。很多提供者的 JWKS 带有 alg,但这不是强制字段;拿到 jwk.Set 后请逐把检查,缺失时自行补上,例如:
for i := 0; i < keys.Len(); i++ {
if k, ok := keys.Key(i); ok {
if _, has := k.Algorithm(); !has {
_ = k.Set(jwk.AlgorithmKey, jwa.RS256()) // 按签发方实际使用的算法设置
}
}
}需要跟随签发方轮换密钥?
由于 handler 不做元数据发现,密钥来源由你掌控:可以用 jwk.Fetch 启动时取一次,或用 jwx 的 jwk.Cache 定期刷新后在工厂函数里取最新的 jwk.Set(工厂在每次解析 handler 时调用)。
验证成功后的身份
验证通过后,令牌 claims 被投影为一个认证类型为 Bearer 的身份:
sub映射为claims.NameIdentifier(URI 形式的常量);iss、jti、aud(多值时逐个)以原名保留;exp、nbf、iat不进入身份,而是写入认证属性的Expires/Issued;- 其余 claim 原名原值保留。
事件钩子
jwt.Events 有五个字段,v0.1.4 的 handler 实际调用其中两个:
| 钩子 | 触发 | 用法 |
|---|---|---|
OnMessageReceived | 取令牌之前 | 设置 ctx.Token,或设置 ctx.Result 直接结束 |
OnTokenValidated | 验签与 claim 校验通过之后 | 追加业务校验,调用 ctx.Fail(err) 拒绝;ctx.Principal 可读可改 |
OnAuthenticationFailed、OnForbidden、OnChallenge | 已声明,当前未被调用 | 不要依赖 |
几条容易踩的细节:
- 自己构造
&jwt.Events{...}时未填的字段是nil,被调用时会 panic。请从jwt.NewEvents()出发再覆盖需要的字段。 OnTokenValidated返回非 nil 错误时,handler 会改用验证器链中的下一个验证器;默认链只有一个验证器,于是整体以“没有可用的验证器”失败。要明确拒绝,用ctx.Fail(err)并返回nil。- 所有验证器都失败时结果为失败(
Succeeded()为false),具体原因只写 debug 日志。