fix(auth): 修复 Token 刷新并发竞态条件
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL) 替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。 新增 TokenService::validate_and_revoke_atomic 方法,将 JWT 解码、 哈希匹配和 token 撤销合并为单次数据库操作。
This commit is contained in:
@@ -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?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user