JWT Bearer 认证
适合 API、移动端、前后端分离场景的认证方式——从请求头的 Bearer Token 中校验并还原用户身份。
适用场景
- 请求通过
Authorization: Bearer <token>携带访问令牌 - 服务端不希望用浏览器 Cookie 维持登录态
- 需要和 OpenID Connect、OAuth2 或自定义 JWT 签发流程配合
不适合浏览器页面登录态场景——那种情况优先用 Cookie 认证。
前置条件
JWT Bearer 认证依赖以下包:
| 包 | 用途 |
|---|---|
soulsoft_web_authentication | 认证框架核心(中间件、Handler 基类、方案注册) |
soulsoft_web_authentication_jwtbearer | JWT Bearer 处理器、选项、事件上下文 |
soulsoft_identity_tokens | Token 抽象、签名凭据、校验参数 |
soulsoft_identity_tokens_jwt | JWT 具体实现(JwtSecurityTokenHandler) |
soulsoft_identity_claims | 身份声明模型(Claim、ClaimsPrincipal) |
所有包均通过 cjpm.toml 声明依赖后即可使用,无需额外安装工具。
其中
soulsoft_identity_tokens和soulsoft_identity_tokens_jwt负责 Token 的创建与校验——完整的密钥管理、算法选择、非对称签名、自定义校验委托等用法见 JWT 令牌。
快速开始
下面是一个完整可运行的最小示例:自签 JWT、注册 Bearer 认证、配置授权策略、保护接口。
import std.time.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_identity_claims.*
import soulsoft_identity_tokens.*
import soulsoft_web_authorization.*
import soulsoft_web_authentication.*
import soulsoft_identity_tokens_jwt.*
import soulsoft_extensions_injection.*
import soulsoft_web_authentication_jwtbearer.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addRouting()
let signingKey = SymmetricSecurityKey("your-256-bit-secret-your-256-bit-secret".toArray())
// 指定默认认证方案为 Bearer,配置 Token 校验参数
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
.addJwtBearer(JwtBearerDefaults.Scheme) { options =>
options.tokenValidationParameters.validateIssuer = true
options.tokenValidationParameters.validIssuers = ["spire-demo"]
options.tokenValidationParameters.validateAudience = true
options.tokenValidationParameters.validAudiences = ["spire-api"]
options.tokenValidationParameters.issuerSigningKeys = [signingKey]
}
builder.services.addAuthorization() { options =>
options.addPolicy("AdminOnly") { policy =>
policy.requireClaim("role", ["admin"])
}
}
let app = builder.build()
app.useRouting()
app.useAuthentication()
app.useAuthorization()
// /token — 用 JwtSecurityTokenHandler 签发 Token
app.mapPost("/token") { ctx =>
let creds = SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
let descriptor = SecurityTokenDescriptor(creds)
descriptor.issuer = "spire-demo"
descriptor.audience = "spire-api"
descriptor.expires = DateTime.nowUTC().addSeconds(3600)
descriptor.claims = [Claim("role", "admin")]
let token = JwtSecurityTokenHandler().createEncodedJwt(descriptor)
ctx.response.write(token)
}
// /admin — requireAuthorization(["AdminOnly"]) 要求角色为 admin
app.mapGet("/admin") { ctx =>
ctx.response.write("admin")
}.requireAuthorization(["AdminOnly"])
app.run()
return 0
}这段代码演示了一条完整的认证 + 授权链路:/token 签发 JWT(claims 中写入 role = "admin"),/admin 被 AdminOnly 策略保护。请求 /admin 时,Bearer 处理器从 Authorization 头提取 Token、校验签名和声明、还原用户身份,授权中间件再校验 role 声明是否包含 "admin"。
深入 Token 细节
/token 端点中的 JwtSecurityTokenHandler.createEncodedJwt() 和 addJwtBearer 回调中的 tokenValidationParameters 共享同一套底层 API。需要深入了解非对称密钥签名、自定义校验委托、只解析不验签等完整能力时,见 JWT 令牌。
核心概念
定位与职责
JWT Bearer 负责把请求头中的 Bearer Token 校验并还原成 ClaimsPrincipal。
它关心三件事:
- Token 从哪里读取
- Token 是否验签与校验通过
- 校验成功后如何把身份写入
HttpContext.user
整个流程可以简化为:
Authorization: Bearer eyJhbGciOi...
│
▼
1. 从请求头提取 Token
2. 校验签名、issuer、audience、有效期
3. 从 claims 构建 ClaimsPrincipal
│
▼
context.user = principal(认证完成)
│
▼
授权中间件检查策略 → 放行 / 401 / 403JWT Bearer 只做身份识别,不做权限判断。Token 校验通过 ≠ 有权访问当前接口——那一步由授权策略接管。
校验密钥必须与签发密钥一致
JWT 的签名由签发方用密钥(对称或非对称)生成,校验方必须用同一个密钥或对应的公钥才能验证签名。
如果签发时使用 SymmetricSecurityKey(HMAC 系列),校验方的 issuerSigningKeys 必须包含相同的密钥。如果签发时使用 RSA 非对称密钥,校验方只需持有公钥。
密钥不匹配是认证失败最常见的原因。 排查时优先检查签发端和校验端的密钥是否一致。
Token 校验参数
tokenValidationParameters 控制校验行为,每一项默认都是关闭的——需要显式开启:
| 参数 | 作用 | 不校验的风险 |
|---|---|---|
validateIssuer | 验证 Token 的签发者(iss)是否在 validIssuers 列表中 | 接受任意签发者颁发的 Token |
validateAudience | 验证 Token 的目标受众(aud)是否在 validAudiences 列表中 | Token 可能被用于非预期的服务 |
validateLifetime | 验证 Token 是否在有效期内(nbf / exp) | 过期 Token 仍然可以通过认证 |
issuerSigningKeys | 提供用于验证签名的密钥列表 | 无法验证 Token 签名,认证必定失败 |
生产环境建议四项全部开启,不要因为开发阶段方便而关闭校验。
认证成功不等于授权成功
JWT Bearer 只负责把 Token 变成用户身份——从 claims 构建 ClaimsPrincipal,写入 context.user。
真正决定能否访问某个接口的,是后续的授权策略。示例中 /admin 的可访问性由 AdminOnly 策略决定:
options.addPolicy("AdminOnly") { policy =>
policy.requireClaim("role", ["admin"])
}即使 Bearer 认证通过了(Token 合法、未过期),如果 claims 中的 role 不是 "admin",授权中间件仍然会返回 403。
Token 过期后的行为
Token 过期属于校验失败——validateLifetime = true 时,JwtBearerHandler 会返回 AuthenticateResult.fail("..."),认证中间件不会将用户身份写入 context.user。
后续授权中间件发现用户未认证且策略要求登录,触发 challenge(),最终返回 401。如果需要刷新 Token,应该在客户端侧处理 401 响应并重新获取 Token,而不是在服务端静默续期。
常见用法
配置文件驱动
当认证参数需要随环境变化(开发/测试/生产)时,用配置文件管理比硬编码更灵活。
配置结构中的 signingKeys 对应代码中的 issuerSigningKeys,validIssuers、validAudiences 等字段名称与 tokenValidationParameters 中的属性一一对应。
{
"authentication": {
"schemes": {
"Bearer": {
"challenge": "Bearer",
"saveToken": true,
"validateIssuer": true,
"validateAudience": true,
"validateLifetime": true,
"validIssuers": ["spire-demo"],
"validAudiences": ["spire-api"],
"signingKeys": [
{
"issuer": "spire-demo",
"value": "Base64EncodedKeyBytes"
}
]
}
}
}
}使用配置文件驱动时,代码侧的 addJwtBearer() 回调只需保留配置文件中无法表达的动态逻辑(如事件扩展),其余静态参数由框架自动从配置绑定。
通过 OIDC 元数据自动发现配置
对接第三方 OIDC 提供方(如 Duende IdentityServer、Azure AD)时,手动配置密钥和端点容易出错。设置 metadataAddress 后,框架会自动从 /.well-known/openid-configuration 拉取签名密钥、issuer 等元数据,无需手动填写。
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
.addJwtBearer(JwtBearerDefaults.Scheme) { options =>
options.metadataAddress = "https://demo.duendesoftware.com/.well-known/openid-configuration"
options.requireHttpsMetadata = true
}requireHttpsMetadata = true 确保元数据只能从 HTTPS 地址获取,防止中间人篡改签名密钥。生产环境必须保持开启。
事件扩展
JWT Bearer 在认证流程的关键节点提供了事件回调,允许在不修改处理器源码的情况下插入自定义逻辑。
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
.addJwtBearer(JwtBearerDefaults.Scheme) { options =>
options.events.onMessageReceived = { context =>
context.token = context.request.query["access_token"]
}
options.events.onChallenge = { context =>
context.handleResponse()
context.response.statusCode = 401
context.response.write("invalid or missing bearer token")
}
}onMessageReceived 在从请求头读取 Token 之前触发,适合 WebSocket、SSE 等场景——从 query string 中提取 Token 并赋值给 context.token,后续校验流程不变。onChallenge 在认证失败触发 401 响应之前触发,handleResponse() 后框架不再执行默认的 401 响应逻辑,由你的回调完全接管响应内容。
其他可用事件:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
onTokenValidated | Token 校验成功后 | 额外校验 claims、记录审计日志 |
onAuthenticationFailed | Token 校验失败时 | 记录失败原因、触发告警 |
生产建议
- API 场景优先使用 JWT Bearer,而不是 Cookie 登录态。Bearer Token 是无状态的,服务端不需要维护会话存储,水平扩展更简单。
- 统一约束
issuer、audience、lifetime校验,不要默认放开。关闭任何一项校验都会扩大 Token 的可接受范围,增加安全风险。 - 如果通过 query string 取 token,只建议用于 WebSocket、SSE 等 header 不方便传递的场景。Query string 中的 Token 更容易被日志、代理、浏览器历史记录泄露。
- 生产环境使用非对称密钥(RSA/ECDSA)。对称密钥(HMAC)要求签发方和校验方持有同一密钥,密钥泄露面更大;非对称密钥只需在校验方部署公钥。
常见问题
JWT 与 Cookie 怎么选
API(移动端、SPA)优先用 JWT Bearer;浏览器站点登录态优先用 Cookie。
选 Bearer 的理由:无状态、不依赖服务端会话存储、天然适合跨域和移动端。选 Cookie 的理由:浏览器自动携带、可设置 httpOnly 防 XSS 读取、challenge() 默认跳登录页更适合服务端渲染页面。
Bearer 认证失败为什么不立刻返回 401
认证中间件的设计原则是"只识别身份,不拦截请求"。认证失败时,请求仍然继续往下走到授权中间件——由授权发现"用户未认证 + 当前接口要求登录"后才触发 challenge() → 401。
这样设计是为了支持同一个应用中允许匿名访问和认证访问并存。如果一个接口没有标注 requireAuthorization() 也没有全局 fallbackPolicy,认证失败的用户照样可以访问——认证中间件提前返回 401 反而破坏了这种灵活性。
Token 过期了怎么办
Token 过期后 Bearer 认证会失败,授权中间件返回 401。客户端应当捕获 401 响应,使用刷新令牌(refresh token)获取新 Token,然后重试请求。服务端不在 Bearer 认证层面做静默续期。
相关专题
- 需要理解认证与授权的运行时执行机制 → 认证总览
- 需要深入了解 Token 创建与校验 → JWT 令牌
- 需要控制访问权限 → 授权策略
- 需要浏览器登录态方案 → Cookie 认证
- 需要自定义认证协议 → 自定义Basic认证