fix(auth): 修复 Token 刷新并发竞态条件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。

新增 TokenService::validate_and_revoke_atomic 方法,将 JWT 解码、
哈希匹配和 token 撤销合并为单次数据库操作。
This commit is contained in:
iven
2026-05-09 01:53:28 +08:00
parent 36f2ba381a
commit 3e1413aebc
2 changed files with 42 additions and 6 deletions

View File

@@ -189,12 +189,9 @@ impl AuthService {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>, jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> { ) -> AuthResult<LoginResp> {
// Validate existing refresh token // Atomically validate and revoke the old refresh token (prevents TOCTOU race)
let (old_token_id, claims) = let claims =
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?; TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, claims.sub, db).await?;
// Fetch fresh roles and permissions // Fetch fresh roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?; let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;

View File

@@ -175,6 +175,45 @@ impl TokenService {
Ok(()) Ok(())
} }
/// Atomically validate and revoke a refresh token by hash.
/// This prevents TOCTOU race conditions during concurrent refresh requests.
/// Returns the decoded claims on success, or TokenRevoked if already consumed.
pub async fn validate_and_revoke_atomic(
token: &str,
db: &DatabaseConnection,
secret: &str,
) -> AuthResult<Claims> {
let claims = Self::decode_token(token, secret)?;
if claims.token_type != "refresh" {
return Err(AuthError::Validation("不是 refresh token".to_string()));
}
let hash = sha256_hex(token);
let now = Utc::now();
let result = user_token::Entity::update_many()
.col_expr(
user_token::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Some(now.naive_utc())),
)
.col_expr(
user_token::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(now.naive_utc()),
)
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(claims.sub))
.filter(user_token::Column::TenantId.eq(claims.tid))
.filter(user_token::Column::RevokedAt.is_null())
.exec(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if result.rows_affected == 0 {
return Err(AuthError::TokenRevoked);
}
Ok(claims)
}
/// Revoke all non-revoked refresh tokens for a given user within a tenant. /// Revoke all non-revoked refresh tokens for a given user within a tenant.
pub async fn revoke_all_user_tokens( pub async fn revoke_all_user_tokens(
user_id: Uuid, user_id: Uuid,