Skip to content

认证与授权

认证回答"你是谁",授权回答"你能做什么"。两者解耦:认证产出 HttpContext.user,授权消费它。

打个比方:小区门禁。大门刷卡认出"这是 3 栋 602 的业主"——这是认证。进电梯刷卡只能到 6 楼,按不了 20 楼——这是授权。认证中间件是门禁读卡器,填 HttpContext.user;授权中间件是电梯控制器,用 user 和 Endpoint.metadata 判断能不能放行。

请求 → useRouting() → useAuthentication() → useAuthorization() → 终结点
                        ↓                       ↓
                  填 context.user         读 Endpoint.metadata
                                          + context.user → 放行/拦截

认证中间件

app.useAuthentication() 只做一件事:尝试从请求中解析用户身份,填到 context.user 上。它不返回 401——认证失败请求照样往下走。真正返回 401 的是授权中间件。

请求进入


① 有 IAuthenticationRequestHandler?(如 OIDC 登录跳转)
  │   ├── 有,且 handler.handleRequest() 返回 true → return(截断管道)
  │   └── 没有或未截断 → 继续

② 取默认认证方案 → 创建/获取 handler → handler.authenticate()
  │   成功 → context.user = principal
  │   失败 → 什么都不写

③ next(context)

handler 从哪来

AuthenticationHandlerProvider.getHandler(context, scheme) 负责提供 handler 实例:

  1. 先查同请求内缓存——同一个 scheme 只初始化一次
  2. 缓存未命中 → 从 DI 获取(或 ActivatorUtilities.createInstance 动态创建)
  3. handler.initialize(scheme, context),把 scheme 名、当前 context、IOptionsMonitor 注入

initialize() 结束后,handler 拿到了它运行所需的全部上下文:this.schemethis.contextthis.options

handler.authenticate() 做了什么

cangjie
// AuthenticationHandler.cj:100 — 你重写的是 handleAuthenticate()
public func authenticate(): AuthenticateResult {
    // 1. 如果配置了 forwardAuthenticate,转发到目标 scheme(用于多方案委托)
    if (let Some(target) <- resolveTarget(options.forwardAuthenticate)) {
        return context.authenticate(target)
    }
    // 2. 调 handleAuthenticateOnce(),同实例只执行一次(幂等保护)
    let result = handleAuthenticateOnce()
    // 3. 打日志
    if (let Some(failure) <- result.failure) {
        logger.info("${scheme.name} was not authenticated. Failure message: ${failure.message}")
    }
    return result
}

三个关键点:

  • resolveTarget:如果配置了 forwardAuthenticate(如 forwardAuthenticate = "Bearer"),当前 handler 不做实际认证,而是委托给目标 scheme。这用于"一个方案套另一个"的场景。防自循环——目标如果是自己就跳过。
  • handleAuthenticateOnce:同 handler 实例只执行一次逻辑,第二次调用直接返回缓存结果。这避免中间件和授权中间件反复认证同一个 scheme。
  • handleAuthenticate():你的代码在这里。读 Cookie、解析 Token、调外部服务...成功返回 AuthenticateResult.success(ticket),失败返回 AuthenticateResult.fail("原因")AuthenticateResult.noResult()

认证结果如何传递到授权中间件

认证中间件把结果写到了两个地方:

context.user = principal                           ← 授权中间件直接读它
context.features.set<IAuthenticateResultFeature>()  ← 授权中间件的 PolicyEvaluator 读它

授权中间件的 ② 步骤(DefaultPolicyEvaluator.authenticate())会检查 IAuthenticateResultFeature——如果认证中间件已经执行过,它直接用已有结果,不再重复认证。

认证失败不会自动返回 401

认证失败时 middleware 什么都不做——不设 statusCode,不抛异常,不写 response。 next(context) 照常调用。

401 是由后续的授权中间件触发的:它发现用户未认证 + 策略要求认证 → challenge() → 调认证方案的 handleChallenge() → 默认实现是 context.response.statusCode = 401

授权中间件

app.useAuthorization() 的作用:读取 Endpoint.metadatacontext.user,决定放行、challenge()(401)还是 forbid()(403)。

整个流程分五步,下面逐一拆解。

请求进入 AuthorizationMiddleware


