Skip to content

JWT Bearer 认证

JWT Bearer 方案负责把请求里的访问令牌解析成 ClaimsPrincipal,并挂到 HttpContext.user

它只负责“这是谁”,不直接决定“能不能访问”。真正触发 401403,通常发生在:

  • 终结点上的 .requireAuthorization(...)
  • 授权中间件里的 challenge() / forbid()
  • 代码里显式调用 context.challenge() / context.forbid()

最小示例

cangjie
import soulsoft_web_hosting.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_authorization.*
import soulsoft_web_authentication.*
import soulsoft_web_authentication_jwtbearer.*
import soulsoft_identity_tokens.*
import soulsoft_extensions_injection.*

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    builder.services.addRouting()

    // 把 Bearer 设为默认认证方案
    builder.services.addAuthentication(JwtBearerDefaults.Scheme)
        .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
            let key = SymmetricSecurityKey("your-256-bit-secret-your-256-bit-secret".toArray())

            // 令牌校验规则
            options.tokenValidationParameters.validateIssuer = true
            options.tokenValidationParameters.validateAudience = true
            options.tokenValidationParameters.validateLifetime = true
            options.tokenValidationParameters.requireExpirationTime = true
            options.tokenValidationParameters.validIssuers = ["spire-demo"]
            options.tokenValidationParameters.validAudiences = ["spire-api"]
            options.tokenValidationParameters.issuerSigningKeys = [key]
        }

    builder.services.addAuthorization()

    let app = builder.build()

    // 推荐顺序:先路由,再认证,再授权
    app.useRouting()
    app.useAuthentication()
    app.useAuthorization()

    // 终结点元数据要求已认证用户
    app.mapGet("/profile") { ctx =>
        ctx.response.write("hello jwt")
    }.requireAuthorization()

    app.run()
    return 0
}

addJwtBearer(...) 注册的不只是一个 Handler。它实际做了三件事:

  • 注册 JwtBearerConfigureOptions,支持从配置树读取方案参数
  • 注册 JwtBearerConfigureAfterOptions,补齐 OIDC 元数据和 configurationManager
  • 把方案名绑定到 JwtBearerHandler

请求时序

执行流程

  1. useAuthentication() 先调用默认认证方案;如果成功,AuthenticationMiddleware 才会写入 context.user
  2. JwtBearerHandler.handleAuthenticate() 先触发 options.events.onMessageReceived。这里可以自己放入 context.token,也可以直接设置 context.result 提前结束。
  3. 如果事件没有给出 token,处理器再读取 Authorization 头,并且只接受 Bearer 前缀;前缀判断不区分大小写。
  4. 取到 token 后,处理器会准备 TokenValidationParameters。如果存在 configurationManager,会把远端配置里的 issuersigningKeys 合并到本地参数副本。
  5. 处理器依次调用 options.securityTokenValidators。默认只有一个 JwtSecurityTokenHandler()
  6. 验证成功后触发 onTokenValidated;如果 saveToken = true,会把当前访问令牌以 access_token 名称存进 AuthenticationProperties
  7. 验证失败后触发 onAuthenticationFailed;事件如果写入了 context.result,就以事件结果为准,否则返回失败结果。
  8. challenge() 负责 401forbid() 负责 403。前者通常表示“还没通过认证”,后者表示“已经认证,但不满足授权策略”。

终结点授权与 JWT 的边界

这个边界最值得注意:

  • useAuthentication() 只尝试解析身份,不会因为缺少 Bearer Token 自动返回 401
  • .requireAuthorization() 才会消费身份,并在需要时触发 challenge()forbid()
  • 没带 Token、用了错误方案、Token 无效,通常都会落到 challenge(),最终返回 401
  • Token 有效,但终结点策略不满足,例如 .requireAuthorization(["AdminOnly"]),会走 forbid(),最终返回 403

所以 JWT Bearer 与授权元数据是协作关系,不是替代关系。

Challenge 与 Forbid

JwtBearerHandler 对这两个出口做了明确区分:

  • handleChallenge() 会先调用 handleAuthenticateOnceSafe(),然后构造 WWW-Authenticate
  • 如果 includeErrorDetails = true 且本次存在认证异常,会自动补上 error="invalid_token"error_description
  • error_description 会过滤 CR/LF,避免非法响应头
  • options.events.onChallenge 可以调用 context.handleResponse(),完全接管默认 401 响应
  • handleForbidden() 只设置 403,然后触发 options.events.onForbidden

