diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs index 89c3e32..070a694 100644 --- a/crates/erp-auth/src/handler/auth_handler.rs +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -1,5 +1,6 @@ use axum::Extension; use axum::extract::{FromRef, State}; +use axum::http::HeaderMap; use axum::response::Json; use validator::Validate; @@ -8,7 +9,21 @@ use erp_core::types::{ApiResponse, TenantContext}; use crate::auth_state::AuthState; use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq}; -use crate::service::auth_service::{AuthService, JwtConfig}; +use crate::service::auth_service::{AuthService, JwtConfig, RequestInfo}; + +/// 从请求头中提取客户端信息。 +fn extract_request_info(headers: &HeaderMap) -> RequestInfo { + let ip = headers + .get("x-forwarded-for") + .or_else(|| headers.get("x-real-ip")) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()); + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + RequestInfo { ip, user_agent } +} #[utoipa::path( post, @@ -29,6 +44,7 @@ use crate::service::auth_service::{AuthService, JwtConfig}; /// In production, this will come from a tenant-resolution middleware. pub async fn login( State(state): State, + headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> where @@ -38,6 +54,7 @@ where req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; + let req_info = extract_request_info(&headers); let tenant_id = state.default_tenant_id; let jwt_config = JwtConfig { @@ -53,6 +70,7 @@ where &state.db, &jwt_config, &state.event_bus, + Some(&req_info), ) .await?; @@ -108,13 +126,15 @@ where /// logging them out on all devices. pub async fn logout( State(state): State, + headers: HeaderMap, Extension(ctx): Extension, ) -> Result>, AppError> where AuthState: FromRef, S: Clone + Send + Sync + 'static, { - AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db).await?; + let req_info = extract_request_info(&headers); + AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db, Some(&req_info)).await?; Ok(Json(ApiResponse { success: true, @@ -141,6 +161,7 @@ where /// 用户需要在所有设备上重新登录。 pub async fn change_password( State(state): State, + headers: HeaderMap, Extension(ctx): Extension, Json(req): Json, ) -> Result>, AppError> @@ -151,12 +172,14 @@ where req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; + let req_info = extract_request_info(&headers); AuthService::change_password( ctx.user_id, ctx.tenant_id, &req.current_password, &req.new_password, &state.db, + Some(&req_info), ) .await?; diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index ae21d4c..d65e736 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -5,6 +5,8 @@ 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::audit::AuditLog; +use erp_core::audit_service; use erp_core::events::EventBus; use crate::error::AuthResult; @@ -12,6 +14,12 @@ use crate::error::AuthResult; use super::password; use super::token_service::TokenService; +/// 请求来源信息,用于审计日志记录。 +pub struct RequestInfo { + pub ip: Option, + pub user_agent: Option, +} + /// JWT configuration needed for token signing. pub struct JwtConfig<'a> { pub secret: &'a str, @@ -41,16 +49,32 @@ impl AuthService { db: &sea_orm::DatabaseConnection, jwt: &JwtConfig<'_>, event_bus: &EventBus, + req_info: Option<&RequestInfo>, ) -> AuthResult { // 1. Find user by tenant_id + username - let user_model = user::Entity::find() + let user_model = match 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)?; + { + Some(m) => m, + None => { + // 审计:用户不存在(登录失败) + audit_service::record( + AuditLog::new(tenant_id, None, "user.login_failed", "user") + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + return Err(AuthError::InvalidCredentials); + } + }; // 2. Check user status if user_model.status != "active" { @@ -75,6 +99,16 @@ impl AuthService { .ok_or(AuthError::InvalidCredentials)?; if !password::verify_password(password_plain, stored_hash)? { + // 审计:密码错误(登录失败) + audit_service::record( + AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user") + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; return Err(AuthError::InvalidCredentials); } @@ -130,6 +164,18 @@ impl AuthService { serde_json::json!({ "user_id": user_model.id, "username": user_model.username }), ), db).await; + // 审计:登录成功 + audit_service::record( + AuditLog::new(tenant_id, Some(user_model.id), "user.login", "user") + .with_resource_id(user_model.id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + Ok(LoginResp { access_token, refresh_token, @@ -217,8 +263,23 @@ impl AuthService { user_id: Uuid, tenant_id: Uuid, db: &sea_orm::DatabaseConnection, + req_info: Option<&RequestInfo>, ) -> AuthResult<()> { - TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await + TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + // 审计:登出 + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "user.logout", "user") + .with_resource_id(user_id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + + Ok(()) } /// Change password for the authenticated user. @@ -234,6 +295,7 @@ impl AuthService { current_password: &str, new_password: &str, db: &sea_orm::DatabaseConnection, + req_info: Option<&RequestInfo>, ) -> AuthResult<()> { // 1. Find the user's password credential let cred = user_credential::Entity::find() @@ -271,6 +333,18 @@ impl AuthService { // 4. Revoke all refresh tokens — force re-login on all devices TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + // 审计:密码修改 + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user") + .with_resource_id(user_id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + tracing::info!(user_id = %user_id, "Password changed successfully"); Ok(()) } diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs index 4bdaa83..ac993b6 100644 --- a/crates/erp-auth/src/service/role_service.rs +++ b/crates/erp-auth/src/service/role_service.rs @@ -171,6 +171,8 @@ impl RoleService { .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + let old_json = serde_json::to_value(&model).unwrap_or(serde_json::Value::Null); + let next_ver = check_version(version, model.version) .map_err(|e| AuthError::Validation(e.to_string()))?; @@ -192,8 +194,12 @@ impl RoleService { .await .map_err(|e| AuthError::Validation(e.to_string()))?; + let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null); + audit_service::record( - AuditLog::new(tenant_id, Some(operator_id), "role.update", "role").with_resource_id(id), + AuditLog::new(tenant_id, Some(operator_id), "role.update", "role") + .with_resource_id(id) + .with_changes(Some(old_json), Some(new_json)), db, ) .await; diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index 29e9fa1..8d2f9b6 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -204,6 +204,8 @@ impl UserService { .map_err(|e| AuthError::Validation(e.to_string()))? .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + let old_json = serde_json::to_value(&user_model).unwrap_or(serde_json::Value::Null); + let next_ver = check_version(req.version, user_model.version) .map_err(|e| AuthError::Validation(e.to_string()))?; @@ -233,8 +235,12 @@ impl UserService { .await .map_err(|e| AuthError::Validation(e.to_string()))?; + let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null); + audit_service::record( - AuditLog::new(tenant_id, Some(operator_id), "user.update", "user").with_resource_id(id), + AuditLog::new(tenant_id, Some(operator_id), "user.update", "user") + .with_resource_id(id) + .with_changes(Some(old_json), Some(new_json)), db, ) .await; diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index c859a0c..ed3d0ac 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -1,6 +1,8 @@ use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement}; use uuid::Uuid; +use erp_core::audit::{AuditLog}; +use erp_core::audit_service; use erp_core::error::{AppError, AppResult}; use erp_core::events::EventBus; @@ -51,6 +53,13 @@ impl PluginDataService { .await? .ok_or_else(|| PluginError::DatabaseError("INSERT 未返回结果".to_string()))?; + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "plugin.data.create", entity_name) + .with_resource_id(result.id), + db, + ) + .await; + Ok(PluginDataResp { id: result.id.to_string(), data: result.data, @@ -243,6 +252,13 @@ impl PluginDataService { .await? .ok_or_else(|| AppError::VersionMismatch)?; + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "plugin.data.update", entity_name) + .with_resource_id(id), + db, + ) + .await; + Ok(PluginDataResp { id: result.id.to_string(), data: result.data, @@ -369,6 +385,13 @@ impl PluginDataService { )) .await?; + audit_service::record( + AuditLog::new(tenant_id, None, "plugin.data.delete", entity_name) + .with_resource_id(id), + db, + ) + .await; + Ok(()) }