返回文章列表

Rust 微服务里的认证授权,别把 JWT 当万能钥匙

271·2 分钟阅读
Rust微服务

用户带着一个 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 的类型系统很适合把这些边界写进代码,不要把它们留在口头约定里。