From 016ce9ebabba516e6f3d9452965558fe05438aa5 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sun, 26 Jul 2026 02:03:43 +0800 Subject: [PATCH] fix: secure email change recovery flow --- Cargo.lock | 1 + Cargo.toml | 3 + docs/api.md | 19 +- docs/email.md | 4 +- frontend/src/pages/VerifyEmailPage.vue | 6 +- .../pages/dashboard/DashboardSettingsPage.vue | 43 +- frontend/src/services/api.ts | 18 +- frontend/src/stores/auth.ts | 1 + migrations/014_email_change_requests.sql | 25 + src/api/auth.rs | 230 +++++- src/api/user.rs | 742 ++++++++++++++++-- src/services/mail.rs | 12 + 12 files changed, 999 insertions(+), 105 deletions(-) create mode 100644 migrations/014_email_change_requests.sql diff --git a/Cargo.lock b/Cargo.lock index d391d2d..e3e6a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2215,6 +2215,7 @@ dependencies = [ "time", "tokio", "tokio-util", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 5e563de..38625d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,3 +67,6 @@ zip = "2" [target.'cfg(target_os = "linux")'.dependencies] image = { version = "0.25", default-features = false, features = ["avif-native"] } ravif = { version = "0.11", default-features = false, features = ["asm", "threading"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/docs/api.md b/docs/api.md index 24c1325..0339185 100644 --- a/docs/api.md +++ b/docs/api.md @@ -214,9 +214,11 @@ Content-Type: application/json 响应: ```json -{ "success": true, "data": { "message": "邮箱验证成功" } } +{ "success": true, "data": { "message": "邮箱验证成功", "session_invalidated": false } } ``` +同一路径也用于确认邮箱变更。邮箱变更成功时 `session_invalidated=true`,服务端会提升 `token_version`,客户端必须清除旧 JWT 并重新登录。 + ### 4.7 请求密码重置 ```http POST /auth/forgot-password @@ -447,7 +449,9 @@ Authorization: Bearer "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "username": "myusername", - "role": "user" + "role": "user", + "email_verified": true, + "pending_email": null } } ``` @@ -696,6 +700,17 @@ Authorization: Bearer Content-Type: application/json ``` +修改用户名只需提交 `username`。修改邮箱必须同时提交当前密码: + +```json +{ + "email": "new@example.com", + "current_password": "current-password" +} +``` + +邮箱验证开启时,新邮箱只写入 `pending_email`,确认前主邮箱、登录邮箱和密码恢复地址均保持不变;确认后服务端原子切换主邮箱并撤销全部旧 JWT 与未使用的密码重置链接。邮箱验证关闭时,当前密码校验通过后立即切换邮箱,响应会返回替换当前会话使用的新 `token`。 + ### 11.6 S3 存储端点 ```http diff --git a/docs/email.md b/docs/email.md index 238ac8d..e6613ba 100644 --- a/docs/email.md +++ b/docs/email.md @@ -357,9 +357,11 @@ Content-Type: application/json **响应**: ```json -{ "success": true, "data": { "message": "邮箱验证成功" } } +{ "success": true, "data": { "message": "邮箱验证成功", "session_invalidated": false } } ``` +邮箱变更复用该确认入口,但申请变更必须先通过当前密码校验。新邮箱确认前不会替换 `users.email`,因此不能作为密码恢复地址;确认时会原子切换邮箱、提升 `token_version`、撤销未使用的密码重置链接,并向旧邮箱发送安全通知。邮箱变更响应的 `session_invalidated` 为 `true`,客户端应要求重新登录。 + ### 6.3 请求密码重置 ```http diff --git a/frontend/src/pages/VerifyEmailPage.vue b/frontend/src/pages/VerifyEmailPage.vue index af2a8a2..9419d3c 100644 --- a/frontend/src/pages/VerifyEmailPage.vue +++ b/frontend/src/pages/VerifyEmailPage.vue @@ -25,7 +25,11 @@ onMounted(async () => { try { const resp = await verifyEmail(token.value) message.value = resp.message - auth.markEmailVerified() + if (resp.session_invalidated) { + auth.logout() + } else { + auth.markEmailVerified() + } } catch (err) { if (err instanceof ApiError) { error.value = `[${err.code}] ${err.message}` diff --git a/frontend/src/pages/dashboard/DashboardSettingsPage.vue b/frontend/src/pages/dashboard/DashboardSettingsPage.vue index 0555edb..35b2939 100644 --- a/frontend/src/pages/dashboard/DashboardSettingsPage.vue +++ b/frontend/src/pages/dashboard/DashboardSettingsPage.vue @@ -9,7 +9,7 @@ const auth = useAuthStore() const loading = ref(true) -const profileForm = ref({ email: '', username: '' }) +const profileForm = ref({ email: '', username: '', currentPassword: '' }) const profileBusy = ref(false) const profileMessage = ref(null) const profileError = ref(null) @@ -24,9 +24,13 @@ const verificationMessage = ref(null) const verificationError = ref(null) const canResendVerification = computed(() => Boolean(auth.user && !auth.user.email_verified)) +const emailChangeRequested = computed(() => { + const email = profileForm.value.email.trim().toLowerCase() + return Boolean(email && auth.user && email !== auth.user.email) +}) function syncProfile(user: UserProfile) { - profileForm.value = { email: user.email ?? '', username: user.username ?? '' } + profileForm.value = { email: user.email ?? '', username: user.username ?? '', currentPassword: '' } } onMounted(async () => { @@ -77,9 +81,16 @@ async function saveProfile() { try { const email = profileForm.value.email.trim().toLowerCase() const username = profileForm.value.username.trim() - const payload: { email?: string; username?: string } = {} + const payload: { email?: string; username?: string; current_password?: string } = {} - if (email && email !== auth.user.email) payload.email = email + if (email && email !== auth.user.email) { + if (!profileForm.value.currentPassword) { + profileError.value = '修改邮箱需要输入当前密码' + return + } + payload.email = email + payload.current_password = profileForm.value.currentPassword + } if (username && username !== auth.user.username) payload.username = username if (!payload.email && !payload.username) { @@ -88,7 +99,11 @@ async function saveProfile() { } const resp = await updateProfile(auth.token, payload) - auth.updateUser(resp.user) + if (resp.token) { + auth.setAuth(resp.token, resp.user) + } else { + auth.updateUser(resp.user) + } syncProfile(resp.user) profileMessage.value = resp.message } catch (err) { @@ -178,6 +193,24 @@ async function changePassword() { + + +
+ 待确认新邮箱:{{ auth.user.pending_email }}。确认前仍使用当前邮箱登录和找回密码。 +
+
{{ profileMessage }}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 793050a..98991c3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -29,8 +29,14 @@ export async function sendVerification(token: string): Promise<{ message: string return apiJson<{ message: string }>('/api/v1/auth/send-verification', undefined, token, { method: 'POST' }) } -export async function verifyEmail(verificationToken: string): Promise<{ message: string }> { - return apiJson<{ message: string }>('/api/v1/auth/verify-email', { token: verificationToken }, null) +export async function verifyEmail( + verificationToken: string, +): Promise<{ message: string; session_invalidated: boolean }> { + return apiJson<{ message: string; session_invalidated: boolean }>( + '/api/v1/auth/verify-email', + { token: verificationToken }, + null, + ) } export async function forgotPassword(email: string): Promise<{ message: string }> { @@ -49,9 +55,11 @@ export async function getProfile(token: string): Promise { export async function updateProfile( token: string, - payload: { email?: string; username?: string }, -): Promise<{ user: UserProfile; message: string }> { - return apiJson<{ user: UserProfile; message: string }>('/api/v1/user/profile', payload, token, { method: 'PUT' }) + payload: { email?: string; username?: string; current_password?: string }, +): Promise<{ user: UserProfile; message: string; token?: string }> { + return apiJson<{ user: UserProfile; message: string; token?: string }>('/api/v1/user/profile', payload, token, { + method: 'PUT', + }) } export async function updatePassword( diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 12d00b9..948c910 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -8,6 +8,7 @@ export interface User { username: string role: UserRole email_verified: boolean + pending_email?: string | null } interface StoredAuth { diff --git a/migrations/014_email_change_requests.sql b/migrations/014_email_change_requests.sql new file mode 100644 index 0000000..cf47c8a --- /dev/null +++ b/migrations/014_email_change_requests.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS email_change_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + new_email VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + confirmed_at TIMESTAMPTZ, + canceled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_email_change_requests_token + ON email_change_requests(token_hash); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_email_change_requests_pending_user + ON email_change_requests(user_id) + WHERE confirmed_at IS NULL AND canceled_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_email_change_requests_pending_email + ON email_change_requests(new_email) + WHERE confirmed_at IS NULL AND canceled_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_email_change_requests_expires + ON email_change_requests(expires_at) + WHERE confirmed_at IS NULL AND canceled_at IS NULL; diff --git a/src/api/auth.rs b/src/api/auth.rs index 9ab5c93..7e627a0 100644 --- a/src/api/auth.rs +++ b/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, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, -) -> Result>, AppError> { +) -> Result>, 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, +) -> Result, AppError> { + let user_id: Option = 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 = 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 = + 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 = 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", diff --git a/src/api/user.rs b/src/api/user.rs index 1048e39..ee5ebf1 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,5 +1,6 @@ use crate::api::context; use crate::api::envelope::Envelope; +use crate::auth; use crate::error::{AppError, ErrorCode}; use crate::services::billing; use crate::services::{credentials, mail, settings}; @@ -55,6 +56,7 @@ struct UserView { username: String, role: String, email_verified: bool, + pending_email: Option, } #[derive(Debug, Serialize)] @@ -83,13 +85,24 @@ async fn get_profile( username: String, role: String, email_verified_at: Option>, + pending_email: Option, } let user = sqlx::query_as::<_, UserRow>( r#" - SELECT id, email, username, role::text AS role, email_verified_at - FROM users - WHERE id = $1 + SELECT u.id, u.email, u.username, u.role::text AS role, u.email_verified_at, + ( + SELECT r.new_email + FROM email_change_requests r + WHERE r.user_id = u.id + AND r.confirmed_at IS NULL + AND r.canceled_at IS NULL + AND r.expires_at > NOW() + ORDER BY r.created_at DESC + LIMIT 1 + ) AS pending_email + FROM users u + WHERE u.id = $1 "#, ) .bind(user_id) @@ -107,6 +120,7 @@ async fn get_profile( username: user.username, role: user.role, email_verified: user.email_verified_at.is_some() || !verification_required, + pending_email: user.pending_email, }, })) } @@ -115,12 +129,15 @@ async fn get_profile( struct UpdateProfileRequest { email: Option, username: Option, + current_password: Option, } #[derive(Debug, Serialize)] struct UpdateProfileResponse { user: UserView, message: String, + #[serde(skip_serializing_if = "Option::is_none")] + token: Option, } async fn update_profile( @@ -142,50 +159,71 @@ async fn update_profile( return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段")); } + let verification_required = settings::email_verification_required(&state).await?; + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + #[derive(Debug, FromRow)] struct UserRow { id: Uuid, email: String, username: String, + password_hash: String, role: String, email_verified_at: Option>, + token_version: i32, + pending_email: Option, } let user = sqlx::query_as::<_, UserRow>( r#" - SELECT id, email, username, role::text AS role, email_verified_at - FROM users - WHERE id = $1 + SELECT u.id, u.email, u.username, u.password_hash, + u.role::text AS role, u.email_verified_at, u.token_version, + ( + SELECT r.new_email + FROM email_change_requests r + WHERE r.user_id = u.id + AND r.confirmed_at IS NULL + AND r.canceled_at IS NULL + AND r.expires_at > NOW() + ORDER BY r.created_at DESC + LIMIT 1 + ) AS pending_email + FROM users u + WHERE u.id = $1 + FOR UPDATE OF u "#, ) .bind(user_id) - .fetch_one(&state.db) + .fetch_one(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; - let mut next_email = user.email.clone(); - let mut next_username = user.username.clone(); - let mut email_changed = false; - let verification_required = settings::email_verification_required(&state).await?; - - if let Some(email) = req.email.as_ref() { - let email = email.trim().to_lowercase(); - credentials::validate_email(&email)?; - if email != user.email { - next_email = email; - email_changed = true; + let next_email = match req.email.as_ref() { + Some(email) => { + let email = email.trim().to_lowercase(); + credentials::validate_email(&email)?; + email } - } - - if let Some(username) = req.username.as_ref() { - let username = username.trim().to_string(); - credentials::validate_username(&username)?; - if username != user.username { - next_username = username; + None => user.email.clone(), + }; + let next_username = match req.username.as_ref() { + Some(username) => { + let username = username.trim().to_string(); + credentials::validate_username(&username)?; + username } - } + None => user.username.clone(), + }; + let email_changed = next_email != user.email; + let username_changed = next_username != user.username; - if next_email == user.email && next_username == user.username { + if !email_changed && !username_changed { + tx.rollback().await.ok(); return Ok(Json(Envelope { success: true, data: UpdateProfileResponse { @@ -195,46 +233,41 @@ async fn update_profile( username: user.username, role: user.role, email_verified: user.email_verified_at.is_some() || !verification_required, + pending_email: user.pending_email, }, message: "暂无更新".to_string(), + token: None, }, })); } - let mut tx = state - .db - .begin() - .await - .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + if email_changed { + let current_password = req + .current_password + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "修改邮箱需要当前密码"))?; + if !credentials::verify_password(current_password, &user.password_hash).await? { + return Err(AppError::new(ErrorCode::Unauthorized, "当前密码不正确")); + } - let email_verified_at = if email_changed && verification_required { - None - } else if email_changed { - Some(Utc::now()) - } else { - user.email_verified_at - }; - - let updated = sqlx::query_as::<_, UserRow>( - r#" - UPDATE users - SET email = $2, - username = $3, - email_verified_at = $4, - updated_at = NOW() - WHERE id = $1 - RETURNING id, email, username, role::text AS role, email_verified_at - "#, - ) - .bind(user_id) - .bind(&next_email) - .bind(&next_username) - .bind(email_verified_at) - .fetch_one(&mut *tx) - .await - .map_err(map_unique_violation)?; + let email_in_use: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND id <> $2)") + .bind(&next_email) + .bind(user_id) + .fetch_one(&mut *tx) + .await + .map_err(|err| { + AppError::new(ErrorCode::Internal, "检查邮箱失败").with_source(err) + })?; + if email_in_use { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱已存在")); + } + } let mut verification_link: Option = None; + let mut pending_email = user.pending_email.clone(); + let updated: UserRow; if email_changed && verification_required { let token = credentials::generate_token(); let token_hash = credentials::sha256_hex(&token); @@ -242,43 +275,160 @@ async fn update_profile( sqlx::query( r#" - INSERT INTO email_verifications (user_id, token_hash, expires_at) - VALUES ($1, $2, $3) + UPDATE email_change_requests + SET canceled_at = NOW() + WHERE confirmed_at IS NULL + AND canceled_at IS NULL + AND (user_id = $1 OR (new_email = $2 AND expires_at <= NOW())) "#, ) .bind(user_id) + .bind(&next_email) + .execute(&mut *tx) + .await + .map_err(|err| { + AppError::new(ErrorCode::Internal, "撤销旧邮箱变更请求失败").with_source(err) + })?; + + sqlx::query( + r#" + INSERT INTO email_change_requests (user_id, new_email, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(user_id) + .bind(&next_email) .bind(token_hash) .bind(expires_at) .execute(&mut *tx) .await - .map_err(|err| { - AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err) - })?; + .map_err(map_unique_violation)?; + + updated = sqlx::query_as::<_, UserRow>( + r#" + UPDATE users + SET username = $2, updated_at = NOW() + WHERE id = $1 + RETURNING id, email, username, password_hash, role::text AS role, + email_verified_at, token_version, $3::text AS pending_email + "#, + ) + .bind(user_id) + .bind(&next_username) + .bind(&next_email) + .fetch_one(&mut *tx) + .await + .map_err(map_unique_violation)?; + pending_email = Some(next_email.clone()); verification_link = Some(format!( "{}/verify-email?token={}", state.config.public_base_url, token )); + } else if email_changed { + updated = sqlx::query_as::<_, UserRow>( + r#" + UPDATE users + SET email = $2, + username = $3, + email_verified_at = NOW(), + token_version = token_version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING id, email, username, password_hash, role::text AS role, + email_verified_at, token_version, NULL::text AS pending_email + "#, + ) + .bind(user_id) + .bind(&next_email) + .bind(&next_username) + .fetch_one(&mut *tx) + .await + .map_err(map_unique_violation)?; + sqlx::query( + "UPDATE email_change_requests SET canceled_at = NOW() WHERE user_id = $1 AND confirmed_at IS NULL AND canceled_at IS NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "撤销邮箱变更请求失败").with_source(err))?; + sqlx::query( + "UPDATE password_resets SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|err| { + AppError::new(ErrorCode::Internal, "撤销密码重置请求失败").with_source(err) + })?; + pending_email = None; + } else { + updated = sqlx::query_as::<_, UserRow>( + r#" + UPDATE users + SET username = $2, updated_at = NOW() + WHERE id = $1 + RETURNING id, email, username, password_hash, role::text AS role, + email_verified_at, token_version, $3::text AS pending_email + "#, + ) + .bind(user_id) + .bind(&next_username) + .bind(pending_email.as_deref()) + .fetch_one(&mut *tx) + .await + .map_err(map_unique_violation)?; } tx.commit() .await .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; - if let Some(link) = verification_link.as_deref() { - mail::send_verification_email(&state, &updated.email, &updated.username, link) - .await - .map_err(|err| { - AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err) - })?; + let verification_mail_sent = if let Some(link) = verification_link.as_deref() { + match mail::send_verification_email(&state, &next_email, &updated.username, link).await { + Ok(()) => true, + Err(err) => { + tracing::error!(user_id = %user_id, error = ?err, "email change verification delivery failed"); + false + } + } + } else { + true + }; + + if email_changed && !verification_required { + if let Err(err) = mail::send_email_change_notice(&state, &user.email).await { + tracing::warn!(user_id = %user_id, error = ?err, "email change notice delivery failed"); + } } let message = if email_changed && verification_required { - "资料已更新,请验证新邮箱".to_string() + if verification_mail_sent { + "资料已更新,请验证新邮箱;确认前仍使用原邮箱登录和找回密码".to_string() + } else { + "新邮箱已进入待确认状态,但验证邮件发送失败,请重新提交邮箱变更".to_string() + } + } else if email_changed { + "资料已更新,其他登录状态已失效".to_string() } else { "资料已更新".to_string() }; + let token = if email_changed && !verification_required { + Some( + auth::issue_jwt( + &state.config.jwt_secret, + state.config.jwt_expiry_hours, + updated.id, + &updated.role, + updated.token_version, + )? + .0, + ) + } else { + None + }; + Ok(Json(Envelope { success: true, data: UpdateProfileResponse { @@ -288,8 +438,10 @@ async fn update_profile( username: updated.username, role: updated.role, email_verified: updated.email_verified_at.is_some() || !verification_required, + pending_email, }, message, + token, }, })) } @@ -909,3 +1061,453 @@ fn map_unique_violation(err: sqlx::Error) -> AppError { } AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::services::mail::Mailer; + use axum::body::{to_bytes, Body}; + use axum::http::{Method, Request, StatusCode}; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + use tokio::sync::Semaphore; + use tower::ServiceExt; + + async fn json_request( + app: &axum::Router, + method: Method, + uri: &str, + token: Option<&str>, + payload: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header(axum::http::header::CONTENT_TYPE, "application/json"); + if let Some(token) = token { + builder = builder.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")); + } + let mut request = builder + .body(Body::from(payload.to_string())) + .expect("build test request"); + request.extensions_mut().insert(ConnectInfo( + "127.0.0.1:41000" + .parse::() + .expect("parse test address"), + )); + let response = app + .clone() + .oneshot(request) + .await + .expect("execute test request"); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read test response"); + let json = serde_json::from_slice(&body) + .unwrap_or_else(|_| panic!("response is not JSON: {}", String::from_utf8_lossy(&body))); + (status, json) + } + + async fn replace_pending_token(pool: &sqlx::PgPool, user_id: Uuid, token: &str) { + let updated = sqlx::query( + r#" + UPDATE email_change_requests + SET token_hash = $2 + WHERE user_id = $1 + AND confirmed_at IS NULL + AND canceled_at IS NULL + "#, + ) + .bind(user_id) + .bind(credentials::sha256_hex(token)) + .execute(pool) + .await + .expect("replace pending email token"); + assert_eq!(updated.rows_affected(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore = "requires isolated IMAGEFORGE_TEST_DATABASE_URL and IMAGEFORGE_TEST_REDIS_URL"] + async fn pending_email_cannot_take_over_user_or_admin_recovery() { + let database_url = std::env::var("IMAGEFORGE_TEST_DATABASE_URL") + .expect("IMAGEFORGE_TEST_DATABASE_URL must be set"); + assert!( + database_url.to_ascii_lowercase().contains("test"), + "refusing to run destructive integration test outside a test database" + ); + let redis_url = std::env::var("IMAGEFORGE_TEST_REDIS_URL") + .expect("IMAGEFORGE_TEST_REDIS_URL must be set"); + let pool = PgPoolOptions::new() + .max_connections(16) + .connect(&database_url) + .await + .expect("connect test database"); + sqlx::migrate!().run(&pool).await.expect("run migrations"); + sqlx::query( + r#" + INSERT INTO system_config (key, value, description) + VALUES ( + 'auth_config', + '{"email_verification_required":true}'::jsonb, + 'security integration test' + ) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW() + "#, + ) + .execute(&pool) + .await + .expect("enable email verification"); + + let mut config = Config::from_env().expect("load test config"); + config.database_url = database_url; + config.redis_url = redis_url; + config.mail_enabled = false; + config.mail_log_links_when_disabled = false; + let redis = redis::Client::open(config.redis_url.clone()) + .expect("create test redis client") + .get_connection_manager() + .await + .expect("connect test redis"); + let state = AppState { + mailer: Arc::new(Mailer::new(&config).expect("create disabled test mailer")), + image_processing_semaphore: Arc::new(Semaphore::new(2)), + runtime_policy_cache: crate::services::settings::RuntimePolicyCache::new(), + storage_cache: crate::services::storage::StorageCache::new(), + config, + db: pool.clone(), + redis, + }; + let app = axum::Router::new() + .nest("/auth", crate::api::auth::router()) + .merge(router()) + .with_state(state.clone()); + + let marker = Uuid::new_v4().simple().to_string(); + let password = "Original9!"; + let password_hash = credentials::hash_password(password) + .await + .expect("hash test password"); + let user_id = Uuid::new_v4(); + let old_email = format!("old-{marker}@example.test"); + let pending_email = format!("pending-{marker}@example.test"); + sqlx::query( + r#" + INSERT INTO users ( + id, email, username, password_hash, email_verified_at + ) VALUES ($1, $2, $3, $4, NOW()) + "#, + ) + .bind(user_id) + .bind(&old_email) + .bind(format!("user_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert test user"); + let (old_token, _) = auth::issue_jwt( + &state.config.jwt_secret, + state.config.jwt_expiry_hours, + user_id, + "user", + 0, + ) + .expect("issue test jwt"); + + let (status, _) = json_request( + &app, + Method::PUT, + "/user/profile", + Some(&old_token), + serde_json::json!({ "email": pending_email }), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "JWT alone changed email"); + let (status, _) = json_request( + &app, + Method::PUT, + "/user/profile", + Some(&old_token), + serde_json::json!({ + "email": pending_email, + "current_password": "WrongPassword9!" + }), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, response) = json_request( + &app, + Method::PUT, + "/user/profile", + Some(&old_token), + serde_json::json!({ + "email": pending_email, + "current_password": password + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!(response["data"]["user"]["email"], old_email); + assert_eq!(response["data"]["user"]["pending_email"], pending_email); + let persisted_email: String = sqlx::query_scalar("SELECT email FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&pool) + .await + .expect("query persisted email"); + assert_eq!(persisted_email, old_email); + + let (status, _) = json_request( + &app, + Method::POST, + "/auth/forgot-password", + None, + serde_json::json!({ "email": pending_email }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let active_resets: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM password_resets WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count pending-email resets"); + assert_eq!(active_resets, 0, "pending email became a recovery address"); + + let (status, _) = json_request( + &app, + Method::POST, + "/auth/forgot-password", + None, + serde_json::json!({ "email": old_email }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let active_resets: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM password_resets WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count old-email resets"); + assert_eq!( + active_resets, 1, + "old email stopped being the recovery address early" + ); + + let confirm_token = format!("confirm-{marker}"); + replace_pending_token(&pool, user_id, &confirm_token).await; + let (status, response) = json_request( + &app, + Method::POST, + "/auth/verify-email", + None, + serde_json::json!({ "token": confirm_token }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!(response["data"]["session_invalidated"], true); + let active_resets: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM password_resets WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count invalidated resets"); + assert_eq!(active_resets, 0); + let (status, _) = json_request( + &app, + Method::GET, + "/user/profile", + Some(&old_token), + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "old JWT remained valid"); + let (status, response) = json_request( + &app, + Method::POST, + "/auth/login", + None, + serde_json::json!({ "email": pending_email, "password": password }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + + let admin_id = Uuid::new_v4(); + let admin_old_email = format!("admin-old-{marker}@example.test"); + let admin_pending_email = format!("admin-pending-{marker}@example.test"); + sqlx::query( + r#" + INSERT INTO users ( + id, email, username, password_hash, role, email_verified_at + ) VALUES ($1, $2, $3, $4, 'admin', NOW()) + "#, + ) + .bind(admin_id) + .bind(&admin_old_email) + .bind(format!("admin_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert test admin"); + let (admin_token, _) = auth::issue_jwt( + &state.config.jwt_secret, + state.config.jwt_expiry_hours, + admin_id, + "admin", + 0, + ) + .expect("issue admin jwt"); + let (status, _) = json_request( + &app, + Method::PUT, + "/user/profile", + Some(&admin_token), + serde_json::json!({ "email": admin_pending_email }), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, response) = json_request( + &app, + Method::PUT, + "/user/profile", + Some(&admin_token), + serde_json::json!({ + "email": admin_pending_email, + "current_password": password + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!(response["data"]["user"]["email"], admin_old_email); + let admin_confirm_token = format!("admin-confirm-{marker}"); + replace_pending_token(&pool, admin_id, &admin_confirm_token).await; + let (status, response) = json_request( + &app, + Method::POST, + "/auth/verify-email", + None, + serde_json::json!({ "token": admin_confirm_token }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + let (status, _) = json_request( + &app, + Method::GET, + "/user/profile", + Some(&admin_token), + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let admin: (String, String) = + sqlx::query_as("SELECT email, role::text FROM users WHERE id = $1") + .bind(admin_id) + .fetch_one(&pool) + .await + .expect("query test admin"); + assert_eq!(admin, (admin_pending_email, "admin".to_string())); + + 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"); + let race_confirm_token = format!("race-confirm-{marker}"); + sqlx::query( + r#" + INSERT INTO users ( + id, email, username, password_hash, email_verified_at + ) VALUES ($1, $2, $3, $4, NOW()) + "#, + ) + .bind(race_user_id) + .bind(&race_old_email) + .bind(format!("race_{marker}")) + .bind(&password_hash) + .execute(&pool) + .await + .expect("insert recovery race user"); + 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(race_user_id) + .bind(&race_new_email) + .bind(credentials::sha256_hex(&race_confirm_token)) + .execute(&pool) + .await + .expect("insert recovery race email change"); + + let mut blocker = pool.begin().await.expect("begin recovery race blocker"); + let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE") + .bind(race_user_id) + .fetch_one(&mut *blocker) + .await + .expect("lock recovery race user"); + let verify_join = { + let app = app.clone(); + let token = race_confirm_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(100)).await; + let forgot_join = { + let app = app.clone(); + let email = race_old_email.clone(); + tokio::spawn(async move { + json_request( + &app, + Method::POST, + "/auth/forgot-password", + None, + serde_json::json!({ "email": email }), + ) + .await + }) + }; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + blocker.commit().await.expect("release recovery race user"); + let (verify_status, verify_response) = + verify_join.await.expect("join racing email confirmation"); + let (forgot_status, forgot_response) = + forgot_join.await.expect("join racing password recovery"); + assert_eq!(verify_status, StatusCode::OK, "{verify_response}"); + assert_eq!(forgot_status, StatusCode::OK, "{forgot_response}"); + let race_result: (String, i64) = sqlx::query_as( + r#" + SELECT u.email, + COUNT(r.id) FILTER (WHERE r.used_at IS NULL) + FROM users u + LEFT JOIN password_resets r ON r.user_id = u.id + WHERE u.id = $1 + GROUP BY u.email + "#, + ) + .bind(race_user_id) + .fetch_one(&pool) + .await + .expect("query recovery race result"); + assert_eq!(race_result, (race_new_email, 0)); + + sqlx::query("DELETE FROM users WHERE id IN ($1, $2, $3)") + .bind(user_id) + .bind(admin_id) + .bind(race_user_id) + .execute(&pool) + .await + .expect("delete account recovery test users"); + } +} diff --git a/src/services/mail.rs b/src/services/mail.rs index 82ca23a..aef5800 100644 --- a/src/services/mail.rs +++ b/src/services/mail.rs @@ -183,6 +183,13 @@ impl Mailer { .await } + pub async fn send_email_change_notice(&self, to: &str) -> Result<(), AppError> { + let text = "您的 ImageForge 账号邮箱已完成修改。若非本人操作,请立即联系管理员并重置密码。"; + let html = "

账号邮箱已修改

您的 ImageForge 账号邮箱已完成修改。

若非本人操作,请立即联系管理员并重置密码。

"; + self.send_email(to, "ImageForge 账号邮箱变更通知", text, html) + .await + } + async fn send_email( &self, to: &str, @@ -336,6 +343,11 @@ pub async fn send_password_reset_email( .await } +pub async fn send_email_change_notice(state: &AppState, to: &str) -> Result<(), AppError> { + let mailer = resolve_mailer(state).await?; + mailer.send_email_change_notice(to).await +} + pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> { let mailer = resolve_mailer(state).await?; let year = chrono::Utc::now().year().to_string();