Rust 微服务里的认证授权,别把 JWT 当万能钥匙
用户带着一个 token 进来,服务能解析出 user_id,于是接口就放行了。很多越权问题就是从这里开始的。
认证和授权经常被混在一起。
认证回答:你是谁?
授权回答:你能做什么?
JWT 只能帮助服务携带身份和部分声明,它不能替业务判断“这个用户能不能改这张订单、能不能看这个租户、能不能执行这个管理员操作”。
认证是入口,授权才是边界。
本文代码环境:
# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }Claims 只能代表令牌签发时的事实
JWT 里通常会放一些 claims:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Claims {
sub: String,
tenant_id: u64,
roles: Vec<String>,
exp: u64,
}这能说明 token 签发时,用户属于哪个租户、有哪些角色、什么时候过期。
但它不自动说明:
- 用户现在是否被禁用
- 角色是否刚被收回
- 资源是否属于这个租户
- 当前操作是否需要二次确认
所以我不喜欢在业务代码里写“JWT 解析成功等于可以操作”。
Claims 是授权输入,不是授权结论。
user_id 不是权限
很多接口会这样写:
#[derive(Debug)]
struct AuthUser {
user_id: u64,
tenant_id: u64,
roles: Vec<String>,
}
fn is_logged_in(user: &AuthUser) -> bool {
user.user_id > 0
}登录状态只能证明“请求来自某个用户”。它不能证明这个用户可以访问目标资源。
比如订单详情接口,至少要判断订单归属:
#[derive(Debug)]
struct OrderResource {
owner_id: u64,
tenant_id: u64,
}
fn can_read_order(user: &AuthUser, order: &OrderResource) -> bool {
user.tenant_id == order.tenant_id && user.user_id == order.owner_id
}这段逻辑看起来简单,但很多越权漏洞就漏在这里。
如果是后台管理员,还要引入角色或权限点,而不是绕过资源边界。
RBAC 适合组织权限,ABAC 适合资源判断
RBAC 基于角色:
fn has_role(user: &AuthUser, role: &str) -> bool {
user.roles.iter().any(|item| item == role)
}它适合表达“管理员、运营、财务、客服”这类组织分工。
但很多权限不是角色能独立决定的。比如客服可以看订单,但只能看自己租户的订单;用户可以改资料,但只能改自己的资料。
这就需要资源属性参与判断:
fn can_refund_order(user: &AuthUser, order: &OrderResource) -> bool {
user.tenant_id == order.tenant_id && has_role(user, "finance")
}这个判断同时用了角色和资源属性。
角色决定“有没有这类能力”,资源属性决定“能不能作用到这个对象”。
只做 RBAC,很容易出现“有角色就能看全站数据”的问题。
权限判断要集中,不要散在 handler 里
权限代码如果散在 handler 里,过一段时间会变成这样:
接口 A 判断 role = admin
接口 B 判断 role = owner
接口 C 忘了判断 tenant_id
接口 D 复制了旧逻辑更好的做法是给每类操作一个明确函数:
#[derive(Debug, PartialEq, Eq)]
enum Action {
ReadOrder,
RefundOrder,
}
fn authorize(user: &AuthUser, action: Action, order: &OrderResource) -> bool {
match action {
Action::ReadOrder => can_read_order(user, order),
Action::RefundOrder => can_refund_order(user, order),
}
}这不是为了抽象而抽象。集中以后,测试、审计、改规则都会容易很多。
权限逻辑应该像业务规则一样被测试,而不是藏在路由函数里。
多租户系统最怕漏 tenant_id
多租户服务里,几乎所有查询都应该带租户条件:
select *
from orders
where tenant_id = $1
and id = $2;少了 tenant_id,就可能出现跨租户访问。
Rust 类型可以帮助减少漏传:
#[derive(Debug, Clone, Copy)]
struct TenantId(u64);
#[derive(Debug, Clone, Copy)]
struct OrderId(u64);
struct TenantScopedOrder {
tenant_id: TenantId,
order_id: OrderId,
}把租户作用域放进类型,不会自动解决所有问题,但它能让函数签名更诚实:
fn order_key(scope: TenantScopedOrder) -> String {
format!("tenant:{}:order:{}", scope.tenant_id.0, scope.order_id.0)
}我个人很看重这种小类型。它让“这个查询必须带租户”从口头约定变成代码结构。
Token 过期和权限变更不是同一个问题
短 token 可以降低风险,但不能完全解决权限撤销。
用户角色被收回后,如果 token 还有 30 分钟过期,这 30 分钟内 claims 里仍然有旧角色。
常见处理方式:
| 场景 | 策略 |
|---|---|
| 普通用户会话 | 短 access token + refresh token |
| 高风险权限 | 服务端查当前权限版本 |
| 用户被禁用 | token version 或 session version |
| 管理后台 | 更短过期时间 + 二次校验 |
可以在 claims 里带权限版本:
#[derive(Debug)]
struct PermissionVersion {
user_id: u64,
version: u64,
}
fn permission_version_matches(token: PermissionVersion, current: PermissionVersion) -> bool {
token.user_id == current.user_id && token.version == current.version
}这样用户权限变化时,服务端提升当前 version,旧 token 就不能继续通过高风险校验。
认证失败和授权失败要分清
错误码不要混乱。
#[derive(Debug, PartialEq, Eq)]
enum AuthError {
Unauthenticated,
Forbidden,
}
fn status_for_auth_error(err: AuthError) -> u16 {
match err {
AuthError::Unauthenticated => 401,
AuthError::Forbidden => 403,
}
}401 表示你还没有被认证,或者 token 无效。
403 表示你已经被认证,但没有权限做这件事。
这个区分对前端体验、审计日志、安全分析都有用。所有错误都返回 401,排查时会很难判断是 token 问题还是越权尝试。
结论
JWT 只是携带身份和声明的容器,不是授权系统本身。真正的授权要结合用户、角色、资源、租户、操作和当前状态。
认证只回答“你是谁”;授权必须回答“你能不能对这个资源做这个动作”;RBAC 管能力,资源属性管边界;多租户查询必须把 tenant_id 当成一等条件。
Rust 的类型系统很适合把这些边界写进代码,不要把它们留在口头约定里。