← 中文资源

简体中文版本

JSON Web Token 声明与过期校验

用 Rust 的 jsonwebtoken 签发与验证令牌:注册声明强制存在、过期校验带时钟容忍窗口、租户声明匹配防止跨租户令牌复用。

阅读英文原文

示例说明

令牌验证的强度取决于声明的约束完整度:required_spec_claims 把 exp 与 sub 设为强制——缺任一即拒绝;validate_exp 开启过期校验,leeway 容忍五秒的多机时钟偏差防误杀;ES256 非对称算法让验证方只持公钥。业务声明 tenant 的匹配是额外防线——签给甲租户的令牌到乙租户的接口只能吃到拒绝。

技术要点

  • required_spec_claims 声明必填字段
  • leeway 吸收多机时钟偏差
  • 租户声明匹配防跨租户复用

代码解读

issue 构造带时间戳与过期时间的声明并用 ES256 私钥签名;verify 的 Validation 限定算法、必填声明、过期校验与容忍窗口四要素,decode 失败的错误以 Debug 形式输出完整分类。两次验证演示:同租户放行输出身份,跨租户被租户检查拦截。

使用建议

私钥进密钥管理系统而非仓库,示例的 include_bytes 仅演示加载方式。算法白名单防降级。刷新令牌与访问令牌分轨管理时效。

代码示例

use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Serialize, Deserialize)]
struct SessionClaims {
    sub: String,
    role: String,
    tenant: String,
    exp: u64,
    iat: u64,
}

fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

fn issue(key: &EncodingKey, subject: &str, role: &str, tenant: &str) -> String {
    let claims = SessionClaims {
        sub: subject.into(),
        role: role.into(),
        tenant: tenant.into(),
        iat: now(),
        exp: now() + 900, // 15 分钟访问令牌
    };
    encode(&Header::new(Algorithm::ES256), &claims, key).unwrap()
}

fn verify(
    key: &DecodingKey,
    token: &str,
    required_tenant: &str,
) -> Result<SessionClaims, String> {
    let mut validation = Validation::new(Algorithm::ES256);
    validation.required_spec_claims.insert("exp".into());
    validation.required_spec_claims.insert("sub".into());
    validation.leeway = 5; // 时钟偏差容忍 5 秒
    validation.validate_exp = true;

    let data = decode::<SessionClaims>(token, key, &validation)
        .map_err(|err| format!("token rejected: {err:?}"))?;

    if data.claims.tenant != required_tenant {
        return Err("tenant mismatch".into());
    }
    Ok(data.claims)
}

fn main() {
    let encoding = EncodingKey::from_ec_pem(include_bytes!("../keys/private.pem")).unwrap();
    let decoding = DecodingKey::from_ec_pem(include_bytes!("../keys/public.pem")).unwrap();

    let token = issue(&encoding, "user-42", "editor", "acme");
    match verify(&decoding, &token, "acme") {
        Ok(claims) => println!("accepted: {} as {}", claims.sub, claims.role),
        Err(err) => println!("rejected: {err}"),
    }

    match verify(&decoding, &token, "other-tenant") {
        Ok(_) => unreachable!(),
        Err(err) => println!("cross-tenant blocked: {err}"),
    }
}