From 3e1413aebc3c0839e1472d86ed25e41321f6f858 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 9 May 2026 01:53:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=20Token=20?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=B9=B6=E5=8F=91=E7=AB=9E=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL) 替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。 新增 TokenService::validate_and_revoke_atomic 方法,将 JWT 解码、 哈希匹配和 token 撤销合并为单次数据库操作。 --- crates/erp-auth/src/service/auth_service.rs | 9 ++--- crates/erp-auth/src/service/token_service.rs | 39 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index 4ec3653..64c712b 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -189,12 +189,9 @@ impl AuthService { db: &sea_orm::DatabaseConnection, jwt: &JwtConfig<'_>, ) -> AuthResult { - // Validate existing refresh token - let (old_token_id, claims) = - TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?; - - // Revoke the old token (rotation) - TokenService::revoke_token(old_token_id, claims.sub, db).await?; + // Atomically validate and revoke the old refresh token (prevents TOCTOU race) + let claims = + TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?; // Fetch fresh roles and permissions let roles: Vec = TokenService::get_user_roles(claims.sub, claims.tid, db).await?; diff --git a/crates/erp-auth/src/service/token_service.rs b/crates/erp-auth/src/service/token_service.rs index 350f7f4..62ea928 100644 --- a/crates/erp-auth/src/service/token_service.rs +++ b/crates/erp-auth/src/service/token_service.rs @@ -175,6 +175,45 @@ impl TokenService { 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 { + 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. pub async fn revoke_all_user_tokens( user_id: Uuid,