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