feat(auth): add handlers, JWT middleware, RBAC, and module registration
- 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
This commit is contained in:
@@ -21,3 +21,4 @@ argon2.workspace = true
|
||||
sha2.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
77
crates/erp-auth/src/auth_state.rs
Normal file
77
crates/erp-auth/src/auth_state.rs
Normal file
@@ -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<AppState> for AuthState` so that
|
||||
/// Axum handlers in erp-auth can extract `State<AuthState>` 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::<i64>().unwrap_or(900)
|
||||
} else if ttl.ends_with('m') {
|
||||
ttl.trim_end_matches('m').parse::<i64>().unwrap_or(15) * 60
|
||||
} else if ttl.ends_with('h') {
|
||||
ttl.trim_end_matches('h').parse::<i64>().unwrap_or(1) * 3600
|
||||
} else if ttl.ends_with('d') {
|
||||
ttl.trim_end_matches('d').parse::<i64>().unwrap_or(1) * 86400
|
||||
} else {
|
||||
ttl.parse::<i64>().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);
|
||||
}
|
||||
}
|
||||
90
crates/erp-auth/src/handler/auth_handler.rs
Normal file
90
crates/erp-auth/src/handler/auth_handler.rs
Normal file
@@ -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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<LoginReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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()),
|
||||
}))
|
||||
}
|
||||
2
crates/erp-auth/src/handler/mod.rs
Normal file
2
crates/erp-auth/src/handler/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth_handler;
|
||||
pub mod user_handler;
|
||||
137
crates/erp-auth/src/handler/user_handler.rs
Normal file
137
crates/erp-auth/src/handler/user_handler.rs
Normal file
@@ -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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
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()),
|
||||
}))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
64
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
64
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
@@ -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<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
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)
|
||||
}
|
||||
5
crates/erp-auth/src/middleware/mod.rs
Normal file
5
crates/erp-auth/src/middleware/mod.rs
Normal file
@@ -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};
|
||||
96
crates/erp-auth/src/middleware/rbac.rs
Normal file
96
crates/erp-auth/src/middleware/rbac.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
104
crates/erp-auth/src/module.rs
Normal file
104
crates/erp-auth/src/module.rs
Normal file
@@ -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<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
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<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::config::AppConfig;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ModuleRegistry;
|
||||
|
||||
/// Axum 共享应用状态
|
||||
/// 所有 handler 通过 State<AppState> 获取数据库连接、配置等
|
||||
/// Axum shared application state.
|
||||
/// All handlers access database connections, configuration, etc. through `State<AppState>`.
|
||||
#[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<AppState>`.
|
||||
impl FromRef<AppState> for DatabaseConnection {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.db.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `EventBus` directly from `State<AppState>`.
|
||||
impl FromRef<AppState> 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<AppState> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user