Skip to content

授权中间件

授权解决的是“这个身份能不能访问当前资源”。

它依赖认证,但不等于认证:

  • 认证负责把用户身份放进 HttpContext.user
  • 授权负责读取 Endpoint 元数据、策略和当前用户,再决定放行、challenge()forbid()

这一层真正有价值的地方,是它把规则拆成了:

  • AuthorizationPolicy
  • IAuthorizationRequirement
  • IAuthorizationHandler

所以你既可以直接用角色、声明这些现成规则,也可以接入自己的业务约束。

最小示例

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

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

    builder.services.addAuthorization() { options =>
        options.addPolicy("AdminOnly") { policy =>
            policy.requireClaim("role", ["admin"])
        }
    }

    let app = builder.build()

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

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

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

    app.run()
    return 0
}

这个顺序不能反:

  1. useRouting() 先把 Endpoint 放到 HttpContext
  2. useAuthentication() 先把身份放到 HttpContext.user
  3. useAuthorization() 再根据 Endpoint.metadatauser 做判断

Endpoint 与授权元数据

授权中间件不是自己解析路由模板,它只消费前面路由阶段已经选中的 Endpoint

真正影响授权行为的是这个 Endpoint 上的元数据:

  • IAuthorizeData
  • AuthorizationPolicy
  • IAuthorizationRequirementData
  • IAllowAnonymous

最常见的挂载方式有四种:

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

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

// 直接挂一个策略对象
let policy = AuthorizationPolicyBuilder()
    .requireClaim("role", ["admin"])
    .build()

app.mapGet("/audit") { ctx =>
    ctx.response.write("audit")
}.requireAuthorization(policy)

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

这里的关键不是 DSL 本身,而是它们最后都会变成 Endpoint.metadata

AuthorizeAllowAnonymous 本身也是注解类型,所以在支持注解元数据的场景里,也可以直接标注:

cangjie
@Authorize(policy: "AdminOnly")
func adminAction(ctx: HttpContext) {
    ctx.response.write("admin")
}

@AllowAnonymous()
func ping(ctx: HttpContext) {
    ctx.response.write("pong")
}

时序图

执行流程

  1. AuthorizationMiddleware 先读取 context.getEndpoint()
  2. 它会从 Endpoint.metadata 里收集 IAuthorizeDataAuthorizationPolicyIAuthorizationRequirementData,然后组合成最终策略。
  3. 如果策略提供器允许缓存,计算结果会按 Endpoint 缓存。
  4. IPolicyEvaluator.authenticate(...) 先根据策略里的认证方案执行认证;如果策略没指定方案,就复用已有认证结果或当前 context.user
  5. 即使端点带了 allowAnonymous(),授权中间件也会先做认证,把 user 和认证特征补齐,然后才跳过授权失败处理。
  6. IPolicyEvaluator.authorize(...) 再调用 IAuthorizationService 执行需求判断。
  7. 成功时直接放行;失败时如果本次请求已通过认证,返回 forbid,否则返回 challenge
  8. IAuthorizationMiddlewareResultHandler 最终把结果落成具体响应:调用 context.challenge(...)context.forbid(...)

策略如何选

这部分最容易写错。真实规则是:

  • 端点上没有任何授权元数据时,使用 fallbackPolicy
  • 如果 fallbackPolicy 也是空,授权中间件直接放行
  • 端点上只要出现了 IAuthorizeData,就会进入授权流程
  • requireAuthorization() 挂的是一个空的 Authorize(),这时会使用 defaultPolicy
  • requireAuthorization(["AdminOnly"]) 会按名称从 IAuthorizationPolicyProvider 取策略
  • requireAuthorization(policy) 会直接把 AuthorizationPolicy 放进元数据,不再依赖命名查找
  • allowAnonymous() 不会撤销认证,但会跳过授权失败处理

可以把它理解成下面这个优先级:

  1. 显式挂到 Endpoint 的 AuthorizationPolicy
  2. IAuthorizeData 中引用的命名策略、角色、认证方案
  3. IAuthorizationRequirementData 追加的需求
  4. 没有明确策略对象时,defaultPolicy
  5. 完全没有授权元数据时,fallbackPolicy

默认策略与回退策略

AuthorizationOptions 启动时自带一个默认策略:

text
requireAuthenticatedUser()

也就是只要你写了:

cangjie
app.mapGet("/protected") { ctx =>
    ctx.response.write("protected")
}.requireAuthorization()

没有额外配置的情况下,它就已经表达了“必须是已认证用户”。

fallbackPolicy 是另一回事。它只在“端点完全没有授权元数据”时生效:

cangjie
builder.services.addAuthorization() { options =>
    // 所有没显式声明授权规则的端点,都要求已认证
    options.fallbackPolicy = AuthorizationPolicyBuilder()
        .requireAuthenticatedUser()
        .build()
}

这时如果某个端点必须匿名,就需要显式写:

cangjie
app.mapGet("/health") { ctx =>
    ctx.response.write("OK")
}.allowAnonymous()

Requirement / Handler 模型

授权并不是“if role == admin”这种写死判断。

库的真正核心是:

  • IAuthorizationRequirement 描述规则
  • IAuthorizationHandler 负责判断规则是否满足
  • AuthorizationHandlerContext 维护待完成需求、失败原因和资源对象

