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:
iven
2026-04-11 03:22:04 +08:00
parent edc41a1500
commit 3afd732de8
16 changed files with 667 additions and 15 deletions

View File

@@ -21,3 +21,4 @@ argon2.workspace = true
sha2.workspace = true
validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true

View 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);
}
}

View 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()),
}))
}

View File

@@ -0,0 +1,2 @@
pub mod auth_handler;
pub mod user_handler;

View 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()),
}))
}

View File

@@ -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;

View 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)
}

View 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};

View 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());
}
}

View 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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?;

View File

@@ -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(),
}
}
}