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 { // 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 = 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 { // 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 = 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> { 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 = 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()) } }