From 7e8fabb095d815ae44e28cfbcf15b8a234ff55c9 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 15 Apr 2026 01:32:18 +0800 Subject: [PATCH] feat(auth): add change password API and frontend page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add ChangePasswordReq DTO with validation (current + new password) - Add AuthService::change_password() method with credential verification, password rehash, and token revocation - Add POST /api/v1/auth/change-password endpoint with utoipa annotation Frontend: - Add changePassword() API function in auth.ts - Add ChangePassword.tsx page with form validation and confirmation - Add "修改密码" tab in Settings page After password change, all refresh tokens are revoked and the user is redirected to the login page. --- apps/web/src/api/auth.ts | 10 ++ apps/web/src/pages/Settings.tsx | 12 +++ .../web/src/pages/settings/ChangePassword.tsx | 95 +++++++++++++++++++ crates/erp-auth/src/dto.rs | 45 +++++++++ crates/erp-auth/src/handler/auth_handler.rs | 46 ++++++++- crates/erp-auth/src/module.rs | 4 + crates/erp-auth/src/service/auth_service.rs | 54 +++++++++++ crates/erp-server/src/main.rs | 2 + 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/settings/ChangePassword.tsx diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 9d4e2bd..8029eba 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -51,3 +51,13 @@ export async function refresh(refreshToken: string): Promise { export async function logout(): Promise { await client.post('/auth/logout'); } + +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + await client.post('/auth/change-password', { + current_password: currentPassword, + new_password: newPassword, + }); +} diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 7fa38ac..46ffc11 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -7,6 +7,7 @@ import { SettingOutlined, BgColorsOutlined, AuditOutlined, + LockOutlined, } from '@ant-design/icons'; import DictionaryManager from './settings/DictionaryManager'; import LanguageManager from './settings/LanguageManager'; @@ -15,6 +16,7 @@ import NumberingRules from './settings/NumberingRules'; import SystemSettings from './settings/SystemSettings'; import ThemeSettings from './settings/ThemeSettings'; import AuditLogViewer from './settings/AuditLogViewer'; +import ChangePassword from './settings/ChangePassword'; const Settings: React.FC = () => { return ( @@ -100,6 +102,16 @@ const Settings: React.FC = () => { ), children: , }, + { + key: 'change-password', + label: ( + + + 修改密码 + + ), + children: , + }, ]} /> diff --git a/apps/web/src/pages/settings/ChangePassword.tsx b/apps/web/src/pages/settings/ChangePassword.tsx new file mode 100644 index 0000000..167fa12 --- /dev/null +++ b/apps/web/src/pages/settings/ChangePassword.tsx @@ -0,0 +1,95 @@ +import { Form, Input, Button, message, Card, Typography } from 'antd'; +import { LockOutlined } from '@ant-design/icons'; +import { useAuthStore } from '../../stores/auth'; +import { changePassword } from '../../api/auth'; +import { useNavigate } from 'react-router-dom'; + +const { Title } = Typography; + +export default function ChangePassword() { + const [messageApi, contextHolder] = message.useMessage(); + const logout = useAuthStore((s) => s.logout); + const navigate = useNavigate(); + const [form] = Form.useForm(); + + const onFinish = async (values: { + current_password: string; + new_password: string; + confirm_password: string; + }) => { + if (values.new_password !== values.confirm_password) { + messageApi.error('两次输入的新密码不一致'); + return; + } + try { + await changePassword(values.current_password, values.new_password); + messageApi.success('密码修改成功,请重新登录'); + await logout(); + navigate('/login'); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '密码修改失败'; + messageApi.error(errorMsg); + } + }; + + return ( + + {contextHolder} + + 修改密码 + +
+ + } + placeholder="请输入当前密码" + /> + + + } + placeholder="请输入新密码(至少6位)" + /> + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请再次输入新密码" + /> + + + + +
+
+ ); +} diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index fd4e105..0184fc0 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -26,6 +26,15 @@ pub struct RefreshReq { pub refresh_token: String, } +/// 修改密码请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ChangePasswordReq { + #[validate(length(min = 1, message = "当前密码不能为空"))] + pub current_password: String, + #[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))] + pub new_password: String, +} + // --- User DTOs --- #[derive(Debug, Serialize, ToSchema)] @@ -234,6 +243,42 @@ mod tests { assert!(result.is_err()); } + #[test] + fn change_password_req_valid() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "newPassword456".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn change_password_req_empty_current_fails() { + let req = ChangePasswordReq { + current_password: "".to_string(), + new_password: "newPassword456".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn change_password_req_short_new_fails() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "12345".to_string(), // min 6 + }; + assert!(req.validate().is_err()); + } + + #[test] + fn change_password_req_long_new_fails() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "a".repeat(129), // max 128 + }; + assert!(req.validate().is_err()); + } + #[test] fn login_req_empty_password_fails() { let req = LoginReq { diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs index 0a33740..89c3e32 100644 --- a/crates/erp-auth/src/handler/auth_handler.rs +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -7,7 +7,7 @@ use erp_core::error::AppError; use erp_core::types::{ApiResponse, TenantContext}; use crate::auth_state::AuthState; -use crate::dto::{LoginReq, LoginResp, RefreshReq}; +use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq}; use crate::service::auth_service::{AuthService, JwtConfig}; #[utoipa::path( @@ -122,3 +122,47 @@ where message: Some("已成功登出".to_string()), })) } + +#[utoipa::path( + post, + path = "/api/v1/auth/change-password", + request_body = ChangePasswordReq, + responses( + (status = 200, description = "密码修改成功,需重新登录"), + (status = 400, description = "当前密码不正确"), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "认证" +)] +/// POST /api/v1/auth/change-password +/// +/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销, +/// 用户需要在所有设备上重新登录。 +pub async fn change_password( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + AuthService::change_password( + ctx.user_id, + ctx.tenant_id, + &req.current_password, + &req.new_password, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("密码修改成功,请重新登录".to_string()), + })) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 6621020..2d18317 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -43,6 +43,10 @@ impl AuthModule { { Router::new() .route("/auth/logout", axum::routing::post(auth_handler::logout)) + .route( + "/auth/change-password", + axum::routing::post(auth_handler::change_password), + ) .route( "/users", axum::routing::get(user_handler::list_users).post(user_handler::create_user), diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index 0ddc266..7909d3b 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -210,6 +210,60 @@ impl AuthService { TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await } + /// Change password for the authenticated user. + /// + /// Steps: + /// 1. Verify current password + /// 2. Hash the new password + /// 3. Update the credential record + /// 4. Revoke all existing refresh tokens (force re-login) + pub async fn change_password( + user_id: Uuid, + tenant_id: Uuid, + current_password: &str, + new_password: &str, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<()> { + // 1. Find the user's password credential + let cred = user_credential::Entity::find() + .filter(user_credential::Column::UserId.eq(user_id)) + .filter(user_credential::Column::TenantId.eq(tenant_id)) + .filter(user_credential::Column::CredentialType.eq("password")) + .filter(user_credential::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?; + + // 2. Verify current password + let stored_hash = cred + .credential_data + .as_ref() + .and_then(|v| v.get("hash").and_then(|h| h.as_str())) + .ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?; + + if !password::verify_password(current_password, stored_hash)? { + return Err(AuthError::Validation("当前密码不正确".to_string())); + } + + // 3. Hash new password and update credential + let new_hash = password::hash_password(new_password)?; + let mut cred_active: user_credential::ActiveModel = cred.into(); + cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash }))); + cred_active.updated_at = Set(Utc::now()); + cred_active.version = Set(2); + cred_active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 4. Revoke all refresh tokens — force re-login on all devices + TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + tracing::info!(user_id = %user_id, "Password changed successfully"); + Ok(()) + } + /// Fetch role details for a user, returning RoleResp DTOs. async fn get_user_role_resps( user_id: Uuid, diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index af8d276..26dbc92 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -21,6 +21,7 @@ struct ApiDoc; erp_auth::handler::auth_handler::login, erp_auth::handler::auth_handler::refresh, erp_auth::handler::auth_handler::logout, + erp_auth::handler::auth_handler::change_password, erp_auth::handler::user_handler::list_users, erp_auth::handler::user_handler::create_user, erp_auth::handler::user_handler::get_user, @@ -49,6 +50,7 @@ struct ApiDoc; erp_auth::dto::UpdateRoleReq, erp_auth::dto::PermissionResp, erp_auth::dto::AssignPermissionsReq, + erp_auth::dto::ChangePasswordReq, ) ) )]