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.
305 lines
10 KiB
Rust
305 lines
10 KiB
Rust
use chrono::Utc;
|
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
|
use uuid::Uuid;
|
|
|
|
use crate::dto::{LoginResp, RoleResp, UserResp};
|
|
use crate::entity::{role, user, user_credential, user_role};
|
|
use crate::error::AuthError;
|
|
use erp_core::events::EventBus;
|
|
|
|
use crate::error::AuthResult;
|
|
|
|
use super::password;
|
|
use super::token_service::TokenService;
|
|
|
|
/// JWT configuration needed for token signing.
|
|
pub struct JwtConfig<'a> {
|
|
pub secret: &'a str,
|
|
pub access_ttl_secs: i64,
|
|
pub refresh_ttl_secs: i64,
|
|
}
|
|
|
|
/// Authentication service handling login, token refresh, and logout.
|
|
pub struct AuthService;
|
|
|
|
impl AuthService {
|
|
/// Authenticate a user and issue access + refresh tokens.
|
|
///
|
|
/// Steps:
|
|
/// 1. Look up user by tenant + username (soft-delete aware)
|
|
/// 2. Verify user status is "active"
|
|
/// 3. Fetch the stored password credential
|
|
/// 4. Verify password hash
|
|
/// 5. Collect roles and permissions
|
|
/// 6. Sign JWT tokens
|
|
/// 7. Update last_login_at
|
|
/// 8. Publish login event
|
|
pub async fn login(
|
|
tenant_id: Uuid,
|
|
username: &str,
|
|
password_plain: &str,
|
|
db: &sea_orm::DatabaseConnection,
|
|
jwt: &JwtConfig<'_>,
|
|
event_bus: &EventBus,
|
|
) -> AuthResult<LoginResp> {
|
|
// 1. Find user by tenant_id + username
|
|
let user_model = user::Entity::find()
|
|
.filter(user::Column::TenantId.eq(tenant_id))
|
|
.filter(user::Column::Username.eq(username))
|
|
.filter(user::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
|
.ok_or(AuthError::InvalidCredentials)?;
|
|
|
|
// 2. Check user status
|
|
if user_model.status != "active" {
|
|
return Err(AuthError::UserDisabled(user_model.status.clone()));
|
|
}
|
|
|
|
// 3. Find password credential
|
|
let cred = user_credential::Entity::find()
|
|
.filter(user_credential::Column::UserId.eq(user_model.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(AuthError::InvalidCredentials)?;
|
|
|
|
// 4. Verify password
|
|
let stored_hash = cred
|
|
.credential_data
|
|
.as_ref()
|
|
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
|
.ok_or(AuthError::InvalidCredentials)?;
|
|
|
|
if !password::verify_password(password_plain, stored_hash)? {
|
|
return Err(AuthError::InvalidCredentials);
|
|
}
|
|
|
|
// 5. Get roles and permissions
|
|
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
|
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
|
|
|
// 6. Sign tokens
|
|
let access_token = TokenService::sign_access_token(
|
|
user_model.id,
|
|
tenant_id,
|
|
roles.clone(),
|
|
permissions,
|
|
jwt.secret,
|
|
jwt.access_ttl_secs,
|
|
)?;
|
|
let (refresh_token, _) = TokenService::sign_refresh_token(
|
|
user_model.id,
|
|
tenant_id,
|
|
db,
|
|
jwt.secret,
|
|
jwt.refresh_ttl_secs,
|
|
)
|
|
.await?;
|
|
|
|
// 7. Update last_login_at
|
|
let mut user_active: user::ActiveModel = user_model.clone().into();
|
|
user_active.last_login_at = Set(Some(Utc::now()));
|
|
user_active.updated_at = Set(Utc::now());
|
|
user_active
|
|
.update(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
// 8. Build response
|
|
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
|
|
let user_resp = UserResp {
|
|
id: user_model.id,
|
|
username: user_model.username.clone(),
|
|
email: user_model.email,
|
|
phone: user_model.phone,
|
|
display_name: user_model.display_name,
|
|
avatar_url: user_model.avatar_url,
|
|
status: user_model.status,
|
|
roles: role_resps,
|
|
version: user_model.version,
|
|
};
|
|
|
|
// 9. Publish event
|
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
|
"user.login",
|
|
tenant_id,
|
|
serde_json::json!({ "user_id": user_model.id, "username": user_model.username }),
|
|
), db).await;
|
|
|
|
Ok(LoginResp {
|
|
access_token,
|
|
refresh_token,
|
|
expires_in: jwt.access_ttl_secs as u64,
|
|
user: user_resp,
|
|
})
|
|
}
|
|
|
|
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
|
|
pub async fn refresh(
|
|
refresh_token_str: &str,
|
|
db: &sea_orm::DatabaseConnection,
|
|
jwt: &JwtConfig<'_>,
|
|
) -> AuthResult<LoginResp> {
|
|
// Validate existing refresh token
|
|
let (old_token_id, claims) =
|
|
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
|
|
|
|
// Revoke the old token (rotation)
|
|
TokenService::revoke_token(old_token_id, db).await?;
|
|
|
|
// Fetch fresh roles and permissions
|
|
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
|
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
|
|
|
// Sign new token pair
|
|
let access_token = TokenService::sign_access_token(
|
|
claims.sub,
|
|
claims.tid,
|
|
roles.clone(),
|
|
permissions,
|
|
jwt.secret,
|
|
jwt.access_ttl_secs,
|
|
)?;
|
|
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
|
claims.sub,
|
|
claims.tid,
|
|
db,
|
|
jwt.secret,
|
|
jwt.refresh_ttl_secs,
|
|
)
|
|
.await?;
|
|
|
|
// Fetch user for the response
|
|
let user_model = user::Entity::find_by_id(claims.sub)
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
|
.ok_or(AuthError::TokenRevoked)?;
|
|
|
|
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
|
|
let user_resp = UserResp {
|
|
id: user_model.id,
|
|
username: user_model.username,
|
|
email: user_model.email,
|
|
phone: user_model.phone,
|
|
display_name: user_model.display_name,
|
|
avatar_url: user_model.avatar_url,
|
|
status: user_model.status,
|
|
roles: role_resps,
|
|
version: user_model.version,
|
|
};
|
|
|
|
Ok(LoginResp {
|
|
access_token,
|
|
refresh_token: new_refresh_token,
|
|
expires_in: jwt.access_ttl_secs as u64,
|
|
user: user_resp,
|
|
})
|
|
}
|
|
|
|
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
|
|
pub async fn logout(
|
|
user_id: Uuid,
|
|
tenant_id: Uuid,
|
|
db: &sea_orm::DatabaseConnection,
|
|
) -> AuthResult<()> {
|
|
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,
|
|
tenant_id: Uuid,
|
|
db: &sea_orm::DatabaseConnection,
|
|
) -> AuthResult<Vec<RoleResp>> {
|
|
let user_roles = user_role::Entity::find()
|
|
.filter(user_role::Column::UserId.eq(user_id))
|
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
|
.all(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
|
if role_ids.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let roles = role::Entity::find()
|
|
.filter(role::Column::Id.is_in(role_ids))
|
|
.filter(role::Column::TenantId.eq(tenant_id))
|
|
.all(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
Ok(roles
|
|
.iter()
|
|
.map(|r| RoleResp {
|
|
id: r.id,
|
|
name: r.name.clone(),
|
|
code: r.code.clone(),
|
|
description: r.description.clone(),
|
|
is_system: r.is_system,
|
|
version: r.version,
|
|
})
|
|
.collect())
|
|
}
|
|
}
|