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:
@@ -70,6 +70,9 @@ utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
|||||||
# Validation
|
# Validation
|
||||||
validator = { version = "0.19", features = ["derive"] }
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async trait
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
erp-core = { path = "crates/erp-core" }
|
erp-core = { path = "crates/erp-core" }
|
||||||
erp-common = { path = "crates/erp-common" }
|
erp-common = { path = "crates/erp-common" }
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ argon2.workspace = true
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
validator.workspace = true
|
validator.workspace = true
|
||||||
utoipa.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 dto;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod module;
|
||||||
pub mod service;
|
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
|
tracing.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
sea-orm.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 std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -38,6 +39,12 @@ pub trait ErpModule: Send + Sync {
|
|||||||
async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> {
|
async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> {
|
||||||
Ok(())
|
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)
|
/// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State)
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ utoipa.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
erp-server-migration = { path = "migration" }
|
erp-server-migration = { path = "migration" }
|
||||||
|
erp-auth.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ mod handlers;
|
|||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use axum::middleware;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use erp_core::events::EventBus;
|
use erp_auth::middleware::jwt_auth_middleware_fn;
|
||||||
use erp_core::module::ModuleRegistry;
|
|
||||||
use erp_server_migration::MigratorTrait;
|
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::{ErpModule, ModuleRegistry};
|
||||||
|
use erp_server_migration::MigratorTrait;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Load config
|
// Load config
|
||||||
@@ -41,10 +44,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Initialize event bus (capacity 1024 events)
|
// Initialize event bus (capacity 1024 events)
|
||||||
let event_bus = EventBus::new(1024);
|
let event_bus = EventBus::new(1024);
|
||||||
|
|
||||||
// Initialize module registry
|
// Initialize auth module
|
||||||
let registry = ModuleRegistry::new();
|
let auth_module = erp_auth::AuthModule::new();
|
||||||
// Phase 2+ will register modules here:
|
tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized");
|
||||||
// let registry = registry.register(Box::new(erp_auth::AuthModule::new(db.clone())));
|
|
||||||
|
// Initialize module registry and register auth module
|
||||||
|
let registry = ModuleRegistry::new().register(auth_module);
|
||||||
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
||||||
|
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
@@ -53,6 +58,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let host = config.server.host.clone();
|
let host = config.server.host.clone();
|
||||||
let port = config.server.port;
|
let port = config.server.port;
|
||||||
|
|
||||||
|
// Extract JWT secret for middleware construction
|
||||||
|
let jwt_secret = config.jwt.secret.clone();
|
||||||
|
|
||||||
// Build shared state
|
// Build shared state
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
@@ -61,11 +69,31 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
module_registry: registry,
|
module_registry: registry,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build API router with versioning
|
// --- Build the router ---
|
||||||
let api_v1 = Router::new().merge(handlers::health::health_check_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
|
// Public routes (no authentication)
|
||||||
let app = Router::new().merge(api_v1).with_state(state);
|
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 addr = format!("{}:{}", host, port);
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::config::AppConfig;
|
|||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::module::ModuleRegistry;
|
use erp_core::module::ModuleRegistry;
|
||||||
|
|
||||||
/// Axum 共享应用状态
|
/// Axum shared application state.
|
||||||
/// 所有 handler 通过 State<AppState> 获取数据库连接、配置等
|
/// All handlers access database connections, configuration, etc. through `State<AppState>`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: DatabaseConnection,
|
pub db: DatabaseConnection,
|
||||||
@@ -15,9 +15,38 @@ pub struct AppState {
|
|||||||
pub module_registry: ModuleRegistry,
|
pub module_registry: ModuleRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 允许 handler 直接提取子字段
|
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
|
||||||
impl FromRef<AppState> for DatabaseConnection {
|
impl FromRef<AppState> for DatabaseConnection {
|
||||||
fn from_ref(state: &AppState) -> Self {
|
fn from_ref(state: &AppState) -> Self {
|
||||||
state.db.clone()
|
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