楼层: 首页/ 软件技术/ Rust 后端技术栈/ 认证与会话:JWT、OAuth2 与 OIDC
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 + Refreshaccess 短(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 的结构决定了后面所有校验能不能做。先记住七个标准声明,其余的按业务自定义(自定义字段不要和标准名撞)。

声明全称作用与校验要点
issIssuer签发方。多环境(dev/staging/prod)共用密钥时,靠它区分,必须校验。
subSubject主体,通常放 user_id。注意它是字符串,别当整数直接解析。
audAudience受众,放你的 API 标识。防止 A 服务的 token 拿去调 B 服务。
expExpiration过期时间(Unix 秒)。必须校验,且要留时钟偏移余量。
nbfNot Before生效时间。服务器时钟比签发方慢会直接判定"未生效",这是线上事故高发点。
iatIssued At签发时间。可用来做"密码修改后签发时间更早的 token 全部失效"。
jtiJWT 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/challengePKCE:证明"换 token 的人就是发起授权的人"授权码被中间人截获后可直接换 token(公开客户端尤其致命)
redirect_uri 精确匹配防开放重定向授权码被转到攻击者域名
id_token 的 nonceOIDC 专用,防 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) 不能同时出现,必须写具体域名。