feat(auth): add change password API and frontend page

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.
This commit is contained in:
iven
2026-04-15 01:32:18 +08:00
parent d8a0ac7519
commit 7e8fabb095
8 changed files with 267 additions and 1 deletions

View File

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

View File

@@ -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<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<ChangePasswordReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
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()),
}))
}

View File

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

View File

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

View File

@@ -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,
)
)
)]