内置需求已经覆盖了最常见场景:

  • DenyAnonymousAuthorizationRequirement
  • RolesAuthorizationRequirement
  • ClaimsAuthorizationRequirement
  • NameAuthorizationRequirement
  • AssertionRequirement

例如:

cangjie
builder.services.addAuthorization() { options =>
    options.addPolicy("AdminOnly") { policy =>
        policy.requireRole(["admin"])
    }

    options.addPolicy("VerifiedUser") { policy =>
        policy.requireClaim("email_verified", ["true"])
    }

    options.addPolicy("SeniorOnly") { policy =>
        policy.requireAssertion { ctx =>
            let isAdmin = ctx.user.hasClaim("role", "admin")
            let isSenior = ctx.user.hasClaim("seniority", "senior")
            isAdmin && isSenior
        }
    }
}

这里有一个重要语义:

  • 同一个策略里的多个 requirement 是 AND 关系
  • requireRole(["admin", "moderator"]) 这种单个角色需求内部才是 OR 关系

自定义 Requirement 与 Handler

如果内置需求不够,就自己加一个 Requirement 和对应 Handler。

下面这个例子要求当前用户只能访问自己的资料页:

cangjie
import soulsoft_web_http.*
import soulsoft_web_authorization.*
import soulsoft_identity_claims.*

public class OwnerRequirement <: IAuthorizationRequirement {
}

public class OwnerHandler <: AuthorizationResourceHandler<OwnerRequirement, HttpContext> {
    protected override func handleRequirement(
        context: AuthorizationHandlerContext,
        requirement: OwnerRequirement,
        resource: HttpContext
    ): Unit {
        let routeUserId = resource.request.routeValues.get("userId") ?? String.empty
        let currentUserId = context.user.findFirstValue(ClaimTypes.UserId) ?? String.empty

        if (!routeUserId.isEmpty() && routeUserId == currentUserId) {
            context.succeed(requirement)
        }
    }
}

注册:

cangjie
builder.services.addAuthorization() { options =>
    options.addPolicy("OwnerOnly") { policy =>
        policy.addRequirements([OwnerRequirement()])
    }
}

builder.services.addTransient<IAuthorizationHandler, OwnerHandler>()

使用:

cangjie
app.mapGet("/users/{userId}") { ctx =>
    ctx.response.write("my profile")
}.requireAuthorization(["OwnerOnly"])

这个例子能说明两件事:

  • 默认情况下,授权资源是 HttpContext
  • 如果你的判断依赖路由值、请求头、查询参数,这种资源处理器写法会非常自然

资源对象是什么

AuthorizationMiddleware 默认把 HttpContext 当作授权资源传给 IAuthorizationService

也就是说,在自定义 AuthorizationResourceHandler<TRequirement, TResource> 里,最常见的 TResource 就是 HttpContext

只有当你把:

cangjie
options.suppressUseHttpContextAsAuthorizationResource = true

设为 true 时,中间件才会把 Endpoint 本身作为资源对象传进去。

这个开关主要影响“你的自定义 Handler 看见的 resource 是谁”,不会改变策略选择规则。

Challenge 与 Forbid

授权中间件本身不直接拼响应,它只给出三类结果:

  • success
  • challenge
  • forbid

DefaultPolicyEvaluator 的判断标准非常直接:

  • 授权成功:success
  • 授权失败,但认证已成功:forbid
  • 授权失败,而且认证未成功:challenge

随后 DefaultAuthorizationMiddlewareResultHandler 会:

  • 如果策略声明了认证方案,对每个方案分别调用 context.challenge(scheme)context.forbid(scheme)
  • 否则调用默认方案的 challenge() / forbid()

所以最终返回 401 还是登录跳转,返回 403 还是拒绝访问跳转,其实取决于底层认证方案,而不是授权中间件自己写死。

终结点约定

除了直接在端点上写,你也可以在组上统一挂授权约定:

cangjie
let admin = app.mapGroup("/admin")

admin.requireAuthorization(["AdminOnly"])

admin.mapGet("/users") { ctx =>
    ctx.response.write("users")
}

admin.mapPost("/users") { ctx =>
    ctx.response.write("create user")
}

组约定的价值在于:

  • 共享前缀
  • 共享授权规则
  • 新增端点时自动继承

如果某个组默认要求认证,但少数端点要开放匿名,也可以在具体端点上补 allowAnonymous()

几个实用结论

1. useAuthorization() 必须放在 useRouting() 之后

因为它要消费 Endpoint

2. useAuthorization() 通常也要放在 useAuthentication() 之后

否则授权时拿不到已经填充好的 context.user

3. allowAnonymous() 不是“跳过整个认证授权链”

它只跳过授权失败处理。认证仍然会执行,因此下游仍然能读到 context.user

4. requireAuthorization() 走的是默认策略

而不是“没有策略”。默认策略在这个库里本来就存在,默认含义就是“要求已认证用户”。

5. fallbackPolicy 会影响所有未标注端点

一旦你打开它,就要显式给匿名端点标 allowAnonymous(),否则健康检查、登录发 Token 这类接口也会被拦住。