这意味着:

  • “未认证”与“已认证但被拒绝”在响应语义上是分开的
  • 你可以改写质询响应,但不用改 Handler 主体

配置方式

代码配置本地验证参数

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        let key = SymmetricSecurityKey("your-256-bit-secret-your-256-bit-secret".toArray())

        // 本地静态验证参数
        options.tokenValidationParameters.validateIssuer = true
        options.tokenValidationParameters.validateAudience = true
        options.tokenValidationParameters.validateLifetime = true
        options.tokenValidationParameters.validIssuers = ["spire-demo"]
        options.tokenValidationParameters.validAudiences = ["spire-api"]
        options.tokenValidationParameters.issuerSigningKeys = [key]
    }

创建令牌终结点

如果你使用本地对称密钥校验,通常还会提供一个发放 JWT 的终结点,方便本地联调。

cangjie
import std.collection.*
import std.time.*
import stdx.encoding.json.*
import soulsoft_identity_claims.*
import soulsoft_identity_tokens.*
import soulsoft_identity_tokens_jwt.*

app.mapPost("/token") { ctx =>
    let key = SymmetricSecurityKey("your-256-bit-secret-your-256-bit-secret".toArray())
    let creds = SigningCredentials(key, SecurityAlgorithms.HmacSha256)
    let claims = ArrayList<Claim>()

    // 令牌里的主体与业务声明
    claims.add(Claim(ClaimTypes.Name, "spire"))
    claims.add(Claim("role", "admin"))

    let descriptor = SecurityTokenDescriptor(creds)
    descriptor.issuer = Some("spire-demo")
    descriptor.audience = Some("spire-api")
    descriptor.issuedAt = Some(DateTime.nowUTC())
    descriptor.expires = Some(DateTime.nowUTC().addSeconds(3600))
    descriptor.claims = claims

    let handler = JwtSecurityTokenHandler()
    let token = handler.createEncodedJwt(descriptor)

    let obj = JsonObject()
    obj.put("access_token", JsonString(token))
    obj.put("token_type", JsonString("Bearer"))
    obj.put("expires_in", JsonInt(3600))

    ctx.response.contentType = "application/json"
    ctx.response.write(obj.toString())
}

配置文件驱动

JwtBearerConfigureOptions 会读取:

text
authentication:schemes:<scheme>

例如:

json
{
  "authentication": {
    "schemes": {
      "Bearer": {
        "challenge": "Bearer",
        "includeErrorDetails": true,
        "saveToken": true,
        "validateIssuer": true,
        "requireAudience": true,
        "validateAudience": true,
        "validateLifetime": true,
        "requireExpirationTime": true,
        "clockSkew": 300,
        "validIssuers": ["spire-demo"],
        "validAudiences": ["spire-api"],
        "signingKeys": [
          {
            "issuer": "spire-demo",
            "value": "Base64EncodedKeyBytes"
          }
        ]
      }
    }
  }
}

这里有两个实现细节需要注意:

  • signingKeys 会按 issuer 去匹配 validIssuers
  • value 会先做 Base64 解码,再构造成 SymmetricSecurityKey

使用 OIDC 元数据

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        // 直接指定 OIDC 元数据地址
        options.metadataAddress = "https://demo.duendesoftware.com/.well-known/openid-configuration"

        // 远端元数据默认要求 HTTPS
        options.requireHttpsMetadata = true
    }

JwtBearerConfigureAfterOptions 的规则是:

  • 如果已经提供了 configurationManager,不再做任何补充
  • 否则如果提供了 configuration,会包装成 StaticConfigurationManager
  • 否则如果提供了 metadataAddressauthority,会创建远端 ConfigurationManager
  • 只提供 authority 时,框架会自动补成 /.well-known/openid-configuration
  • requireHttpsMetadata = true 时,元数据地址必须是 https://

事件与扩展点

这套设计的可扩展点非常干净:令牌来源、验证完成后的处理、失败后的响应,都不需要改 JwtBearerHandler 本体。

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        // 允许从 query 中读取 access_token
        options.events.onMessageReceived = { context =>
            context.token = context.request.query["access_token"]
        }

        // 接管默认 challenge 响应
        options.events.onChallenge = { context =>
            context.handleResponse()
            context.response.statusCode = 401
            context.response.write("invalid or missing bearer token")
        }
    }

如果你需要更换底层验证器,也可以直接替换:

  • options.securityTokenValidators

默认实现是 JwtSecurityTokenHandler(),但架构并没有把验证逻辑写死在 Handler 里。