09
认证与会话:JWT、OAuth2 与 OIDC
AuthN & Session · JWT / OAuth2 / OIDC
第 2 章的 Todo API 是"裸奔"的——任何人都能调。真实项目里第一件要补的事就是鉴权,而这恰恰是最容易写出安全漏洞的一环:算法没指定导致 alg=none 伪造令牌、role 直接信 token 不查库导致权限改不掉、时钟偏移导致刚签发的 token 就被判过期。这一章从"认证和授权的区别"讲起,一路写到 axum 里能直接用的提取器,并且每段代码都标清安全依据。基线:jsonwebtoken 9(9 → 11 的核心 API 未变,只是密码学后端可切换)。
先把三个词分清:认证、授权、会话
这三个词天天被混用,但它们在代码里对应完全不同的位置。混着设计,最后一定会变成"所有接口都在手写 if user.is_admin"。
| 概念 | 回答的问题 | 落地形态 | 典型实现 |
| 认证 AuthN |
"你是谁?" |
登录接口、密码校验、第三方登录回调 |
POST /login 校验 argon2 哈希 → 签发 token;OAuth2 授权码换 token |
| 授权 AuthZ |
"你能干什么?" |
权限判断、角色检查、资源归属校验 |
RBAC(角色权限表)、ABAC(属性策略)、资源级校验(order.owner_id == me.id) |
| 会话 Session |
"你这次登录的状态怎么维持?" |
服务端 session / 客户端 token / 混合 |
cookie + Redis session;JWT access token + refresh token |
三种会话方案的取舍(这是选型的核心依据)
| 方案 | 优点 | 代价 | 什么时候选 |
| 服务端 Session(cookie + Redis) | 可随时失效、权限即时生效、token 不落地前端 | 每次请求要查一次 Redis;需要共享存储;跨域麻烦 | 单体应用、后台管理系统、对"立刻踢人下线"有硬需求 |
| 纯 JWT | 无状态、服务端零存储、横向扩展最省心 | 签发后无法撤销(只能等过期或维护黑名单,又退化成有状态) | 短时效 access token、服务间调用、内部 API |
| JWT + Refresh | access 短(15 分钟)、refresh 长(7 天)且可撤销 | 客户端要处理续期逻辑、并发续期要防抖 | 面向浏览器/App 的公开 API,默认选这个 |
论JWT 不是"安全令牌",它只是"签了名的 JSON"
① JWT 的 payload 是明文。eyJzdWIiOiIxIn0 只是 base64url,不是加密。任何人拿到 token 都能解出 sub、roles。所以 绝不能把密码、身份证号、密钥放进 claims。它的价值只在于"签名不能被伪造"。
② 签名保证的是完整性,不是保密性。服务端能确认"这个 payload 确实是我签的、且没被改过一个字节",仅此而已。要保密就上 JWE,或者干脆别放敏感数据。
③ 因此 JWT 的正确心智是"自包含的、可离线验证的凭证"。它适合"验证完就能立刻放行"的场景;一旦你的业务需要"马上撤销权限",就必须引入服务端状态(Redis 黑名单或查库),那时 JWT 的无状态优势也就没了——这就是很多团队最终选择"JWT 里只放 user_id,权限每次查缓存"的原因。
签发:先设计 Claims,再写 encode
Claims 的结构决定了后面所有校验能不能做。先记住七个标准声明,其余的按业务自定义(自定义字段不要和标准名撞)。
| 声明 | 全称 | 作用与校验要点 |
iss | Issuer | 签发方。多环境(dev/staging/prod)共用密钥时,靠它区分,必须校验。 |
sub | Subject | 主体,通常放 user_id。注意它是字符串,别当整数直接解析。 |
aud | Audience | 受众,放你的 API 标识。防止 A 服务的 token 拿去调 B 服务。 |
exp | Expiration | 过期时间(Unix 秒)。必须校验,且要留时钟偏移余量。 |
nbf | Not Before | 生效时间。服务器时钟比签发方慢会直接判定"未生效",这是线上事故高发点。 |
iat | Issued At | 签发时间。可用来做"密码修改后签发时间更早的 token 全部失效"。 |
jti | JWT ID | 唯一 ID。做黑名单/refresh 轮换的钥匙,强烈建议每个 token 都带。 |
Cargo.toml 与 claims 定义
[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde"] }
// src/auth/claims.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
// ---- 标准声明 ----
pub iss: String, // "https://api.example.com"
pub sub: String, // user_id,注意是 String
pub aud: String, // "example-api"
pub exp: usize, // 过期时间戳(秒)
pub nbf: usize, // 生效时间戳
pub iat: usize, // 签发时间戳
// ---- 自定义声明 ----
// jti 让 token 可以被单独撤销,refresh 轮换也靠它
pub jti: String,
// 只放"粗粒度"角色,细粒度权限每次查缓存,避免改权限要等 token 过期
pub roles: Vec<String>,
// 用于"改密码后旧 token 全部失效":与 user.pwd_changed_at 比较
pub pwd_at: i64,
}
签发 access token(注意 exp 是绝对时间戳,不是"多少秒后")
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use uuid::Uuid;
const ISSUER: &str = "https://api.example.com";
const AUDIENCE: &str = "example-api";
const ACCESS_TTL_SECS: usize = 15 * 60; // 15 分钟,越短越安全
pub fn issue_access_token(
user_id: &str,
roles: Vec<String>,
pwd_at: i64,
secret: &[u8],
) -> Result<String, jsonwebtoken::errors::Error> {
// get_current_timestamp() 返回秒级 Unix 时间戳,别自己算错时区
let now = jsonwebtoken::get_current_timestamp() as usize;
let claims = Claims {
iss: ISSUER.into(),
sub: user_id.into(),
aud: AUDIENCE.into(),
exp: now + ACCESS_TTL_SECS,
// nbf 故意往前留 30 秒,容忍签发机与被验机之间的时钟偏移
nbf: now.saturating_sub(30),
iat: now,
jti: Uuid::new_v4().to_string(),
roles,
pwd_at,
};
// Header::new(Algorithm::HS256) 会把 alg 写进 JWT 头部;typ 自动填 JWT
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret),
)
}
把密钥放进配置,而不是硬编码(一个真实的翻车点)
// src/config.rs —— 密钥来源优先级:环境变量 > 密钥管理服务 > 配置文件
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub jwt_secret: Vec<u8>,
pub access_ttl: std::time::Duration,
pub refresh_ttl: std::time::Duration,
}
impl AuthConfig {
pub fn from_env() -> anyhow::Result<Self> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("必须设置 JWT_SECRET 环境变量"))?;
// HS256 的安全下限是"密钥熵不小于哈希输出",32 字节起步
if secret.len() < 32 {
anyhow::bail!("JWT_SECRET 太短,至少 32 字节,建议 openssl rand -base64 48 生成");
}
Ok(Self {
jwt_secret: secret.into_bytes(),
access_ttl: std::time::Duration::from_secs(15 * 60),
refresh_ttl: std::time::Duration::from_secs(7 * 24 * 3600),
})
}
}
坑:exp 写成"剩余秒数"、密钥用 "secret" 这种弱口令
两个几乎每个新项目都会犯的错:
① exp: 3600 意味着"1970-01-01 01:00:00 过期",也就是永远过期。JWT 里所有时间声明都是绝对 Unix 时间戳,必须 now + ttl。写错了本地测试可能还"通过",因为校验只是拿 exp 和当前时间比——它会立刻判定过期,你反而以为是自己 TTL 设短了。
② 密钥硬编码或过短。HS256 的密钥强度直接决定 token 能否被离线爆破。"my-secret"、"change-me" 这类口令在 GitHub 上一搜一大把。规范做法:openssl rand -base64 48 生成,走环境变量或 KMS 注入,绝不进 Git;同时在启动时校验长度,短于 32 字节直接拒绝启动。
校验:必须显式指定算法,这是防 alg=none 的唯一手段
这是 JWT 历史上最著名的漏洞。JWT 头部里有个 alg 字段,告诉验证方"我用什么算法签的"。而攻击者可以把这个字段改成 none,并在移除签名后提交。如果验证方"按头部声明的算法去验"——那就等于"没有签名也认",token 完全可伪造。
错误的写法(看起来很自然,但会中招)
// 危险:先解析 header 看 alg,再决定怎么验 —— 攻击者说 none 你就放行
let header = jsonwebtoken::decode_header(token)?;
match header.alg {
Algorithm::HS256 => { /* 用密钥验 */ }
// 一旦这里写了"其他算法就不验签名",直接沦陷
_ => { /* 放行 */ }
}
正确的写法:算法由服务端决定,与 token 头部无关
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
pub fn verify_access_token(token: &str, secret: &[u8]) -> Result<Claims, AuthError> {
// 关键 1:Validation::new(Algorithm::HS256) —— 算法在这里就被钉死
// 库内部会把 header.alg 与这里指定的算法比对,alg=none 的 token 直接被拒
let mut validation = Validation::new(Algorithm::HS256);
// 关键 2:iss / aud 必须校验,否则跨服务 token 可以互相冒用
validation.set_issuer(&[ISSUER]);
validation.set_audience(&[AUDIENCE]);
// 关键 3:时钟偏移容忍。服务器之间差 30 秒是常态,默认 60 秒 buffer 别关掉
validation.leeway = 60;
// 关键 4:显式声明哪些声明"必须存在",缺失就报错
// 默认只有 exp 是必需的,iss/aud 不写进这里就可能被"缺失即跳过"
validation.required_spec_claims.insert("iss".into());
validation.required_spec_claims.insert("aud".into());
validation.required_spec_claims.insert("sub".into());
// 关键 5:过期必须校验(默认 true,显式写出来让 Review 的人放心)
validation.validate_exp = true;
// nbf 与 iat 的校验按需;校验 iat 能挡住"把 exp 改到很久以后"的旧 token 复用
validation.validate_nbf = true;
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&validation,
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::Expired,
jsonwebtoken::errors::ErrorKind::InvalidSignature => AuthError::BadSignature,
jsonwebtoken::errors::ErrorKind::ImmatureSignature => AuthError::NotYetValid,
_ => AuthError::Invalid,
})?;
// data.header 是 token 头部,data.claims 才是我们关心的内容
Ok(data.claims)
}
// 只在"确实要看 payload 内容但不需要信任它"时才用(例如日志、路由)
pub fn peek_unverified(token: &str) -> Result<Claims, AuthError> {
// dangerous::insecure_decode 明确标了 dangerous,看到它就该警觉
jsonwebtoken::dangerous::insecure_decode::<Claims>(token)
.map(|d| d.claims)
.map_err(|_| AuthError::Invalid)
}
论为什么"指定算法"这种看起来多此一举的写法是必须的
① 因为 JWT 把"元数据"和"数据"放在同一个不可信的信封里。头部里的 alg 和 payload 一样来自客户端,它是攻击者可控的输入。一个安全的验证器必须遵循"用我预期的算法去验,而不是用你告诉我的算法去验"。
② Validation::new(alg) 的语义就是把算法从"输入"变成"配置"。你写 Validation::new(Algorithm::HS256),那么任何 alg 不是 HS256 的 token(包括 none、包括 RS256)都会被拒。同样的坑在 RS256/HS256 混淆攻击里也存在:如果服务端用公钥验 RS256,而攻击者把 alg 改成 HS256、用公钥字符串当 HMAC 密钥签名,某些实现会被骗过。钉死算法同时防住了这两种攻击。
③ 这是一个通用原则,不止 JWT。凡是"数据里带着自己的解析方式"的格式(XML 的 <!DOCTYPE>、序列化的 __class__ 字段、JWT 的 alg),都要按 "解析方式由接收方决定,不由发送方声明"来写。
HS256 与 RS256:怎么选,以及 JWKS 公钥怎么拉
// 对称:HS256 —— 同一把密钥既签名又验签
let enc = EncodingKey::from_secret(secret);
let dec = DecodingKey::from_secret(secret);
// 优点:快、实现简单。缺点:所有验签方都拿到密钥 = 都能签发
// 适用:单体服务、内部服务之间(且能保证密钥不外泄)
// 非对称:RS256 / ES256 —— 私钥签名,公钥验签
let enc = EncodingKey::from_rsa_pem(include_bytes!("keys/private.pem"))?;
let dec = DecodingKey::from_rsa_pem(include_bytes!("keys/public.pem"))?;
// 优点:验签方只有公钥,无法伪造 token(可安全下发给网关、其他团队)
// 缺点:RSA 验签比 HMAC 慢一个量级;密钥管理更重(要处理轮换)
// 适用:多服务共享登录态、需要把公钥暴露给第三方、OIDC 场景
// ECDSA(ES256)比 RS256 快、签名更短,但并非所有 JWT 库都完整支持
let dec = DecodingKey::from_ec_pem(public_pem)?;
JWKS:从 IdP 拉公钥并缓存(OIDC 的标准做法)
use jsonwebtoken::jwk::{Jwk, JwkSet};
use std::collections::HashMap;
// 典型 JWKS 文档结构(https://idp.example.com/.well-known/jwks.json)
// { "keys": [ { "kty": "RSA", "kid": "abc123", "use": "sig", "n": "...", "e": "AQAB" } ] }
pub struct JwksCache {
// kid -> 已解析好的 DecodingKey,避免每次请求都重新解析模数
keys: tokio::sync::RwLock<HashMap<String, DecodingKey>>,
fetched_at: std::sync::atomic::AtomicU64,
url: String,
http: reqwest::Client,
}
impl JwksCache {
pub async fn get(&self, kid: &str) -> anyhow::Result<DecodingKey> {
{
let guard = self.keys.read().await;
if let Some(k) = guard.get(kid) {
return Ok(k.clone());
}
}
// 缓存没命中:可能是 IdP 轮换了密钥,重新拉一次再查
let set: JwkSet = self.http.get(&self.url).send().await?.json().await?;
let mut map = HashMap::new();
for jwk in &set.keys {
if let Some(kid) = &jwk.common.key_id {
// from_jwk 直接把 JWK 转成 DecodingKey,不用自己拼模数
map.insert(kid.clone(), DecodingKey::from_jwk(jwk)?);
}
}
let key = map.get(kid).cloned().ok_or_else(|| anyhow::anyhow!("未知 kid: {kid}"))?;
*self.keys.write().await = map;
Ok(key)
}
}
// 验签时:先取 header 的 kid(不验签,只看头部),再从缓存取公钥
let header = jsonwebtoken::decode_header(token)?;
let key = jwks.get(header.kid.as_deref().ok_or(AuthError::Invalid)?).await?;
let mut v = Validation::new(Algorithm::RS256); // 仍然要钉死算法
let data = decode::<Claims>(token, &key, &v)?;
坑:每次请求都去拉 JWKS,或者把公钥写死在配置文件里
① 每请求拉一次 JWKS 会把你的鉴权延迟从 0.1ms 拉到 200ms,而且 IdP 一抖你就全站 401。正确做法是"按 kid 缓存 + 未命中才刷新 + 加最小刷新间隔(比如 5 分钟)+ 单个 reqwest::Client 复用连接"。
② 把公钥写死在配置里看着更"稳",但 IdP 轮换密钥的那天你会全站登录失败,且排查时会盯着自己的代码看半天。JWKS 的整个设计目的就是"让密钥轮换对使用方透明",别把它退化成硬编码。
③ 还有一条容易忽略:kid 缺失时怎么办。严格做法是直接拒绝(JWKS 场景下 kid 是必需的);宽松做法是"只有一个公钥时就用它"。后者看起来更方便,但会在密钥轮换窗口期(新旧密钥并存)验错密钥,表现是间歇性 401,非常难查。
Refresh token:轮换、撤销与重放检测
access token 短(15 分钟)带来一个问题:用户不可能每 15 分钟重新登录。解法是发一个长时效的 refresh token,专门用来换新的 access token。而 refresh token 必须是可撤销的——所以它要落库或落 Redis。
Redis 里的数据结构设计
# refresh 白名单:只存"当前有效"的 refresh token 的 jti
# 值里带上 user_id 与"这一代的 jti",TTL 就等于 refresh 有效期
SET refresh:{jti} "user:42|gen:7" EX 604800
# 用户级会话索引:用于"一键登出所有设备"
SADD user:42:refresh_jtis {jti1} {jti2}
# access token 黑名单(只在"提前撤销"时需要,TTL 设成 access 剩余寿命即可)
SET revoked:access:{jti} "1" EX 900
# 关键点:黑名单的 TTL 千万不要设成永久,否则 Redis 会被慢慢撑爆
# 设成"access token 的自然过期时间"就够,过期后 JWT 自己会失效
轮换 + 重放检测(这段逻辑是 refresh 方案的安全核心)
use redis::AsyncCommands;
/// 用 refresh token 换一套新的 (access, refresh)
pub async fn rotate(
old_refresh: &str,
redis: &redis::aio::ConnectionManager,
cfg: &AuthConfig,
) -> Result<(String, String), AuthError> {
// 1) 先校验签名与 exp(refresh token 也要签名,别用不透明字符串硬查库)
let claims = verify_refresh_token(old_refresh, &cfg.jwt_secret)?;
// 2) 原子地"取出并删除"旧 jti —— GETDEL 保证并发下只有一个请求能成功
// 用 GET + DEL 两步会在并发续期时出现两个请求都成功的竞态
let key = format!("refresh:{}", claims.jti);
let mut conn = redis.clone();
let stored: Option<String> = conn.get_del(&key).await.map_err(AuthError::internal)?;
match stored {
// 3a) 正常路径:旧 token 确实在我们这,删除成功,发新一代
Some(_) => {
let new_access = issue_access_token(&claims.sub, claims.roles.clone(), claims.pwd_at, &cfg.jwt_secret)?;
let new_refresh = issue_refresh_token(&claims.sub, &cfg)?;
// 把新的 refresh jti 写回白名单
let new_claims = peek_unverified(&new_refresh)?;
let _: () = conn
.set_ex(format!("refresh:{}", new_claims.jti), &claims.sub, cfg.refresh_ttl.as_secs())
.await
.map_err(AuthError::internal)?;
Ok((new_access, new_refresh))
}
// 3b) 签名合法但 jti 不在白名单 —— 典型的【重放】:
// 这个 refresh 已经用过一次,现在被人拿着旧副本再次使用。
// 说明凭证可能已泄露,必须把该用户的所有会话全部踢掉。
None => {
revoke_all_sessions(&claims.sub, &mut conn).await;
Err(AuthError::RefreshReused)
}
}
}
/// 踢掉某用户全部会话:遍历索引里的 jti 逐个删白名单 + 加 access 黑名单
async fn revoke_all_sessions(user_id: &str, conn: &mut redis::aio::ConnectionManager) {
let idx = format!("user:{user_id}:refresh_jtis");
let jtis: Vec<String> = conn.smembers(&idx).await.unwrap_or_default();
for jti in jtis {
let _: Result<(), _> = conn.del(format!("refresh:{jti}")).await;
}
let _: Result<(), _> = conn.del(&idx).await;
}
论为什么 refresh 必须"一次一换",而不能"反复使用"
① 长期有效的凭证必然会被偷。refresh 有效期 7 天,它存在浏览器的 localStorage 或 App 的私有目录里,XSS、恶意 App、备份文件泄露都会把它带出去。设计上必须假设"它总有一天会泄露"。
② 轮换把"泄露"变成了"可检测"。如果 refresh 只能成功兑换一次,那么当攻击者拿着偷来的 refresh 去换新 token 时,要么攻击者成功(真用户下次用就失败,触发报警),要么真用户先成功(攻击者下次用就失败,触发报警)。两个分支都会被检测到,这就是"重放检测"。而不轮换的 refresh 被偷之后,攻击者可以静默续期 7 天,你一点都察觉不到。
③ 代价是并发续期。前端同时发两个请求、都要续期,第二个会因为旧 jti 已被删除而触发"重放",把用户踢下线。工程解法有两条:前端做单飞锁(正在续期时其他请求排队等结果)、服务端给一个宽限窗口(旧 jti 删除后仍保留 30 秒的"已轮换到新 jti"映射,重复请求直接返回同一个新 token)。生产环境一定要做这个宽限,否则用户会莫名被登出。
OAuth2 与 OIDC:从"用微信登录"到"标准流程"
你天天见到的"用 Google 登录""用企业微信登录",底层就是这个流程。OAuth2 是授权框架(我给你访问我资源的权限),OIDC 是在它之上加了一层身份认证(我是谁)。做登录要用 OIDC,不是裸 OAuth2。
授权码 + PKCE 的完整时序(这是目前唯一推荐的前端接入方式)
1. 前端生成 code_verifier(43~128 位随机串)
└─ 算出 code_challenge = BASE64URL( SHA256(code_verifier) )
2. 浏览器跳转到授权端点:
GET https://idp.example.com/authorize
?response_type=code
&client_id=my-app
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=RANDOM_CSRF_TOKEN
&code_challenge=XXXX
&code_challenge_method=S256
3. 用户在 IdP 登录并授权 → 浏览器被重定向回:
https://app.example.com/callback?code=CODE&state=RANDOM_CSRF_TOKEN
└─ 前端【必须比对 state】,防止 CSRF
4. 后端拿 code 换 token(这一步在服务端做,client_secret 不下发浏览器):
POST https://idp.example.com/token
grant_type=authorization_code
&code=CODE
&redirect_uri=...
&client_id=my-app
&code_verifier=原始随机串 ← 回放给 IdP,它自己算 hash 比对
5. 响应里拿到:
{ "access_token": "...", "refresh_token": "...",
"id_token": "eyJ...", ← OIDC 才有,JWT 格式,含身份
"expires_in": 3600 }
6. 用 access_token 调 /userinfo 拿详细资料,或用 id_token 里的 claims
└─ 调业务 API 时不需要再带 IdP 的 token,换成你自己签发的 JWT
| 字段/参数 | 作用 | 不做会怎样 |
state | 防 CSRF:把随机串带去再带回来比对 | 攻击者能构造回调 URL 把你的账号绑到他的身份上(登录 CSRF) |
code_verifier/challenge | PKCE:证明"换 token 的人就是发起授权的人" | 授权码被中间人截获后可直接换 token(公开客户端尤其致命) |
redirect_uri 精确匹配 | 防开放重定向 | 授权码被转到攻击者域名 |
id_token 的 nonce | OIDC 专用,防 id_token 重放 | 旧 id_token 可被重复使用 |
校验 id_token 的 iss/aud/exp/签名 | 确认 id_token 真是这个 IdP 签给你的 | 任意伪造的 id_token 都能通过 |
Rust 侧:拿到 code 后换 token 并校验 id_token
// src/auth/oidc.rs —— 用 reqwest 走标准的 token 端点交换
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
id_token: Option<String>,
refresh_token: Option<String>,
expires_in: u64,
}
pub async fn exchange_code(
http: &reqwest::Client,
issuer: &str,
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: &str,
code_verifier: &str,
) -> anyhow::Result<TokenResponse> {
// 端点地址从 discovery 文档拿,别硬编码
// GET {issuer}/.well-known/openid-configuration -> token_endpoint / jwks_uri
let disc: serde_json::Value = http
.get(format!("{issuer}/.well-known/openid-configuration"))
.send().await?.json().await?;
let token_endpoint = disc["token_endpoint"].as_str().unwrap();
let resp = http
.post(token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("client_secret", client_secret),
// PKCE 的唯一作用就是回放这个原串给 IdP
("code_verifier", code_verifier),
])
.send().await?
.error_for_status()?
.json::<TokenResponse>().await?;
Ok(resp)
}
/// 校验 OIDC 的 id_token(本质就是一个 JWT)
pub fn verify_id_token(
id_token: &str,
key: &DecodingKey,
issuer: &str,
client_id: &str,
) -> anyhow::Result<IdClaims> {
// id_token 的 aud 必须等于你的 client_id —— 这一步常被漏掉
let mut v = Validation::new(Algorithm::RS256); // 仍然钉死算法
v.set_issuer(&[issuer]);
v.set_audience(&[client_id]);
v.leeway = 60;
let data = decode::<IdClaims>(id_token, key, &v)?;
Ok(data.claims)
}
落到 axum:提取器 + 中间件 + RBAC
业务代码里不应该出现 Authorization 头解析。正确姿势是实现 FromRequestParts,让鉴权变成一个函数参数——这样"忘了鉴权"在编译期就不可能发生。
自定义提取器:从请求头解析并校验 token
// src/auth/extractor.rs
use axum::{
extract::FromRequestParts,
http::{header, request::Parts, StatusCode},
Json,
};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: String,
pub roles: Vec<String>,
pub jti: String,
pub pwd_at: i64,
}
// 把依赖(密钥、redis)放进 AppState,提取器通过 State 拿到
pub struct AppState {
pub auth: Arc<AuthConfig>,
pub redis: redis::aio::ConnectionManager,
pub db: sqlx::PgPool,
}
// axum 0.8 的 FromRequestParts 用的是原生 async fn(Rust 1.75+),
// 不再需要 #[async_trait],写起来和普通函数一样
impl FromRequestParts<AppState> for AuthUser {
// Rejection 决定鉴权失败时的响应。用一个自定义错误类型比元组更清晰
type Rejection = (StatusCode, Json<ApiError>);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// 1) 取头。大小写不敏感,用 header::AUTHORIZATION 常量而不是手写字符串
let raw = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| unauthorized("缺少 Authorization 头"))?;
// 2) 严格按 "Bearer " 前缀解析,大小写差异按 RFC 应当忽略
let token = raw
.strip_prefix("Bearer ")
.or_else(|| raw.strip_prefix("bearer "))
.ok_or_else(|| unauthorized("Authorization 头格式必须是 Bearer <token>"))?
.trim();
// 3) 验签 + 校验声明
let claims = verify_access_token(token, &state.auth.jwt_secret)
.map_err(|e| unauthorized(&format!("token 无效: {e}")))?;
// 4) 黑名单检查:被撤销的 access token 要立刻失效
// 注意这里查 Redis 是每次请求都做的,所以键的 TTL 必须是有界的
let mut conn = state.redis.clone();
let revoked: Option<String> = redis::AsyncCommands::get(
&mut conn,
format!("revoked:access:{}", claims.jti),
)
.await
.map_err(|_| unauthorized("鉴权服务暂不可用"))?;
if revoked.is_some() {
return Err(unauthorized("token 已被撤销"));
}
// 5) 关键的安全设计:roles 不信任 token 里的值,而是查库/查缓存
// 这样管理员改了权限立刻生效,不用等 token 过期
let roles: Vec<String> = sqlx::query_scalar(
"SELECT role FROM user_roles WHERE user_id = $1",
)
.bind(&claims.sub)
.fetch_all(&state.db)
.await
.unwrap_or_default();
// 6) 密码修改时间比对:改过密码后,更早签发的 token 一律失效
let pwd_at: i64 = sqlx::query_scalar("SELECT pwd_changed_at FROM users WHERE id = $1")
.bind(&claims.sub)
.fetch_one(&state.db)
.await
.map_err(|_| unauthorized("用户不存在"))?;
if claims.pwd_at < pwd_at {
return Err(unauthorized("密码已变更,请重新登录"));
}
Ok(AuthUser {
user_id: claims.sub,
roles,
jti: claims.jti,
pwd_at,
})
}
}
handler 里怎么用,以及 RBAC 怎么做
// 1) 只要"已登录":把 AuthUser 当普通参数写进去即可
async fn me(user: AuthUser) -> Json<serde_json::Value> {
Json(serde_json::json!({ "user_id": user.user_id, "roles": user.roles }))
}
// 2) 只要某个角色:提取器实现了"角色守卫",用类型把它们区分开
pub struct AdminUser(pub AuthUser);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = (StatusCode, Json<ApiError>);
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
// 复用基础提取器的全部逻辑,再叠加角色检查
let user = AuthUser::from_request_parts(parts, state).await?;
if !user.roles.iter().any(|r| r == "admin") {
// 注意语义:已认证但无权限应当是 403,不是 401
return Err((StatusCode::FORBIDDEN, Json(ApiError::new("需要管理员权限"))));
}
Ok(AdminUser(user))
}
}
// 3) 资源级校验必须写在业务逻辑里,提取器管不了
async fn delete_order(
user: AuthUser,
axum::extract::Path(order_id): axum::extract::Path<i64>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<StatusCode, AppError> {
let owner: String = sqlx::query_scalar("SELECT owner_id FROM orders WHERE id = $1")
.bind(order_id).fetch_one(&state.db).await?;
// 只能删自己的,或者是管理员。这条规则无法用"角色"表达,必须查数据
if owner != user.user_id && !user.roles.iter().any(|r| r == "admin") {
return Err(AppError::Forbidden);
}
sqlx::query("DELETE FROM orders WHERE id = $1").bind(order_id).execute(&state.db).await?;
Ok(StatusCode::NO_CONTENT)
}
// 4) 路由装配:把 AuthUser 写在参数里,就自动要求鉴权
let app = axum::Router::new()
.route("/me", axum::routing::get(me))
.route("/admin/users", axum::routing::get(list_users)) // handler 参数是 AdminUser
.route("/login", axum::routing::post(login))
.with_state(state);
跨域时必须放行 Authorization 头(这条坑 90% 的人踩过)
use tower_http::cors::{Any, CorsLayer};
use axum::http::{header, Method};
let cors = CorsLayer::new()
// 注意:allow_credentials(true) 与 allow_origin(Any) 不能同时用
// 浏览器规范禁止 "Access-Control-Allow-Origin: *" 携带凭证
.allow_origin("https://app.example.com".parse::<axum::http::HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST, Method::DELETE])
// 必须显式列出 AUTHORIZATION,否则浏览器预检请求会失败
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true);
坑:这五条鉴权错误在真实项目里都出过线上事故
① 直接信任 token 里的 role。用户被降权后,旧 token 在有效期内仍然带着 admin,最长能撑 15 分钟。如果 access TTL 设成 24 小时,那就是 24 小时的越权窗口。做法:权限查库/查缓存,token 里只放 user_id。
② 时钟偏移导致 nbf 校验失败。多台服务器未做 NTP 同步,签发机比验签机快 5 秒,刚签的 token 立刻被判"尚未生效",前端表现是"登录后立刻 401,重试又好了"。做法:容器/主机统一开 NTP;leeway 至少 60 秒;nbf 签发时往前挪 30 秒。
③ CORS 没放行 Authorization。浏览器发预检请求,服务端返回的 Access-Control-Allow-Headers 里没有 authorization,请求被浏览器拦掉。表现是"Postman 里好的,网页里死活不通"。
④ 401 和 403 用混。没带 token / token 无效 → 401(告诉客户端去登录);带了合法 token 但权限不够 → 403(别去登录,登录也没用)。前端拦截器通常按 401 触发刷新逻辑,用错会造成"无限刷新循环"。
⑤ 把 refresh token 也放进 localStorage。XSS 一次就全丢。更稳的是 refresh 放 HttpOnly + Secure + SameSite=Lax 的 Cookie(前端 JS 读不到),access token 放内存变量。
记
本章小结
① 认证问"你是谁"、授权问"你能干什么"、会话问"状态怎么维持",三者要分开设计,别让权限判断散落在每个 handler 里。
② JWT payload 是明文,只放 user_id 和粗粒度角色;敏感信息一律不放。
③ Validation::new(Algorithm::HS256) 是防 alg=none 与算法混淆攻击的唯一手段——算法由服务端定,不由 token 头部定。
④ 校验清单:exp、nbf、iss、aud、算法、leeway;aud 不做就等于允许跨服务冒用。
⑤ HS256 适合单体,RS256 + JWKS 适合多服务;JWKS 必须按 kid 缓存,不能每请求拉、也不能硬编码。
⑥ refresh token 必须轮换 + 落白名单 + 重放检测,并给并发续期留宽限窗口。
⑦ 用 FromRequestParts 提取器把鉴权变成函数参数;角色守卫用独立的 AdminUser 类型表达;资源级归属校验必须查数据。
⑧ 权限查库不查 token,401 与 403 分清,CORS 记得放行 Authorization。
小练习 · 五道鉴权自测题(点开看答案)
1.(安全题)JWT 头部写着 {"alg":"none"},服务端用 decode_header 读了 alg 再决定验证方式,为什么危险?
查看答案
因为 alg 是攻击者可控的输入。alg=none 的语义是"没有签名",如果服务端按头部的 alg 去验,就会在"没有签名"的情况下放行,任何人都能伪造任意用户的 token。正确做法是用 Validation::new(Algorithm::HS256) 把算法钉死在服务端,头部声明不符的直接拒绝。
2.(设计题)用户被管理员降权了,但他手上的 token 还有 14 分钟才过期。怎么让降权立刻生效?
查看答案
把权限判断从 token 移到服务端状态。做法有两种:① token 里只放 user_id,roles 每次从 Redis 缓存(TTL 几十秒)或数据库查;② 保留 token 里的 roles 作为"粗筛",但关键操作前再查一次库。加上"密码/权限变更时间戳"比对(pwd_at/perm_at),就能让旧 token 立即失效。
3.(排错题)用户反馈"刚登录成功后马上调接口报 401,刷新页面又好了",最可能是什么问题?
查看答案
时钟偏移导致 nbf 或 exp 校验失败。签发机与验签机时间不同步(签发机快几秒),token 的 nbf 在验签机看来还在未来,抛 ImmatureSignature。"刷新又好了"正是因为过了几秒。修法:统一 NTP、validation.leeway 设 60 秒、签发时把 nbf 往前挪 30 秒。
4.(协议题)PKCE 解决的是什么问题?只做授权码流程不加 PKCE 行不行?
查看答案
PKCE 防的是"授权码被截获后直接换 token"。公开客户端(SPA、手机 App)无法安全保存 client_secret,一旦授权码通过重定向 URL、日志、Referer 泄露,攻击者就能用它换到 token。PKCE 让换 token 时必须回放原始 code_verifier,而这个串从没离开过发起方。现在 OAuth 2.1 已把 PKCE 列为所有客户端的强制要求,不做就是不合规。
5.(实操题)前端本地调试时跨域预检失败,服务端已经配了 CorsLayer,还差什么?
查看答案
要显式 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])。带自定义头的请求会先发 OPTIONS 预检,浏览器的 Access-Control-Request-Headers 里有 authorization,服务端必须回 Access-Control-Allow-Headers 包含它。另外如果用了 Cookie 凭证,allow_credentials(true) 与 allow_origin(Any) 不能同时出现,必须写具体域名。