① 解析策略:Endpoint.metadata → AuthorizationPolicy

  ├── 没解析出策略(无 [Authorize] 且无 fallbackPolicy)
  │     → 直接 next(),跳过所有授权逻辑

  └── 解析出策略 → 继续


② 执行认证:按策略指定的 scheme 调 context.authenticate()


③ AllowAnonymous?→ next(),授权失败不拦截


④ 执行授权:遍历所有 IAuthorizationHandler 处理 requirements

        ├── pendingRequirements 全部清空 → 放行

        └── 有残留 → 看认证结果
              ├── 未认证 → challenged → ⑤ → 401
              └── 已认证 → forbidden → ⑤ → 403

① 解析策略:从 Endpoint 到 AuthorizationPolicy

AuthorizationMiddleware.invoke() 先调 resolvePolicy(),把端点上的元数据组合成一个 AuthorizationPolicy

关键代码在这条链路上:

cangjie
// AuthorizationMiddleware.cj:113
private static func computePolicy(policyProvider, endpoint): ?AuthorizationPolicy {
    let authorizeData = endpoint.metadata.getOrderedMetadata<IAuthorizeData>()     // ①
    let authorizePolicies = endpoint.metadata.getOrderedMetadata<AuthorizationPolicy>()
    return AuthorizationPolicy.combine(policyProvider, authorizeData, authorizePolicies)  // ②
}

第 ① 步:提取元数据。 requireAuthorization()requireAuthorization(["X"])allowAnonymous() 都会在 endpoint 上写入 IAuthorizeData。这是"端点声明了什么",还不是最终的策略对象。

第 ② 步:组合成策略。 AuthorizationPolicy.combine() 拿元数据去查实际的 AuthorizationPolicy 对象,合并 requirements 和 authenticationSchemes:

IAuthorizeData.policy = "AdminOnly"


policyProvider.getPolicy("AdminOnly")


AuthorizationPolicy {
    requirements: [DenyAnonymousAuthorizationRequirement, ClaimsAuthorizationRequirement("role", ["admin"])],
    authenticationSchemes: []
}

如果 IAuthorizeData.policy 为空(即 requireAuthorization() 没传名字),就用 defaultPolicy

如果端点没有任何 IAuthorizeData,就看 fallbackPolicy 有没有配——配了就全局生效,没配就跳过授权。

总结:元数据 → 策略查找 → 合并,结果是包含 requirements 和 authenticationSchemes 的 AuthorizationPolicy

② 执行认证:按策略指定的 scheme 认证

策略的 authenticationSchemes 字段决定用什么方案认证。比如多方案并存时:

cangjie
// 策略指定用 Bearer 方案
options.addPolicy("ApiOnly") { policy =>
    policy.requireAuthenticatedUser()
    policy.addAuthenticationSchemes(["Bearer"])
}

DefaultPolicyEvaluator.authenticate() 遍历 schemes,逐个调 context.authenticate(scheme),把多个方案的结果合并到 context.user

cangjie
// DefaultPolicyEvaluator.cj:38
for (scheme in policy.authenticationSchemes) {
    let result = context.authenticate(scheme)  // 调该方案的 handler
    if (result.succeeded) {
        newPrincipal = SecurityHelper.mergeUserPrincipal(newPrincipal, result.principal)
    }
}
context.user = newPrincipal  // 最终挂到 context 上

如果策略没有指定 authenticationSchemes(多数情况),就走默认路径:读 IAuthenticateResultFeature(认证中间件写的),或直接用 context.user

无论认证成功还是失败,请求都继续往下走。 不会在这一步返回 401。

③ AllowAnonymous:跳过授权失败处理

AllowAnonymous 端点仍然会走 ① 和 ②——认证照做,context.user 照样填。只是在授权失败时不拦截,始终 next()

④ 执行授权:遍历 handler,清空 pendingRequirements

这是核心环节。DefaultAuthorizationService.authorize() 干三件事:

1. 创建 AuthorizationHandlerContext
   → pendingRequirements = policy.requirements 的副本
   
2. 从 DI 收集所有 IAuthorizationHandler,逐个调 handler.handle(context)

3. IAuthorizationEvaluator 检查 pendingRequirements 是否全部清空

handler 从哪来? DefaultAuthorizationHandlerProvider 从 DI 收集所有 IAuthorizationHandler 注册。包括两类:

