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

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