Skip to content

JWT Bearer 认证

适合 API、移动端、前后端分离场景的认证方式——从请求头的 Bearer Token 中校验并还原用户身份。

适用场景

  • 请求通过 Authorization: Bearer <token> 携带访问令牌
  • 服务端不希望用浏览器 Cookie 维持登录态
  • 需要和 OpenID Connect、OAuth2 或自定义 JWT 签发流程配合

不适合浏览器页面登录态场景——那种情况优先用 Cookie 认证

前置条件

JWT Bearer 认证依赖以下包:

用途
soulsoft_web_authentication认证框架核心(中间件、Handler 基类、方案注册)
soulsoft_web_authentication_jwtbearerJWT Bearer 处理器、选项、事件上下文
soulsoft_identity_tokensToken 抽象、签名凭据、校验参数
soulsoft_identity_tokens_jwtJWT 具体实现(JwtSecurityTokenHandler
soulsoft_identity_claims身份声明模型(ClaimClaimsPrincipal

所有包均通过 cjpm.toml 声明依赖后即可使用,无需额外安装工具。

其中 soulsoft_identity_tokenssoulsoft_identity_tokens_jwt 负责 Token 的创建与校验——完整的密钥管理、算法选择、非对称签名、自定义校验委托等用法见 JWT 令牌

快速开始

下面是一个完整可运行的最小示例:自签 JWT、注册 Bearer 认证、配置授权策略、保护接口。

cangjie
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"),/adminAdminOnly 策略保护。请求 /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 / 403

JWT 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 策略决定:

cangjie
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 对应代码中的 issuerSigningKeysvalidIssuersvalidAudiences 等字段名称与 tokenValidationParameters 中的属性一一对应。

json
{
  "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 等元数据,无需手动填写。

cangjie
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 在认证流程的关键节点提供了事件回调,允许在不修改处理器源码的情况下插入自定义逻辑。

cangjie
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 响应逻辑,由你的回调完全接管响应内容。

其他可用事件:

事件触发时机典型用途
onTokenValidatedToken 校验成功后额外校验 claims、记录审计日志
onAuthenticationFailedToken 校验失败时记录失败原因、触发告警

生产建议

  • API 场景优先使用 JWT Bearer,而不是 Cookie 登录态。Bearer Token 是无状态的,服务端不需要维护会话存储,水平扩展更简单。
  • 统一约束 issueraudiencelifetime 校验,不要默认放开。关闭任何一项校验都会扩大 Token 的可接受范围,增加安全风险。
  • 如果通过 query string 取 token,只建议用于 WebSocket、SSE 等 header 不方便传递的场景。Query string 中的 Token 更容易被日志、代理、浏览器历史记录泄露。
  • 生产环境使用非对称密钥(RSA/ECDSA)。对称密钥(HMAC)要求签发方和校验方持有同一密钥,密钥泄露面更大;非对称密钥只需在校验方部署公钥。

常见问题

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 认证层面做静默续期。

相关专题