fix: revoke stale account recovery credentials

This commit is contained in:
237899745
2026-07-26 03:07:04 +08:00
parent 1f55bd45ca
commit 03d0e43d4d
5 changed files with 352 additions and 17 deletions

View File

@@ -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");