来源说明
addAuthorization() 自动注册的PassThroughAuthorizationHandler(处理自处理的 requirement)
手动 addTransient 注册的RiskLevelHandler(分离模式的 handler)

handler 做了什么?DenyAnonymousAuthorizationRequirement 为例:

cangjie
// 继承 AuthorizationHandler<DenyAnonymousAuthorizationRequirement>
// handle() 从 context.requirements 中筛出 DenyAnonymous 类型
// 然后调 handleRequirement()
protected func handleRequirement(context, requirement): Unit {
    if (context.user.identity?.isAuthenticated ?? false) {
        context.succeed(requirement)  // 从 pendingRequirements 中移除
    }
}

每个 handler 处理自己关心的 requirement 类型。满足条件就 context.succeed(requirement)——从 pendingRequirements 中删掉该项。

最终判定: IAuthorizationEvaluator.evaluate(context) 检查:

hasSucceeded = !failCalled && succeedCalled && pendingRequirements.size == 0

所有 requirement 都被 succeed 了 → 放行。有一个没被 succeed → 失败。

⑤ 处理结果:challenge 还是 forbid

DefaultPolicyEvaluator.authorize() 根据授权结果和认证结果做最终裁决:

cangjie
// DefaultPolicyEvaluator.cj:100
if (result.succeeded) {
    return PolicyAuthorizationResult.success()  // → 放行
}
if (authenticationResult.succeeded) {
    return PolicyAuthorizationResult.forbid()   // → 403,已认证但权限不够
} else {
    return PolicyAuthorizationResult.challenge() // → 401,没认证所以不给访问
}

DefaultAuthorizationMiddlewareResultHandler 收到结果后:

  • succeedednext(context)
  • challengedcontext.challenge(scheme) → 触发认证方案的 challenge 逻辑 → 401
  • forbiddencontext.forbid(scheme) → 403

这就是最终 HTTP 状态码的来源。

Endpoint 授权元数据

cangjie
// 默认策略
app.mapGet("/profile") { ctx =>
    ctx.response.write("profile")
}.requireAuthorization()

// 命名策略
app.mapGet("/admin") { ctx =>
    ctx.response.write("admin")
}.requireAuthorization(["AdminOnly"])

// 显式匿名
app.mapGet("/ping") { ctx =>
    ctx.response.write("pong")
}.allowAnonymous()

策略选择规则

端点状态行为
无授权元数据 + 无 fallbackPolicy直接放行
无授权元数据 + 有 fallbackPolicy使用 fallbackPolicy
requireAuthorization()使用 defaultPolicy
requireAuthorization(["X"])按名称取策略
allowAnonymous()认证照做,跳过授权失败处理

defaultPolicy 的默认值requireAuthenticatedUser()——只要登录了就能访问。fallbackPolicy 默认为 None——不设时无授权元数据的端点直接放行。

修改默认值和全局回退策略:

cangjie
builder.services.addAuthorization() { options =>
    // 修改默认策略:除了登录,还要求 role = "user"
    options.setDefaultPolicy { policy =>
        policy.requireAuthenticatedUser()
        policy.requireClaim("role", "user")
    }

    // 设置全局回退:所有没有 [Authorize] 的端点都要登录
    options.setFallbackPolicy { policy =>
        policy.requireAuthenticatedUser()
    }
    // 设为 None 关闭回退策略(恢复默认行为)
    // options.setFallbackPolicy(None)
}

添加策略

cangjie
builder.services.addAuthorization() { options =>
    // 最基本:只要登录了就能访问
    options.addPolicy("Authenticated") { policy =>
        policy.requireAuthenticatedUser()
    }
    // 基于角色声明
    options.addPolicy("AdminOnly") { policy =>
        policy.requireClaim("role", ["admin"])
    }
    // 自定义声明校验
    options.addPolicy("AtLeast18") { policy =>
        policy.requireClaim("age") { age => age.toInt64() >= 18 }
    }
}

JWT Bearer 认证

JWT Bearer 负责把 Authorization: Bearer <token> 解析成 ClaimsPrincipal

