fix: secure email change recovery flow

This commit is contained in:
237899745
2026-07-26 02:03:43 +08:00
parent de5f451cd1
commit 016ce9ebab
12 changed files with 999 additions and 105 deletions

1
Cargo.lock generated
View File

@@ -2215,6 +2215,7 @@ dependencies = [
"time",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",

View File

@@ -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"] }

View File

@@ -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 <token>
"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 <admin_token>
Content-Type: application/json
```
修改用户名只需提交 `username`。修改邮箱必须同时提交当前密码:
```json
{
"email": "new@example.com",
"current_password": "current-password"
}
```
邮箱验证开启时,新邮箱只写入 `pending_email`,确认前主邮箱、登录邮箱和密码恢复地址均保持不变;确认后服务端原子切换主邮箱并撤销全部旧 JWT 与未使用的密码重置链接。邮箱验证关闭时,当前密码校验通过后立即切换邮箱,响应会返回替换当前会话使用的新 `token`
### 11.6 S3 存储端点
```http

View File

@@ -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

View File

@@ -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}`

View File

@@ -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<string | null>(null)
const profileError = ref<string | null>(null)
@@ -24,9 +24,13 @@ const verificationMessage = ref<string | null>(null)
const verificationError = ref<string | null>(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() {
</label>
</div>
<label v-if="emailChangeRequested" class="mt-3 block max-w-md space-y-1">
<div class="text-xs font-medium text-slate-600">当前密码修改邮箱必填</div>
<input
v-model="profileForm.currentPassword"
type="password"
autocomplete="current-password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="用于确认是本人操作"
/>
</label>
<div
v-if="auth.user?.pending_email"
class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"
>
待确认新邮箱{{ auth.user.pending_email }}确认前仍使用当前邮箱登录和找回密码
</div>
<div v-if="profileMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ profileMessage }}
</div>

View File

@@ -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<UserProfile> {
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(

View File

@@ -8,6 +8,7 @@ export interface User {
username: string
role: UserRole
email_verified: boolean
pending_email?: string | null
}
interface StoredAuth {

View File

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

View File

@@ -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",

View File

@@ -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<String>,
}
#[derive(Debug, Serialize)]
@@ -83,13 +85,24 @@ async fn get_profile(
username: String,
role: String,
email_verified_at: Option<DateTime<Utc>>,
pending_email: Option<String>,
}
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<String>,
username: Option<String>,
current_password: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateProfileResponse {
user: UserView,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
}
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<DateTime<Utc>>,
token_version: i32,
pending_email: Option<String>,
}
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<String> = 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::<SocketAddr>()
.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");
}
}

View File

@@ -183,6 +183,13 @@ impl Mailer {
.await
}
pub async fn send_email_change_notice(&self, to: &str) -> Result<(), AppError> {
let text = "您的 ImageForge 账号邮箱已完成修改。若非本人操作,请立即联系管理员并重置密码。";
let html = "<h2>账号邮箱已修改</h2><p>您的 ImageForge 账号邮箱已完成修改。</p><p>若非本人操作,请立即联系管理员并重置密码。</p>";
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();