fix: secure email change recovery flow
This commit is contained in:
230
src/api/auth.rs
230
src/api/auth.rs
@@ -415,12 +415,22 @@ struct VerifyEmailRequest {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VerifyEmailResponse {
|
||||
message: String,
|
||||
session_invalidated: bool,
|
||||
}
|
||||
|
||||
struct EmailChangeConfirmation {
|
||||
old_email: String,
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<VerifyEmailRequest>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
) -> Result<Json<Envelope<VerifyEmailResponse>>, AppError> {
|
||||
if req.token.trim().is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
|
||||
}
|
||||
@@ -440,6 +450,19 @@ async fn verify_email(
|
||||
let token_hash = credentials::sha256_hex(&req.token);
|
||||
let now = Utc::now();
|
||||
|
||||
if let Some(confirmation) = confirm_email_change(&state, &token_hash, now).await? {
|
||||
if let Err(err) = mail::send_email_change_notice(&state, &confirmation.old_email).await {
|
||||
tracing::warn!(error = ?err, "email change notice delivery failed");
|
||||
}
|
||||
return Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: VerifyEmailResponse {
|
||||
message: "新邮箱确认成功,请重新登录".to_string(),
|
||||
session_invalidated: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
WITH v AS (
|
||||
@@ -477,12 +500,149 @@ async fn verify_email(
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
data: VerifyEmailResponse {
|
||||
message: "邮箱验证成功".to_string(),
|
||||
session_invalidated: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn confirm_email_change(
|
||||
state: &AppState,
|
||||
token_hash: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<EmailChangeConfirmation>, AppError> {
|
||||
let user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT user_id
|
||||
FROM email_change_requests
|
||||
WHERE token_hash = $1
|
||||
AND confirmed_at IS NULL
|
||||
AND canceled_at IS NULL
|
||||
AND expires_at > $2
|
||||
"#,
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询邮箱变更请求失败").with_source(err))?;
|
||||
let Some(user_id) = user_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
let user: Option<(String,)> =
|
||||
sqlx::query_as("SELECT email FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?;
|
||||
let Some((old_email,)) = user else {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let request: Option<(Uuid, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, new_email
|
||||
FROM email_change_requests
|
||||
WHERE token_hash = $1
|
||||
AND user_id = $2
|
||||
AND confirmed_at IS NULL
|
||||
AND canceled_at IS NULL
|
||||
AND expires_at > $3
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定邮箱变更请求失败").with_source(err))?;
|
||||
let Some((request_id, new_email)) = request else {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let changed = sqlx::query(
|
||||
r#"
|
||||
UPDATE users u
|
||||
SET email = $2,
|
||||
email_verified_at = $3,
|
||||
token_version = token_version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE u.id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users other
|
||||
WHERE other.email = $2 AND other.id <> u.id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&new_email)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if matches!(&err, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")) {
|
||||
AppError::new(ErrorCode::InvalidRequest, "新邮箱已被其他账号使用")
|
||||
} else {
|
||||
AppError::new(ErrorCode::Internal, "确认新邮箱失败").with_source(err)
|
||||
}
|
||||
})?;
|
||||
if changed.rows_affected() == 0 {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"新邮箱已被其他账号使用",
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE email_change_requests SET confirmed_at = $2 WHERE id = $1 AND confirmed_at IS NULL AND canceled_at IS NULL",
|
||||
)
|
||||
.bind(request_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "完成邮箱变更请求失败").with_source(err))?;
|
||||
sqlx::query(
|
||||
"UPDATE email_change_requests SET canceled_at = $2 WHERE user_id = $1 AND id <> $3 AND confirmed_at IS NULL AND canceled_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.bind(request_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "撤销旧邮箱变更请求失败").with_source(err))?;
|
||||
sqlx::query("UPDATE password_resets SET used_at = $2 WHERE user_id = $1 AND used_at IS NULL")
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "撤销密码重置请求失败").with_source(err)
|
||||
})?;
|
||||
sqlx::query(
|
||||
"UPDATE email_verifications SET verified_at = $2 WHERE user_id = $1 AND verified_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "撤销旧邮箱验证请求失败").with_source(err))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交邮箱变更失败").with_source(err))?;
|
||||
Ok(Some(EmailChangeConfirmation { old_email }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgotPasswordRequest {
|
||||
email: String,
|
||||
@@ -517,6 +677,12 @@ async fn forgot_password(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requested_email = req.email.to_lowercase();
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -530,19 +696,20 @@ async fn forgot_password(
|
||||
token_version
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(req.email.to_lowercase())
|
||||
.fetch_optional(&state.db)
|
||||
.bind(&requested_email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let delivery = if let Some(user) = user {
|
||||
let reset_token = credentials::generate_token();
|
||||
let token_hash = credentials::sha256_hex(&reset_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(1);
|
||||
|
||||
let _ = sqlx::query(
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO password_resets (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -551,16 +718,26 @@ async fn forgot_password(
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "创建密码重置记录失败").with_source(err)
|
||||
})?;
|
||||
|
||||
let reset_url = format!(
|
||||
"{}/reset-password?token={}",
|
||||
state.config.public_base_url, reset_token
|
||||
);
|
||||
Some((user.email, user.username, reset_url))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.commit().await.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "提交密码找回事务失败").with_source(err)
|
||||
})?;
|
||||
|
||||
let _ =
|
||||
mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
|
||||
if let Some((email, username, reset_url)) = delivery {
|
||||
let _ = mail::send_password_reset_email(&state, &email, &username, &reset_url).await;
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
@@ -611,25 +788,23 @@ async fn reset_password(
|
||||
|
||||
let token_hash = credentials::sha256_hex(&req.token);
|
||||
let now = Utc::now();
|
||||
let token_exists: bool = sqlx::query_scalar(
|
||||
let reset_user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM password_resets
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > $2
|
||||
)
|
||||
SELECT user_id
|
||||
FROM password_resets
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > $2
|
||||
"#,
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
|
||||
if !token_exists {
|
||||
let Some(reset_user_id) = reset_user_id else {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
}
|
||||
};
|
||||
|
||||
let password_hash = credentials::hash_password(&req.new_password).await?;
|
||||
|
||||
@@ -639,6 +814,16 @@ async fn reset_password(
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
|
||||
let locked_user: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(reset_user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?;
|
||||
if locked_user.is_none() {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
}
|
||||
|
||||
let user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT user_id
|
||||
@@ -658,6 +843,9 @@ async fn reset_password(
|
||||
let Some(user_id) = user_id else {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
};
|
||||
if user_id != reset_user_id {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET password_hash = $1, token_version = token_version + 1, updated_at = NOW() WHERE id = $2",
|
||||
|
||||
Reference in New Issue
Block a user