完整示例

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())

    // 注册认证方案(校验密钥必须和签发密钥一致)
    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()

    // 创建令牌(用相同的 signingKey 签发,校验才能通过)
    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)
    }

    app.mapGet("/public") { ctx =>
        ctx.response.write("public")
    }.allowAnonymous()

    app.mapGet("/admin") { ctx =>
        ctx.response.write("admin")
    }.requireAuthorization(["AdminOnly"])

    app.run()
    return 0
}

配置文件驱动

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"
          }
        ]
      }
    }
  }
}

OIDC 元数据

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        options.metadataAddress = "https://demo.duendesoftware.com/.well-known/openid-configuration"
        options.requireHttpsMetadata = true
    }

事件扩展

addJwtBearer 的配置回调中通过 options.events 挂事件:

cangjie
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        // ... 其他配置 ...

        // 自定义 Token 来源:默认从 Authorization header 取,这里改成从 query string 取
        options.events.onMessageReceived = { context =>
            context.token = context.request.query["access_token"]
        }
        // 自定义 401 响应内容
        options.events.onChallenge = { context =>
            context.handleResponse()
            context.response.statusCode = 401
            context.response.write("invalid or missing bearer token")
        }
    }

Cookie 方案适合浏览器登录态。票据保护后写入 Cookie,请求时还原。

与 Bearer 的核心区别:challenge() 默认跳登录页(302),forbid() 默认跳拒绝页——只有 Ajax 请求才返回 401/403

完整示例

cangjie
import std.time.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_identity_claims.*
import soulsoft_web_authorization.*
import soulsoft_web_authentication.*
import soulsoft_extensions_injection.*
import soulsoft_web_authentication_cookies.*

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

    builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
        .addCookie(CookieAuthenticationDefaults.Scheme) { options =>
            options.loginPath = PathString("/login")
            options.logoutPath = PathString("/logout")
            options.accessDeniedPath = PathString("/denied")
            options.expireTimeSpan = Duration.day * 7
            options.slidingExpiration = true
            options.cookie.name = ".spire.auth"
            options.cookie.httpOnly = true
        }

    builder.services.addAuthorization()

    let app = builder.build()

    app.useRouting()
    app.useAuthentication()
    app.useAuthorization()

    app.mapPost("/login") { ctx =>
        let identity = ClaimsIdentity(Some(CookieAuthenticationDefaults.Scheme))
        identity.addClaim(ClaimTypes.Name, "spire")
        identity.addClaim("role", "admin")
        let principal = ClaimsPrincipal([identity])
        let properties = AuthenticationProperties()
        properties.isPersistent = true
        properties.redirectUri = "/profile"
        ctx.signIn(principal, properties)
    }

    app.mapPost("/logout") { ctx =>
        let properties = AuthenticationProperties()
        properties.redirectUri = "/"
        ctx.signOut(properties)
    }

    app.mapGet("/profile") { ctx =>
        let name = ctx.user.findFirstValue(ClaimTypes.Name) ?? "anonymous"
        ctx.response.write("hello ${name}")
    }.requireAuthorization()

    app.run()
    return 0
}

服务端会话存储

完整票据存服务端,Cookie 只保留会话键:

cangjie
public class MemoryTicketStore <: ITicketStore {
    private let tickets = HashMap<String, AuthenticationTicket>()

    public func store(ticket: AuthenticationTicket): String {
        let key = "sid-" + DateTime.now().toString()
        tickets[key] = ticket
        return key
    }
    public func renew(key: String, ticket: AuthenticationTicket): Unit {
        tickets[key] = ticket
    }
    public func retrieve(key: String): ?AuthenticationTicket {
        tickets[key]
    }
    public func remove(key: String): ?AuthenticationTicket {
        let value = tickets[key]
        tickets.remove(key)
        return value
    }
}

// 注册
options.sessionStore = MemoryTicketStore()

事件扩展

addCookie 的配置回调中通过 options.events 挂事件:

cangjie
builder.services.addAuthentication(CookieAuthenticationDefaults.Scheme)
    .addCookie(CookieAuthenticationDefaults.Scheme) { options =>
        // ... 其他配置 ...

        // 自定义主体验证:role = "disabled" 的用户禁止登录
        options.events.onValidatePrincipal = { context =>
            let role = context.principal.flatMap { p => p.findFirstValue("role") } ?? "guest"
            if (role == "disabled") {
                context.rejectPrincipal()
            }
        }
        // 把默认的 302 跳转改成直接返回 401(适合 API 场景)
        options.events.onRedirectToLogin = { context =>
            context.response.statusCode = StatusCodes.Unauthorized
            context.response.write("please login first")
        }
    }

