授权中间件
授权解决的是“这个身份能不能访问当前资源”。
它依赖认证,但不等于认证:
- 认证负责把用户身份放进
HttpContext.user - 授权负责读取
Endpoint元数据、策略和当前用户,再决定放行、challenge()或forbid()
这一层真正有价值的地方,是它把规则拆成了:
AuthorizationPolicyIAuthorizationRequirementIAuthorizationHandler
所以你既可以直接用角色、声明这些现成规则,也可以接入自己的业务约束。
最小示例
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
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
这个顺序不能反:
useRouting()先把Endpoint放到HttpContextuseAuthentication()先把身份放到HttpContext.useruseAuthorization()再根据Endpoint.metadata和user做判断
Endpoint 与授权元数据
授权中间件不是自己解析路由模板,它只消费前面路由阶段已经选中的 Endpoint。
真正影响授权行为的是这个 Endpoint 上的元数据:
IAuthorizeDataAuthorizationPolicyIAuthorizationRequirementDataIAllowAnonymous
最常见的挂载方式有四种:
// 使用默认策略
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()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这里的关键不是 DSL 本身,而是它们最后都会变成 Endpoint.metadata。
Authorize 和 AllowAnonymous 本身也是注解类型,所以在支持注解元数据的场景里,也可以直接标注:
@Authorize(policy: "AdminOnly")
func adminAction(ctx: HttpContext) {
ctx.response.write("admin")
}
@AllowAnonymous()
func ping(ctx: HttpContext) {
ctx.response.write("pong")
}2
3
4
5
6
7
8
9
时序图
执行流程
AuthorizationMiddleware先读取context.getEndpoint()。- 它会从
Endpoint.metadata里收集IAuthorizeData、AuthorizationPolicy和IAuthorizationRequirementData,然后组合成最终策略。 - 如果策略提供器允许缓存,计算结果会按
Endpoint缓存。 IPolicyEvaluator.authenticate(...)先根据策略里的认证方案执行认证;如果策略没指定方案,就复用已有认证结果或当前context.user。- 即使端点带了
allowAnonymous(),授权中间件也会先做认证,把user和认证特征补齐,然后才跳过授权失败处理。 IPolicyEvaluator.authorize(...)再调用IAuthorizationService执行需求判断。- 成功时直接放行;失败时如果本次请求已通过认证,返回
forbid,否则返回challenge。 IAuthorizationMiddlewareResultHandler最终把结果落成具体响应:调用context.challenge(...)或context.forbid(...)。
策略如何选
这部分最容易写错。真实规则是:
- 端点上没有任何授权元数据时,使用
fallbackPolicy - 如果
fallbackPolicy也是空,授权中间件直接放行 - 端点上只要出现了
IAuthorizeData,就会进入授权流程 requireAuthorization()挂的是一个空的Authorize(),这时会使用defaultPolicyrequireAuthorization(["AdminOnly"])会按名称从IAuthorizationPolicyProvider取策略requireAuthorization(policy)会直接把AuthorizationPolicy放进元数据,不再依赖命名查找allowAnonymous()不会撤销认证,但会跳过授权失败处理
可以把它理解成下面这个优先级:
- 显式挂到 Endpoint 的
AuthorizationPolicy IAuthorizeData中引用的命名策略、角色、认证方案IAuthorizationRequirementData追加的需求- 没有明确策略对象时,
defaultPolicy - 完全没有授权元数据时,
fallbackPolicy
默认策略与回退策略
AuthorizationOptions 启动时自带一个默认策略:
requireAuthenticatedUser()也就是只要你写了:
app.mapGet("/protected") { ctx =>
ctx.response.write("protected")
}.requireAuthorization()2
3
没有额外配置的情况下,它就已经表达了“必须是已认证用户”。
而 fallbackPolicy 是另一回事。它只在“端点完全没有授权元数据”时生效:
builder.services.addAuthorization() { options =>
// 所有没显式声明授权规则的端点,都要求已认证
options.fallbackPolicy = AuthorizationPolicyBuilder()
.requireAuthenticatedUser()
.build()
}2
3
4
5
6
这时如果某个端点必须匿名,就需要显式写:
app.mapGet("/health") { ctx =>
ctx.response.write("OK")
}.allowAnonymous()2
3
Requirement / Handler 模型
授权并不是“if role == admin”这种写死判断。
库的真正核心是:
IAuthorizationRequirement描述规则IAuthorizationHandler负责判断规则是否满足AuthorizationHandlerContext维护待完成需求、失败原因和资源对象
内置需求已经覆盖了最常见场景:
DenyAnonymousAuthorizationRequirementRolesAuthorizationRequirementClaimsAuthorizationRequirementNameAuthorizationRequirementAssertionRequirement
例如:
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
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这里有一个重要语义:
- 同一个策略里的多个 requirement 是 AND 关系
requireRole(["admin", "moderator"])这种单个角色需求内部才是 OR 关系
自定义 Requirement 与 Handler
如果内置需求不够,就自己加一个 Requirement 和对应 Handler。
下面这个例子要求当前用户只能访问自己的资料页:
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)
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
注册:
builder.services.addAuthorization() { options =>
options.addPolicy("OwnerOnly") { policy =>
policy.addRequirements([OwnerRequirement()])
}
}
builder.services.addTransient<IAuthorizationHandler, OwnerHandler>()2
3
4
5
6
7
使用:
app.mapGet("/users/{userId}") { ctx =>
ctx.response.write("my profile")
}.requireAuthorization(["OwnerOnly"])2
3
这个例子能说明两件事:
- 默认情况下,授权资源是
HttpContext - 如果你的判断依赖路由值、请求头、查询参数,这种资源处理器写法会非常自然
资源对象是什么
AuthorizationMiddleware 默认把 HttpContext 当作授权资源传给 IAuthorizationService。
也就是说,在自定义 AuthorizationResourceHandler<TRequirement, TResource> 里,最常见的 TResource 就是 HttpContext。
只有当你把:
options.suppressUseHttpContextAsAuthorizationResource = true设为 true 时,中间件才会把 Endpoint 本身作为资源对象传进去。
这个开关主要影响“你的自定义 Handler 看见的 resource 是谁”,不会改变策略选择规则。
Challenge 与 Forbid
授权中间件本身不直接拼响应,它只给出三类结果:
successchallengeforbid
DefaultPolicyEvaluator 的判断标准非常直接:
- 授权成功:
success - 授权失败,但认证已成功:
forbid - 授权失败,而且认证未成功:
challenge
随后 DefaultAuthorizationMiddlewareResultHandler 会:
- 如果策略声明了认证方案,对每个方案分别调用
context.challenge(scheme)或context.forbid(scheme) - 否则调用默认方案的
challenge()/forbid()
所以最终返回 401 还是登录跳转,返回 403 还是拒绝访问跳转,其实取决于底层认证方案,而不是授权中间件自己写死。
终结点约定
除了直接在端点上写,你也可以在组上统一挂授权约定:
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")
}2
3
4
5
6
7
8
9
10
11
组约定的价值在于:
- 共享前缀
- 共享授权规则
- 新增端点时自动继承
如果某个组默认要求认证,但少数端点要开放匿名,也可以在具体端点上补 allowAnonymous()。
几个实用结论
1. useAuthorization() 必须放在 useRouting() 之后
因为它要消费 Endpoint。
2. useAuthorization() 通常也要放在 useAuthentication() 之后
否则授权时拿不到已经填充好的 context.user。
3. allowAnonymous() 不是“跳过整个认证授权链”
它只跳过授权失败处理。认证仍然会执行,因此下游仍然能读到 context.user。
4. requireAuthorization() 走的是默认策略
而不是“没有策略”。默认策略在这个库里本来就存在,默认含义就是“要求已认证用户”。
5. fallbackPolicy 会影响所有未标注端点
一旦你打开它,就要显式给匿名端点标 allowAnonymous(),否则健康检查、登录发 Token 这类接口也会被拦住。