From 3afd732de8bcf95cd2386e7c1508fe753cc103c5 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 03:22:04 +0800 Subject: [PATCH] feat(auth): add handlers, JWT middleware, RBAC, and module registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth handlers: login/refresh/logout + user CRUD with tenant isolation - JWT middleware: Bearer token validation → TenantContext injection - RBAC helpers: require_permission, require_any_permission, require_role - AuthModule: implements ErpModule with public/protected route split - AuthState: FromRef pattern avoids circular deps between erp-auth and erp-server - Server: public routes (health+login+refresh) + protected routes (JWT middleware) - ErpModule trait: added as_any() for downcast support - Workspace: added async-trait, sha2 dependencies --- Cargo.toml | 3 + crates/erp-auth/Cargo.toml | 1 + crates/erp-auth/src/auth_state.rs | 77 +++++++++++ crates/erp-auth/src/handler/auth_handler.rs | 90 +++++++++++++ crates/erp-auth/src/handler/mod.rs | 2 + crates/erp-auth/src/handler/user_handler.rs | 137 ++++++++++++++++++++ crates/erp-auth/src/lib.rs | 7 + crates/erp-auth/src/middleware/jwt_auth.rs | 64 +++++++++ crates/erp-auth/src/middleware/mod.rs | 5 + crates/erp-auth/src/middleware/rbac.rs | 96 ++++++++++++++ crates/erp-auth/src/module.rs | 104 +++++++++++++++ crates/erp-core/Cargo.toml | 2 +- crates/erp-core/src/module.rs | 7 + crates/erp-server/Cargo.toml | 2 + crates/erp-server/src/main.rs | 50 +++++-- crates/erp-server/src/state.rs | 35 ++++- 16 files changed, 667 insertions(+), 15 deletions(-) create mode 100644 crates/erp-auth/src/auth_state.rs create mode 100644 crates/erp-auth/src/handler/auth_handler.rs create mode 100644 crates/erp-auth/src/handler/mod.rs create mode 100644 crates/erp-auth/src/handler/user_handler.rs create mode 100644 crates/erp-auth/src/middleware/jwt_auth.rs create mode 100644 crates/erp-auth/src/middleware/mod.rs create mode 100644 crates/erp-auth/src/middleware/rbac.rs create mode 100644 crates/erp-auth/src/module.rs diff --git a/Cargo.toml b/Cargo.toml index 812faaf..b70bda5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,9 @@ utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } # Validation validator = { version = "0.19", features = ["derive"] } +# Async trait +async-trait = "0.1" + # Internal crates erp-core = { path = "crates/erp-core" } erp-common = { path = "crates/erp-common" } diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index 0145b8f..81411c3 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -21,3 +21,4 @@ argon2.workspace = true sha2.workspace = true validator.workspace = true utoipa.workspace = true +async-trait.workspace = true diff --git a/crates/erp-auth/src/auth_state.rs b/crates/erp-auth/src/auth_state.rs new file mode 100644 index 0000000..09c8619 --- /dev/null +++ b/crates/erp-auth/src/auth_state.rs @@ -0,0 +1,77 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; +use uuid::Uuid; + +/// Auth-specific state extracted from the server's AppState via `FromRef`. +/// +/// This avoids a circular dependency between erp-auth and erp-server. +/// The server crate implements `FromRef for AuthState` so that +/// Axum handlers in erp-auth can extract `State` directly. +/// +/// Contains everything the auth handlers need: +/// - Database connection for user/credential lookups +/// - EventBus for publishing domain events +/// - JWT configuration for token signing and validation +/// - Default tenant ID for the bootstrap phase +#[derive(Clone)] +pub struct AuthState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub jwt_secret: String, + pub access_ttl_secs: i64, + pub refresh_ttl_secs: i64, + pub default_tenant_id: Uuid, +} + +/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds. +/// +/// Falls back to parsing the raw string as seconds if no unit suffix is recognized. +pub fn parse_ttl(ttl: &str) -> i64 { + let ttl = ttl.trim(); + if ttl.ends_with('s') { + ttl.trim_end_matches('s').parse::().unwrap_or(900) + } else if ttl.ends_with('m') { + ttl.trim_end_matches('m').parse::().unwrap_or(15) * 60 + } else if ttl.ends_with('h') { + ttl.trim_end_matches('h').parse::().unwrap_or(1) * 3600 + } else if ttl.ends_with('d') { + ttl.trim_end_matches('d').parse::().unwrap_or(1) * 86400 + } else { + ttl.parse::().unwrap_or(900) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ttl_seconds() { + assert_eq!(parse_ttl("900s"), 900); + } + + #[test] + fn parse_ttl_minutes() { + assert_eq!(parse_ttl("15m"), 900); + } + + #[test] + fn parse_ttl_hours() { + assert_eq!(parse_ttl("1h"), 3600); + } + + #[test] + fn parse_ttl_days() { + assert_eq!(parse_ttl("7d"), 604800); + } + + #[test] + fn parse_ttl_raw_number() { + assert_eq!(parse_ttl("300"), 300); + } + + #[test] + fn parse_ttl_fallback_on_invalid() { + assert_eq!(parse_ttl("invalid"), 900); + } +} diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs new file mode 100644 index 0000000..b197f81 --- /dev/null +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -0,0 +1,90 @@ +use axum::Extension; +use axum::extract::{FromRef, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::auth_state::AuthState; +use crate::dto::{LoginReq, LoginResp, RefreshReq}; +use crate::service::auth_service::AuthService; + +/// POST /api/v1/auth/login +/// +/// Authenticates a user with username and password, returning access and refresh tokens. +/// +/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`. +/// In production, this will come from a tenant-resolution middleware. +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let tenant_id = state.default_tenant_id; + + let resp = AuthService::login( + tenant_id, + &req.username, + &req.password, + &state.db, + &state.jwt_secret, + state.access_ttl_secs, + state.refresh_ttl_secs, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +/// POST /api/v1/auth/refresh +/// +/// Validates an existing refresh token, revokes it (rotation), and issues +/// a new access + refresh token pair. +pub async fn refresh( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let resp = AuthService::refresh( + &req.refresh_token, + &state.db, + &state.jwt_secret, + state.access_ttl_secs, + state.refresh_ttl_secs, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +/// POST /api/v1/auth/logout +/// +/// Revokes all refresh tokens for the authenticated user, effectively +/// logging them out on all devices. +pub async fn logout( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("已成功登出".to_string()), + })) +} diff --git a/crates/erp-auth/src/handler/mod.rs b/crates/erp-auth/src/handler/mod.rs new file mode 100644 index 0000000..48477be --- /dev/null +++ b/crates/erp-auth/src/handler/mod.rs @@ -0,0 +1,2 @@ +pub mod auth_handler; +pub mod user_handler; diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs new file mode 100644 index 0000000..2b2492b --- /dev/null +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -0,0 +1,137 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{CreateUserReq, UpdateUserReq, UserResp}; +use crate::middleware::rbac::require_permission; +use crate::service::user_service::UserService; + +/// GET /api/v1/users +/// +/// List users within the current tenant with pagination. +/// Requires the `user.list` permission. +pub async fn list_users( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.list")?; + + let (users, total) = UserService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = (total + page_size - 1) / page_size; + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: users, + total, + page, + page_size, + total_pages, + }))) +} + +/// POST /api/v1/users +/// +/// Create a new user within the current tenant. +/// Requires the `user.create` permission. +pub async fn create_user( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let user = UserService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(user))) +} + +/// GET /api/v1/users/:id +/// +/// Fetch a single user by ID within the current tenant. +/// Requires the `user.read` permission. +pub async fn get_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.read")?; + + let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(user))) +} + +/// PUT /api/v1/users/:id +/// +/// Update editable user fields. +/// Requires the `user.update` permission. +pub async fn update_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.update")?; + + let user = + UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(user))) +} + +/// DELETE /api/v1/users/:id +/// +/// Soft-delete a user by ID within the current tenant. +/// Requires the `user.delete` permission. +pub async fn delete_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.delete")?; + + UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("用户已删除".to_string()), + })) +} diff --git a/crates/erp-auth/src/lib.rs b/crates/erp-auth/src/lib.rs index 3151d51..350cacf 100644 --- a/crates/erp-auth/src/lib.rs +++ b/crates/erp-auth/src/lib.rs @@ -1,4 +1,11 @@ +pub mod auth_state; pub mod dto; pub mod entity; pub mod error; +pub mod handler; +pub mod middleware; +pub mod module; pub mod service; + +pub use auth_state::AuthState; +pub use module::AuthModule; diff --git a/crates/erp-auth/src/middleware/jwt_auth.rs b/crates/erp-auth/src/middleware/jwt_auth.rs new file mode 100644 index 0000000..13a48a1 --- /dev/null +++ b/crates/erp-auth/src/middleware/jwt_auth.rs @@ -0,0 +1,64 @@ +use axum::body::Body; +use axum::http::Request; +use axum::middleware::Next; +use axum::response::Response; +use erp_core::error::AppError; +use erp_core::types::TenantContext; + +use crate::service::token_service::TokenService; + +/// JWT authentication middleware function. +/// +/// Extracts the `Bearer` token from the `Authorization` header, validates it +/// using `TokenService::decode_token`, and injects a `TenantContext` into the +/// request extensions so downstream handlers can access tenant/user identity. +/// +/// The `jwt_secret` parameter is passed explicitly by the server crate at +/// middleware construction time, avoiding any circular dependency between +/// erp-auth and erp-server. +/// +/// # Errors +/// +/// Returns `AppError::Unauthorized` if: +/// - The `Authorization` header is missing +/// - The header value does not start with `"Bearer "` +/// - The token cannot be decoded or has expired +/// - The token type is not "access" +pub async fn jwt_auth_middleware_fn( + jwt_secret: String, + req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + + let token = auth_header + .strip_prefix("Bearer ") + .ok_or(AppError::Unauthorized)?; + + let claims = TokenService::decode_token(token, &jwt_secret) + .map_err(|_| AppError::Unauthorized)?; + + // Verify this is an access token, not a refresh token + if claims.token_type != "access" { + return Err(AppError::Unauthorized); + } + + let ctx = TenantContext { + tenant_id: claims.tid, + user_id: claims.sub, + roles: claims.roles, + permissions: claims.permissions, + }; + + // Reconstruct the request with the TenantContext injected into extensions. + // We cannot borrow `req` mutably after reading headers, so we rebuild. + let (parts, body) = req.into_parts(); + let mut req = Request::from_parts(parts, body); + req.extensions_mut().insert(ctx); + + Ok(next.run(req).await) +} diff --git a/crates/erp-auth/src/middleware/mod.rs b/crates/erp-auth/src/middleware/mod.rs new file mode 100644 index 0000000..971496e --- /dev/null +++ b/crates/erp-auth/src/middleware/mod.rs @@ -0,0 +1,5 @@ +pub mod jwt_auth; +pub mod rbac; + +pub use jwt_auth::jwt_auth_middleware_fn; +pub use rbac::{require_any_permission, require_permission, require_role}; diff --git a/crates/erp-auth/src/middleware/rbac.rs b/crates/erp-auth/src/middleware/rbac.rs new file mode 100644 index 0000000..cbc9f01 --- /dev/null +++ b/crates/erp-auth/src/middleware/rbac.rs @@ -0,0 +1,96 @@ +use erp_core::error::AppError; +use erp_core::types::TenantContext; + +/// Check whether the `TenantContext` includes the specified permission code. +/// +/// Returns `Ok(())` if the permission is present, or `AppError::Forbidden` otherwise. +pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), AppError> { + if ctx.permissions.iter().any(|p| p == permission) { + Ok(()) + } else { + Err(AppError::Forbidden(format!("需要权限: {}", permission))) + } +} + +/// Check whether the `TenantContext` includes at least one of the specified permission codes. +/// +/// Useful when multiple permissions can grant access to the same resource. +pub fn require_any_permission( + ctx: &TenantContext, + permissions: &[&str], +) -> Result<(), AppError> { + let has_any = permissions + .iter() + .any(|p| ctx.permissions.iter().any(|up| up == *p)); + + if has_any { + Ok(()) + } else { + Err(AppError::Forbidden(format!( + "需要以下权限之一: {}", + permissions.join(", ") + ))) + } +} + +/// Check whether the `TenantContext` includes the specified role code. +/// +/// Returns `Ok(())` if the role is present, or `AppError::Forbidden` otherwise. +pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> { + if ctx.roles.iter().any(|r| r == role) { + Ok(()) + } else { + Err(AppError::Forbidden(format!("需要角色: {}", role))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn test_ctx(roles: Vec<&str>, permissions: Vec<&str>) -> TenantContext { + TenantContext { + tenant_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + roles: roles.into_iter().map(String::from).collect(), + permissions: permissions.into_iter().map(String::from).collect(), + } + } + + #[test] + fn require_permission_succeeds_when_present() { + let ctx = test_ctx(vec![], vec!["user.read", "user.write"]); + assert!(require_permission(&ctx, "user.read").is_ok()); + } + + #[test] + fn require_permission_fails_when_missing() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_permission(&ctx, "user.delete").is_err()); + } + + #[test] + fn require_any_permission_succeeds_with_match() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_any_permission(&ctx, &["user.delete", "user.read"]).is_ok()); + } + + #[test] + fn require_any_permission_fails_with_no_match() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_any_permission(&ctx, &["user.delete", "user.admin"]).is_err()); + } + + #[test] + fn require_role_succeeds_when_present() { + let ctx = test_ctx(vec!["admin", "user"], vec![]); + assert!(require_role(&ctx, "admin").is_ok()); + } + + #[test] + fn require_role_fails_when_missing() { + let ctx = test_ctx(vec!["user"], vec![]); + assert!(require_role(&ctx, "admin").is_err()); + } +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs new file mode 100644 index 0000000..0e10bbe --- /dev/null +++ b/crates/erp-auth/src/module.rs @@ -0,0 +1,104 @@ +use axum::Router; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::ErpModule; + +use crate::handler::{auth_handler, user_handler}; + +/// Auth module implementing the `ErpModule` trait. +/// +/// Manages identity, authentication, and user CRUD within the ERP platform. +/// This module has no dependencies on other business modules. +pub struct AuthModule; + +impl AuthModule { + pub fn new() -> Self { + Self + } + + /// Build public (unauthenticated) routes for the auth module. + /// + /// These routes do not require a valid JWT token. + /// The caller wraps this into whatever state type the application uses. + pub fn public_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/auth/login", axum::routing::post(auth_handler::login)) + .route("/auth/refresh", axum::routing::post(auth_handler::refresh)) + } + + /// Build protected (authenticated) routes for the auth module. + /// + /// These routes require a valid JWT token, verified by the middleware layer. + /// The caller wraps this into whatever state type the application uses. + pub fn protected_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/auth/logout", axum::routing::post(auth_handler::logout)) + .route( + "/users", + axum::routing::get(user_handler::list_users).post(user_handler::create_user), + ) + .route( + "/users/{id}", + axum::routing::get(user_handler::get_user) + .put(user_handler::update_user) + .delete(user_handler::delete_user), + ) + } +} + +impl Default for AuthModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for AuthModule { + fn name(&self) -> &str { + "auth" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + // Auth is a foundational module with no business-module dependencies. + vec![] + } + + fn register_routes(&self, router: Router) -> Router { + // The ErpModule trait uses Router<()> (no state type). + // Actual route registration with typed state is done + // via public_routes() and protected_routes(), called by erp-server. + router + } + + fn register_event_handlers(&self, _bus: &EventBus) { + // Phase 2: subscribe to events from other modules if needed + } + + async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> { + // Phase 2+: create default roles and admin user for new tenant + Ok(()) + } + + async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> { + // Phase 2+: soft-delete all users belonging to the tenant + Ok(()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-core/Cargo.toml b/crates/erp-core/Cargo.toml index 991ebed..bbc2541 100644 --- a/crates/erp-core/Cargo.toml +++ b/crates/erp-core/Cargo.toml @@ -14,4 +14,4 @@ anyhow.workspace = true tracing.workspace = true axum.workspace = true sea-orm.workspace = true -async-trait = "0.1" +async-trait.workspace = true diff --git a/crates/erp-core/src/module.rs b/crates/erp-core/src/module.rs index 326df45..2a40ffc 100644 --- a/crates/erp-core/src/module.rs +++ b/crates/erp-core/src/module.rs @@ -1,3 +1,4 @@ +use std::any::Any; use std::sync::Arc; use axum::Router; @@ -38,6 +39,12 @@ pub trait ErpModule: Send + Sync { async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> { Ok(()) } + + /// Downcast support: return `self` as `&dyn Any` for concrete type access. + /// + /// This allows the server crate to retrieve module-specific methods + /// (e.g. `AuthModule::public_routes()`) that are not part of the trait. + fn as_any(&self) -> &dyn Any; } /// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State) diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 54beb52..af32279 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -23,4 +23,6 @@ utoipa.workspace = true serde_json.workspace = true serde.workspace = true erp-server-migration = { path = "migration" } +erp-auth.workspace = true anyhow.workspace = true +uuid.workspace = true diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index d9be07b..77f0c8c 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -4,13 +4,16 @@ mod handlers; mod state; use axum::Router; +use axum::middleware; use config::AppConfig; -use erp_core::events::EventBus; -use erp_core::module::ModuleRegistry; -use erp_server_migration::MigratorTrait; +use erp_auth::middleware::jwt_auth_middleware_fn; use state::AppState; use tracing_subscriber::EnvFilter; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, ModuleRegistry}; +use erp_server_migration::MigratorTrait; + #[tokio::main] async fn main() -> anyhow::Result<()> { // Load config @@ -41,10 +44,12 @@ async fn main() -> anyhow::Result<()> { // Initialize event bus (capacity 1024 events) let event_bus = EventBus::new(1024); - // Initialize module registry - let registry = ModuleRegistry::new(); - // Phase 2+ will register modules here: - // let registry = registry.register(Box::new(erp_auth::AuthModule::new(db.clone()))); + // Initialize auth module + let auth_module = erp_auth::AuthModule::new(); + tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized"); + + // Initialize module registry and register auth module + let registry = ModuleRegistry::new().register(auth_module); tracing::info!(module_count = registry.modules().len(), "Modules registered"); // Register event handlers @@ -53,6 +58,9 @@ async fn main() -> anyhow::Result<()> { let host = config.server.host.clone(); let port = config.server.port; + // Extract JWT secret for middleware construction + let jwt_secret = config.jwt.secret.clone(); + // Build shared state let state = AppState { db, @@ -61,11 +69,31 @@ async fn main() -> anyhow::Result<()> { module_registry: registry, }; - // Build API router with versioning - let api_v1 = Router::new().merge(handlers::health::health_check_router()); + // --- Build the router --- + // + // The router is split into two layers: + // 1. Public routes: no JWT required (health, login, refresh) + // 2. Protected routes: JWT required (user CRUD, logout) + // + // Both layers share the same AppState. The protected layer wraps routes + // with the jwt_auth_middleware_fn. - // Build application router - let app = Router::new().merge(api_v1).with_state(state); + // Public routes (no authentication) + let public_routes = Router::new() + .merge(handlers::health::health_check_router()) + .merge(erp_auth::AuthModule::public_routes()) + .with_state(state.clone()); + + // Protected routes (JWT authentication required) + let protected_routes = erp_auth::AuthModule::protected_routes() + .layer(middleware::from_fn(move |req, next| { + let secret = jwt_secret.clone(); + async move { jwt_auth_middleware_fn(secret, req, next).await } + })) + .with_state(state.clone()); + + // Merge public + protected into the final application router + let app = Router::new().merge(public_routes).merge(protected_routes); let addr = format!("{}:{}", host, port); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index dda34d6..ea6036c 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -5,8 +5,8 @@ use crate::config::AppConfig; use erp_core::events::EventBus; use erp_core::module::ModuleRegistry; -/// Axum 共享应用状态 -/// 所有 handler 通过 State 获取数据库连接、配置等 +/// Axum shared application state. +/// All handlers access database connections, configuration, etc. through `State`. #[derive(Clone)] pub struct AppState { pub db: DatabaseConnection, @@ -15,9 +15,38 @@ pub struct AppState { pub module_registry: ModuleRegistry, } -/// 允许 handler 直接提取子字段 +/// Allow handlers to extract `DatabaseConnection` directly from `State`. impl FromRef for DatabaseConnection { fn from_ref(state: &AppState) -> Self { state.db.clone() } } + +/// Allow handlers to extract `EventBus` directly from `State`. +impl FromRef for EventBus { + fn from_ref(state: &AppState) -> Self { + state.event_bus.clone() + } +} + +/// Allow erp-auth handlers to extract their required state without depending on erp-server. +/// +/// This bridges the gap: erp-auth defines `AuthState` with the fields it needs, +/// and erp-server fills them from `AppState`. +impl FromRef for erp_auth::AuthState { + fn from_ref(state: &AppState) -> Self { + use erp_auth::auth_state::parse_ttl; + + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + jwt_secret: state.config.jwt.secret.clone(), + access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl), + refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl), + // Default tenant ID: during bootstrap, use a well-known UUID. + // In production, tenant resolution middleware will override this. + default_tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000") + .unwrap(), + } + } +}