自定义认证方案

自定义方案只需三步:定义 Options → 继承 AuthenticationHandler<TOptions> → 重写 handleAuthenticate()

Basic 认证示例

Options、Handler、Builder 扩展三步走,和 JWT Bearer 同款风格。

cangjie
import soulsoft_web_http.*
import soulsoft_web_authentication.*
import soulsoft_identity_claims.*
import soulsoft_extensions_options.*
import soulsoft_extensions_logging.*
import stdx.encoding.base64.*

public class BasicAuthenticationOptions <: AuthenticationSchemeOptions {
    public var realm: String = "spire"
    public var username: String = "admin"
    public var password: String = "123456"
}

public class BasicAuthenticationHandler <: AuthenticationHandler<BasicAuthenticationOptions> {

    public init(options: IOptionsMonitor<BasicAuthenticationOptions>, logFactory: ILoggerFactory) {
        super(options, logFactory)
    }

    public func handleAuthenticate(): AuthenticateResult {
        let header = this.context.request.headers.authorization.first ?? String.empty
        if (header.isEmpty() || !header.startsWith("Basic ")) {
            return AuthenticateResult.noResult()
        }
        let credential = header[6..]
        if (let Some(bytes) <- fromBase64String(credential)) {
            unsafe {
                let pairStr = String.withRawData(bytes)
                let pair = pairStr.split(":")
                if (pair.size == 2 && pair[0] == this.options.username && pair[1] == this.options.password) {
                    let identity = ClaimsIdentity(Some(this.scheme.name))
                    identity.addClaim(ClaimTypes.Name, pair[0])
                    let principal = ClaimsPrincipal([identity])
                    let ticket = AuthenticationTicket(principal, this.scheme.name)
                    return AuthenticateResult.success(ticket)
                }
            }
        }
        return AuthenticateResult.fail("Invalid credentials")
    }
}

// 扩展 AuthenticationBuilder,提供 addBasic() 入口
extend AuthenticationBuilder {
    public func addBasic(authenticateScheme: String,
        configureOptions: (BasicAuthenticationOptions) -> Unit): AuthenticationBuilder {
        this.addScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>(
            authenticateScheme, None, configureOptions)
    }
}

注册:

cangjie
builder.services.addAuthentication("Basic")
    .addBasic("Basic") { options =>
        options.username = "admin"
        options.password = "123456"
    }

自定义授权要求

当内置的 requireClaimrequireAuthenticatedUser 不够用时——比如需要判断当前时间、查询数据库、调用外部服务——可以实现自定义授权要求。

有两种实现方式:处理逻辑不需要外部依赖时用自处理模式(一个类全搞定);需要注入 ILoggerFactory、数据库等依赖时用分离模式(Requirement 和 Handler 拆成两个类)。

自处理模式

Requirement 同时实现 IAuthorizationRequirementAuthorizationHandler<Self>PassThroughAuthorizationHandler 自动发现并执行,无需手动注册 handler。

cangjie
import std.time.*
import soulsoft_web_authorization.*

// 仅工作时间(9:00-18:00)可访问
public class WorkingHoursRequirement <: AuthorizationHandler<WorkingHoursRequirement> & IAuthorizationRequirement {
    private let startHour: Int64
    private let endHour: Int64

    public init(startHour: Int64, endHour: Int64) {
        this.startHour = startHour
        this.endHour = endHour
    }

    protected func handleRequirement(context: AuthorizationHandlerContext, requirement: WorkingHoursRequirement): Unit {
        let hour = DateTime.now().hour
        if (hour >= requirement.startHour && hour < requirement.endHour) {
            context.succeed(requirement)
        }
    }
}

注册:policy.requirements.add() 传入实例即可。

cangjie
builder.services.addAuthorization() { options =>
    options.addPolicy("WorkingHoursOnly") { policy =>
        policy.requireAuthenticatedUser()
        policy.requirements.add(WorkingHoursRequirement(9, 18))
    }
}

