diff --git a/docs/email.md b/docs/email.md index e6613ba..4e64129 100644 --- a/docs/email.md +++ b/docs/email.md @@ -360,7 +360,7 @@ Content-Type: application/json { "success": true, "data": { "message": "邮箱验证成功", "session_invalidated": false } } ``` -邮箱变更复用该确认入口,但申请变更必须先通过当前密码校验。新邮箱确认前不会替换 `users.email`,因此不能作为密码恢复地址;确认时会原子切换邮箱、提升 `token_version`、撤销未使用的密码重置链接,并向旧邮箱发送安全通知。邮箱变更响应的 `session_invalidated` 为 `true`,客户端应要求重新登录。 +邮箱变更复用该确认入口,但申请变更必须先通过当前密码校验。新邮箱确认前不会替换 `users.email`,因此不能作为密码恢复地址;确认时会原子切换邮箱、提升 `token_version`、撤销未使用的密码重置链接,并向旧邮箱发送安全通知。反向地,成功修改或重置密码也会在同一用户行锁事务内撤销所有未使用重置链接和待确认邮箱变更。邮箱变更响应的 `session_invalidated` 为 `true`,客户端应要求重新登录。 ### 6.3 请求密码重置 diff --git a/docs/security.md b/docs/security.md index a3d8286..4ed90da 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,7 +31,7 @@ ### 2.2 JWT 使用建议 - 对外 API:支持 Bearer Token(适合 CLI/SDK)。 - 网站(Vue3):优先使用 HttpOnly Cookie 承载会话(降低 XSS 泄露风险),如使用 localStorage 必须配合严格 CSP。 -- JWT 包含用户 `token_version`;修改或重置密码会递增版本,使此前签发的 JWT 立即失效。 +- JWT 包含用户 `token_version`;修改或重置密码会递增版本,使此前签发的 JWT 立即失效。成功修改或重置密码时,服务端持有用户行锁并在同一事务中消费该用户全部未使用重置链接、撤销全部待确认邮箱变更,避免旧恢复凭据再次接管账号。 --- diff --git a/src/api/auth.rs b/src/api/auth.rs index 7e627a0..556dd37 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -856,14 +856,7 @@ async fn reset_password( .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?; - sqlx::query( - "UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL", - ) - .bind(token_hash) - .bind(now) - .execute(&mut *tx) - .await - .map_err(|err| AppError::new(ErrorCode::Internal, "更新重置记录失败").with_source(err))?; + credentials::invalidate_account_recovery(&mut tx, user_id, now).await?; tx.commit() .await diff --git a/src/api/user.rs b/src/api/user.rs index ee5ebf1..000f2e5 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -474,26 +474,39 @@ async fn update_password( password_hash: String, } - let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1") - .bind(user_id) - .fetch_one(&state.db) + let new_hash = credentials::hash_password(&req.new_password).await?; + let now = Utc::now(); + let mut tx = state + .db + .begin() .await - .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + let row = sqlx::query_as::<_, PasswordRow>( + "SELECT password_hash FROM users WHERE id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; if !credentials::verify_password(&req.current_password, &row.password_hash).await? { return Err(AppError::new(ErrorCode::Unauthorized, "密码错误")); } - let new_hash = credentials::hash_password(&req.new_password).await?; sqlx::query( "UPDATE users SET password_hash = $2, token_version = token_version + 1, updated_at = NOW() WHERE id = $1", ) .bind(user_id) .bind(new_hash) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?; + credentials::invalidate_account_recovery(&mut tx, user_id, now).await?; + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交密码更新失败").with_source(err))?; + Ok(Json(Envelope { success: true, data: MessageResponse { @@ -1411,6 +1424,301 @@ mod tests { .expect("query test admin"); assert_eq!(admin, (admin_pending_email, "admin".to_string())); + let reset_user_id = Uuid::new_v4(); + let reset_old_email = format!("reset-old-{marker}@example.test"); + let reset_new_email = format!("reset-new-{marker}@example.test"); + let reset_token_a = format!("reset-a-{marker}"); + let reset_token_b = format!("reset-b-{marker}"); + let reset_email_token = format!("reset-email-{marker}"); + sqlx::query( + r#" + INSERT INTO users (id, email, username, password_hash, email_verified_at) + VALUES ($1, $2, $3, $4, NOW()) + "#, + ) + .bind(reset_user_id) + .bind(&reset_old_email) + .bind(format!("reset_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert multi-reset user"); + sqlx::query( + r#" + INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES + ($1, $2, NOW() + INTERVAL '1 hour'), + ($1, $3, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(reset_user_id) + .bind(credentials::sha256_hex(&reset_token_a)) + .bind(credentials::sha256_hex(&reset_token_b)) + .execute(&pool) + .await + .expect("insert two password resets"); + sqlx::query( + r#" + INSERT INTO email_change_requests (user_id, new_email, token_hash, expires_at) + VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(reset_user_id) + .bind(&reset_new_email) + .bind(credentials::sha256_hex(&reset_email_token)) + .execute(&pool) + .await + .expect("insert pending email change before reset"); + + let (status, response) = json_request( + &app, + Method::POST, + "/auth/reset-password", + None, + serde_json::json!({ + "token": reset_token_a, + "new_password": "Replacement9!" + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + let (status, response) = json_request( + &app, + Method::POST, + "/auth/reset-password", + None, + serde_json::json!({ + "token": reset_token_b, + "new_password": "SecondReplacement9!" + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); + assert_eq!(response["error"]["code"], "INVALID_TOKEN"); + let (status, response) = json_request( + &app, + Method::POST, + "/auth/verify-email", + None, + serde_json::json!({ "token": reset_email_token }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); + assert_eq!(response["error"]["code"], "INVALID_TOKEN"); + let recovery_state: (i64, i64) = sqlx::query_as( + r#" + SELECT + (SELECT COUNT(*) FROM password_resets WHERE user_id = $1 AND used_at IS NULL), + (SELECT COUNT(*) FROM email_change_requests + WHERE user_id = $1 AND confirmed_at IS NULL AND canceled_at IS NULL) + "#, + ) + .bind(reset_user_id) + .fetch_one(&pool) + .await + .expect("query recovery invalidation state"); + assert_eq!(recovery_state, (0, 0)); + + let password_user_id = Uuid::new_v4(); + let password_email = format!("password-{marker}@example.test"); + let password_reset_token = format!("password-reset-{marker}"); + let password_email_token = format!("password-email-{marker}"); + sqlx::query( + r#" + INSERT INTO users (id, email, username, password_hash, email_verified_at) + VALUES ($1, $2, $3, $4, NOW()) + "#, + ) + .bind(password_user_id) + .bind(&password_email) + .bind(format!("password_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert password-update user"); + sqlx::query( + r#" + INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(password_user_id) + .bind(credentials::sha256_hex(&password_reset_token)) + .execute(&pool) + .await + .expect("insert reset before authenticated password update"); + sqlx::query( + r#" + INSERT INTO email_change_requests (user_id, new_email, token_hash, expires_at) + VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(password_user_id) + .bind(format!("password-new-{marker}@example.test")) + .bind(credentials::sha256_hex(&password_email_token)) + .execute(&pool) + .await + .expect("insert email change before authenticated password update"); + let (password_token, _) = auth::issue_jwt( + &state.config.jwt_secret, + state.config.jwt_expiry_hours, + password_user_id, + "user", + 0, + ) + .expect("issue password-update JWT"); + let (status, response) = json_request( + &app, + Method::PUT, + "/user/password", + Some(&password_token), + serde_json::json!({ + "current_password": password, + "new_password": "AuthenticatedReplacement9!" + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + let (status, response) = json_request( + &app, + Method::POST, + "/auth/reset-password", + None, + serde_json::json!({ + "token": password_reset_token, + "new_password": "StaleReset9!" + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); + assert_eq!(response["error"]["code"], "INVALID_TOKEN"); + let (status, response) = json_request( + &app, + Method::POST, + "/auth/verify-email", + None, + serde_json::json!({ "token": password_email_token }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); + assert_eq!(response["error"]["code"], "INVALID_TOKEN"); + + let reset_race_user_id = Uuid::new_v4(); + let reset_race_old_email = format!("reset-race-old-{marker}@example.test"); + let reset_race_new_email = format!("reset-race-new-{marker}@example.test"); + let reset_race_token = format!("reset-race-{marker}"); + let reset_race_email_token = format!("reset-race-email-{marker}"); + sqlx::query( + r#" + INSERT INTO users (id, email, username, password_hash, email_verified_at) + VALUES ($1, $2, $3, $4, NOW()) + "#, + ) + .bind(reset_race_user_id) + .bind(&reset_race_old_email) + .bind(format!("reset_race_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert reset-email race user"); + sqlx::query( + r#" + INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(reset_race_user_id) + .bind(credentials::sha256_hex(&reset_race_token)) + .execute(&pool) + .await + .expect("insert racing reset"); + sqlx::query( + r#" + INSERT INTO email_change_requests (user_id, new_email, token_hash, expires_at) + VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') + "#, + ) + .bind(reset_race_user_id) + .bind(&reset_race_new_email) + .bind(credentials::sha256_hex(&reset_race_email_token)) + .execute(&pool) + .await + .expect("insert racing email change"); + let mut blocker = pool.begin().await.expect("begin reset-email race blocker"); + let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE") + .bind(reset_race_user_id) + .fetch_one(&mut *blocker) + .await + .expect("lock reset-email race user"); + let reset_join = { + let app = app.clone(); + let token = reset_race_token.clone(); + tokio::spawn(async move { + json_request( + &app, + Method::POST, + "/auth/reset-password", + None, + serde_json::json!({ + "token": token, + "new_password": "RaceReplacement9!" + }), + ) + .await + }) + }; + let confirm_join = { + let app = app.clone(); + let token = reset_race_email_token.clone(); + tokio::spawn(async move { + json_request( + &app, + Method::POST, + "/auth/verify-email", + None, + serde_json::json!({ "token": token }), + ) + .await + }) + }; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + blocker + .commit() + .await + .expect("release reset-email race user"); + let reset_result = reset_join.await.expect("join racing reset"); + let confirm_result = confirm_join.await.expect("join racing confirmation"); + let successes = [reset_result.0, confirm_result.0] + .into_iter() + .filter(|status| *status == StatusCode::OK) + .count(); + assert_eq!(successes, 1, "reset and email confirmation both committed"); + for (status, response) in [&reset_result, &confirm_result] { + if *status != StatusCode::OK { + assert_eq!(*status, StatusCode::BAD_REQUEST, "{response}"); + assert_eq!(response["error"]["code"], "INVALID_TOKEN"); + } + } + let (race_email, race_hash): (String, String) = + sqlx::query_as("SELECT email, password_hash FROM users WHERE id = $1") + .bind(reset_race_user_id) + .fetch_one(&pool) + .await + .expect("query reset-email race result"); + if reset_result.0 == StatusCode::OK { + assert_eq!(race_email, reset_race_old_email); + assert!( + credentials::verify_password("RaceReplacement9!", &race_hash) + .await + .expect("verify racing reset password") + ); + } else { + assert_eq!(race_email, reset_race_new_email); + assert!(credentials::verify_password(password, &race_hash) + .await + .expect("verify original password after email confirmation")); + } + let race_user_id = Uuid::new_v4(); let race_old_email = format!("race-old-{marker}@example.test"); let race_new_email = format!("race-new-{marker}@example.test"); @@ -1502,10 +1810,13 @@ mod tests { .expect("query recovery race result"); assert_eq!(race_result, (race_new_email, 0)); - sqlx::query("DELETE FROM users WHERE id IN ($1, $2, $3)") + sqlx::query("DELETE FROM users WHERE id IN ($1, $2, $3, $4, $5, $6)") .bind(user_id) .bind(admin_id) .bind(race_user_id) + .bind(reset_user_id) + .bind(password_user_id) + .bind(reset_race_user_id) .execute(&pool) .await .expect("delete account recovery test users"); diff --git a/src/services/credentials.rs b/src/services/credentials.rs index 864cb7b..10329e8 100644 --- a/src/services/credentials.rs +++ b/src/services/credentials.rs @@ -2,8 +2,11 @@ use crate::error::{AppError, ErrorCode}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; use rand::RngCore; use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; pub fn validate_email(email: &str) -> Result<(), AppError> { if email.trim().is_empty() || !email.contains('@') { @@ -80,6 +83,34 @@ pub async fn consume_dummy_password_work(password: &str) -> Result<(), AppError> .map_err(|err| AppError::new(ErrorCode::Internal, "密码校验失败").with_source(err)) } +pub async fn invalidate_account_recovery( + tx: &mut Transaction<'_, Postgres>, + user_id: Uuid, + invalidated_at: DateTime, +) -> Result<(), AppError> { + sqlx::query( + r#" + WITH consumed_resets AS ( + UPDATE password_resets + SET used_at = $2 + WHERE user_id = $1 AND used_at IS NULL + RETURNING id + ) + UPDATE email_change_requests + SET canceled_at = $2 + WHERE user_id = $1 + AND confirmed_at IS NULL + AND canceled_at IS NULL + "#, + ) + .bind(user_id) + .bind(invalidated_at) + .execute(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "撤销账号恢复凭据失败").with_source(err))?; + Ok(()) +} + pub fn generate_token() -> String { let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes);