Skip to content
Clone
e870254
345 lines 11.3 KB Rust
//! 刷新令牌:落表 `oauth_refresh_tokens`,每次使用都轮换,命中已用令牌视为重放并撤销整条链。
use chrono::{DateTime, Duration, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
};
use uuid::Uuid;
use crate::models::users::{oauth_refresh_tokens as tokens, user_sessions};
use crate::server::AuthSvc;
use crate::server::errors::{AuthError, AuthResult};
use crate::server::share::crypto::{random_b64url, sha256_hex};
/// 明文令牌长度:48 字节随机 → 64 字符 base64url。
const TOKEN_BYTES: usize = 48;
pub struct Issued {
pub token: String,
pub expires_at: DateTime<Utc>,
}
struct Prepared {
token: String,
expires_at: DateTime<Utc>,
}
fn prepare(ttl_secs: i64, absolute_cap: Option<DateTime<Utc>>) -> Prepared {
let expires_at = capped_expiry(Utc::now(), ttl_secs, absolute_cap);
Prepared {
token: random_b64url(TOKEN_BYTES),
expires_at,
}
}
/// 轮换后的过期时间:取 `now + ttl` 与链绝对上限的较小者。绝对上限是链首发的
/// `expires_at`(首发时刻 + ttl),因此轮换只换发新令牌,不延长整条链的绝对寿命。
fn capped_expiry(
now: DateTime<Utc>,
ttl_secs: i64,
absolute_cap: Option<DateTime<Utc>>,
) -> DateTime<Utc> {
let expires_at = now + Duration::seconds(ttl_secs.clamp(60, 31_536_000));
absolute_cap.map_or(expires_at, |cap| expires_at.min(cap))
}
#[allow(clippy::too_many_arguments)]
async fn persist<C: ConnectionTrait>(
prepared: &Prepared,
user_id: i64,
session_id: Uuid,
client_id: &str,
scopes: &[String],
ip_address: Option<String>,
user_agent: Option<String>,
db: &C,
_request_id: Option<&str>,
) -> AuthResult<()> {
tokens::ActiveModel {
token_hash: Set(hash(&prepared.token)),
user_id: Set(user_id),
session_id: Set(session_id),
client_id: Set(client_id.to_owned()),
scopes: Set(serde_json::json!(scopes)),
expires_at: Set(prepared.expires_at),
ip_address: Set(ip_address),
user_agent: Set(user_agent),
..Default::default()
}
.insert(db)
.await?;
Ok(())
}
pub struct Grant<'a> {
pub user_id: i64,
pub session_id: &'a str,
pub client_id: &'a str,
pub scopes: &'a [String],
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
pub struct Rotated {
pub user_id: i64,
pub session_id: Uuid,
pub scopes: Vec<String>,
pub token: String,
pub expires_at: DateTime<Utc>,
/// 原始认证时间(会话建立时间),供 id_token 的 `auth_time` 使用(OIDC Core §12.2)。
pub auth_time: i64,
}
fn hash(token: &str) -> String {
sha256_hex(token.as_bytes())
}
/// 撤销归属判定:只有令牌名下的客户端才能撤它。
fn owns_token(row_client_id: &str, client_id: &str) -> bool {
!client_id.is_empty() && row_client_id == client_id
}
#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
pub async fn issue(
svc: &AuthSvc,
grant: Grant<'_>,
request_id: Option<&str>,
) -> AuthResult<Issued> {
let session_id = Uuid::parse_str(grant.session_id).map_err(|_| AuthError::Unauthenticated)?;
// 链首发:now + ttl 就是本链的绝对上限。
let prepared = prepare(svc.auth().oauth.refresh_token_ttl_secs, None);
persist(
&prepared,
grant.user_id,
session_id,
grant.client_id,
grant.scopes,
grant.ip_address,
grant.user_agent,
svc.db().write(),
request_id,
)
.await?;
Ok(Issued {
token: prepared.token,
expires_at: prepared.expires_at,
})
}
/// 轮换:旧令牌一次性作废,新令牌在链绝对寿命内接续(到期时间不再重置)。
#[tracing::instrument(skip_all, fields(client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
pub async fn rotate(
svc: &AuthSvc,
raw_token: &str,
client_id: &str,
ip_address: Option<String>,
user_agent: Option<String>,
request_id: Option<&str>,
) -> AuthResult<Rotated> {
let token_hash = hash(raw_token);
let row = tokens::Entity::find()
.filter(tokens::Column::TokenHash.eq(token_hash.as_str()))
.one(svc.db().read())
.await?
.ok_or(AuthError::InvalidCredentials)?;
if row.client_id != client_id {
return Err(AuthError::InvalidCredentials);
}
let now = Utc::now();
let replayed = row.revoked_at.is_some() || row.replaced_by.is_some();
if replayed {
// 已用令牌再次出现:说明泄漏,整条链(同会话同客户端)立刻失效。
revoke_chain(svc, row.session_id, client_id, request_id).await?;
return Err(AuthError::InvalidCredentials);
}
if row.expires_at <= now {
tokens::Entity::update_many()
.col_expr(
tokens::Column::RevokedAt,
sea_orm::sea_query::Expr::value(now),
)
.filter(tokens::Column::Id.eq(row.id))
.filter(tokens::Column::RevokedAt.is_null())
.exec(svc.db().write())
.await?;
return Err(AuthError::InvalidCredentials);
}
// 会话本身必须仍然有效(登出、security_version 变更、过期都会让它失效)。
let session = user_sessions::Entity::find_by_id(row.session_id)
.one(svc.db().read())
.await?
.ok_or(AuthError::InvalidCredentials)?;
svc.check_session(
row.user_id,
session.security_version,
&row.session_id.to_string(),
request_id,
)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let scopes: Vec<String> = serde_json::from_value(row.scopes.clone()).unwrap_or_default();
// 先预生成新令牌,再用条件更新原子抢占旧行(revoked_at/replaced_by 仍为空才算赢)。
// 输掉竞争说明旧令牌已被并发使用,按重放处理;赢家的 replaced_by 先于新令牌落库,
// 消除「先签发后标记」窗口内两个令牌并存的竞态。
// 绝对上限取旧令牌的 expires_at(链首发 + ttl),轮换不延长绝对寿命。
let prepared = prepare(
svc.auth().oauth.refresh_token_ttl_secs,
Some(row.expires_at),
);
// 抢占与落库必须在同一事务:否则 persist 失败会留下指向不存在新令牌的 replaced_by,
// 客户端重试被判重放,整条链被撤销且无法恢复。
let txn = svc.db().write().begin().await?;
let claim = tokens::Entity::update_many()
.col_expr(
tokens::Column::RevokedAt,
sea_orm::sea_query::Expr::value(now),
)
.col_expr(
tokens::Column::ReplacedBy,
sea_orm::sea_query::Expr::value(hash(&prepared.token)),
)
.filter(tokens::Column::Id.eq(row.id))
.filter(tokens::Column::RevokedAt.is_null())
.filter(tokens::Column::ReplacedBy.is_null())
.exec(&txn)
.await?;
if claim.rows_affected == 0 {
txn.rollback().await?;
revoke_chain(svc, row.session_id, client_id, request_id).await?;
return Err(AuthError::InvalidCredentials);
}
persist(
&prepared,
row.user_id,
row.session_id,
client_id,
&scopes,
ip_address,
user_agent,
&txn,
request_id,
)
.await?;
txn.commit().await?;
Ok(Rotated {
user_id: row.user_id,
session_id: row.session_id,
scopes,
token: prepared.token,
expires_at: prepared.expires_at,
auth_time: session.created_at.timestamp(),
})
}
/// 撤销一条链上所有仍在有效期的令牌(重放检测、登出、改密都会用到)。
#[tracing::instrument(skip_all, fields(session_id = %session_id, client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
pub async fn revoke_chain(
svc: &AuthSvc,
session_id: Uuid,
client_id: &str,
request_id: Option<&str>,
) -> AuthResult<u64> {
let result = tokens::Entity::update_many()
.col_expr(
tokens::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Utc::now()),
)
.filter(tokens::Column::SessionId.eq(session_id))
.filter(tokens::Column::ClientId.eq(client_id))
.filter(tokens::Column::RevokedAt.is_null())
.exec(svc.db().write())
.await?;
Ok(result.rows_affected)
}
/// 会话级撤销:登出时把该会话在所有客户端下的刷新令牌一起作废。
#[tracing::instrument(skip_all, fields(session_id = %session_id, request_id = %request_id.unwrap_or_default()))]
pub async fn revoke_session(
svc: &AuthSvc,
session_id: Uuid,
request_id: Option<&str>,
) -> AuthResult<u64> {
let result = tokens::Entity::update_many()
.col_expr(
tokens::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Utc::now()),
)
.filter(tokens::Column::SessionId.eq(session_id))
.filter(tokens::Column::RevokedAt.is_null())
.exec(svc.db().write())
.await?;
Ok(result.rows_affected)
}
/// RFC 7009 撤销端点:只撤刷新令牌,未知令牌按成功处理(避免探测)。
/// `client_id` 必填且令牌必须属于该客户端,禁止跨客户端撤销。
#[tracing::instrument(skip_all, fields(client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
pub async fn revoke(
svc: &AuthSvc,
raw_token: &str,
client_id: &str,
request_id: Option<&str>,
) -> AuthResult<()> {
let row = tokens::Entity::find()
.filter(tokens::Column::TokenHash.eq(hash(raw_token).as_str()))
.one(svc.db().write())
.await?;
let Some(row) = row else {
return Ok(());
};
if !owns_token(&row.client_id, client_id) {
return Ok(());
}
// 单行撤销会留下已经发出的后继令牌,必须整条链作废。
revoke_chain(svc, row.session_id, &row.client_id, request_id).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
#[test]
fn rotation_never_extends_the_absolute_deadline() {
let now = at(1_000);
// 无上限:now + ttl。
assert_eq!(capped_expiry(now, 600, None), at(1_600));
// 链上限更早:取上限,轮换不续命。
assert_eq!(capped_expiry(now, 600, Some(at(1_300))), at(1_300));
// 链上限更晚:仍受 ttl 约束。
assert_eq!(capped_expiry(now, 600, Some(at(9_999))), at(1_600));
// 相等边界。
assert_eq!(capped_expiry(now, 600, Some(at(1_600))), at(1_600));
}
#[test]
fn ttl_is_clamped_to_supported_range() {
let now = at(0);
assert_eq!(capped_expiry(now, 0, None), at(60));
assert_eq!(capped_expiry(now, -5, None), at(60));
assert_eq!(capped_expiry(now, 31_536_001, None), at(31_536_000));
// 非法 ttl 也不能把链上限推出去。
assert_eq!(capped_expiry(now, -5, Some(at(30))), at(30));
}
#[test]
fn revocation_is_bound_to_the_owning_client() {
assert!(owns_token("api", "api"));
// 跨客户端与空 client_id 一律不生效。
assert!(!owns_token("api", "other"));
assert!(!owns_token("api", ""));
assert!(!owns_token("", ""));
}
}