app.mapGet("/admin") { ctx =>
    ctx.response.write("admin")
}.requireAuthorization(["WorkingHoursOnly"])

运行结果:

条件结果
已认证 + 9-18 之间succeed() → 放行
已认证 + 非工作时间不调用 succeed → forbid() → 403
未认证requireAuthenticatedUser 未满足 → challenge() → 401

分离模式

当 handler 需要注入 ILoggerFactory、数据库、HTTP 客户端等依赖时,必须把 Requirement 和 Handler 拆开——自处理模式无法利用 DI。

Requirement:纯数据,仅实现 IAuthorizationRequirement

Handler:继承 AuthorizationHandler<TRequirement>,通过构造函数注入所需依赖。

cangjie
import soulsoft_web_authorization.*
import soulsoft_extensions_logging.*

// Requirement:纯数据
public class RiskLevelRequirement <: IAuthorizationRequirement {
    public let minimumLevel: Int64

    public init(minimumLevel: Int64) {
        this.minimumLevel = minimumLevel
    }
}

// Handler:注入 ILoggerFactory,创建 logger 记录审计日志
public class RiskLevelHandler <: AuthorizationHandler<RiskLevelRequirement> {
    private let _logger: ILogger

    public init(loggerFactory: ILoggerFactory) {
        _logger = loggerFactory.createLogger<RiskLevelHandler>()
    }

    protected func handleRequirement(context: AuthorizationHandlerContext, requirement: RiskLevelRequirement): Unit {
        let raw = context.user.findFirstValue("riskLevel") ?? "0"
        let level = Int64.parse(raw)
        if (level >= requirement.minimumLevel) {
            _logger.debug("risk level check passed: ${level} >= ${requirement.minimumLevel}")
            context.succeed(requirement)
        } else {
            _logger.warn("risk level too low: ${level} < ${requirement.minimumLevel}")
            context.fail()
        }
    }
}

Requirement 加进策略,Handler 注册到 DI——两者分开:

cangjie
builder.services.addAuthorization() { options =>
    options.addPolicy("RiskLevel3") { policy =>
        policy.requireAuthenticatedUser()
        policy.requirements.add(RiskLevelRequirement(3))
    }
}
// Handler 必须手动注册,DefaultAuthorizationHandlerProvider 从 DI 收集所有 IAuthorizationHandler
builder.services.addTransient<IAuthorizationHandler, RiskLevelHandler>()

两种模式对比

自处理分离
类数量1 个2 个(Requirement + Handler)
Requirement 继承IAuthorizationRequirement + AuthorizationHandler<Self>IAuthorizationRequirement
Handler 继承无(Requirement 就是 Handler)AuthorizationHandler<TRequirement>
DI 注册 Handler无需(PassThroughAuthorizationHandler 自动发现)必须 addTransient<IAuthorizationHandler, THandler>()
Handler 可注入依赖不行可以(ILoggerFactory、数据库、HTTP 客户端等)
适用场景逻辑简单、独立判断需要外部服务、复杂逻辑、日志记录

常见问题

API(移动端、SPA)用 JWT Bearer;浏览器登录态用 Cookie。

如果用在 API 场景,通过 onRedirectToLogin 事件改写成直接返回 401

是否支持多方案并存?

支持。注册多个认证方案,不同终结点用不同方案校验:

cangjie
// 同时注册 JWT 和 Basic
builder.services.addAuthentication(JwtBearerDefaults.Scheme)
    .addJwtBearer(JwtBearerDefaults.Scheme) { options =>
        options.tokenValidationParameters.issuerSigningKeys = [signingKey]
    }
    .addBasic("Basic") { options =>
        options.username = "admin"
        options.password = "123456"
    }

// 默认方案是 JwtBearer,Basic 方案需显式指定
app.mapGet("/api/data") { ctx =>
    ctx.response.write("jwt 校验通过")
}.requireAuthorization()                              // 走默认 JwtBearer

app.mapGet("/api/admin") { ctx =>
    ctx.response.write("basic 校验通过")
}.requireAuthorization(["Basic"])                     // 指定走 Basic 方案

每个方案的 handler 互不干扰,useAuthentication() 按默认方案执行认证,授权时可按终结点指定不同策略。同一个请求也可以携带多种凭证——比如 Cookie 做登录态、Bearer 做 API 鉴权。