admin_service.rs
322 lignes · 12770 octets
/// Admin Service Middleware /// /// Tower Service wrapper that applies admin_required middleware to /admin/* routes. /// This works around Axum 0.8 Sync limitations with from_fn_with_state. use axum::{ extract::Request, response::{IntoResponse, Response}, }; use sea_orm::DatabaseConnection; use std::{ future::Future, pin::Pin, sync::Arc, task::{Context, Poll}, }; use tower::Service; use super::auth_service; use crate::config::auth::AuthConfig; /// Service wrapper that applies admin_required middleware to /admin/* routes /// /// The [`AuthConfig`] used to verify the JWT is resolved by /// `auth_service::resolve_auth_config`: the config captured at construction /// first, then `Extension(Arc<AuthConfig>)`, then /// [`AuthConfig::from_env`]. The last two steps are the legacy path of the /// deprecated [`AdminService::new`] constructor and ignore builder overrides /// whenever this middleware is layered outside the `Extension` layer. #[derive(Clone)] pub struct AdminService<S> { inner: S, db: DatabaseConnection, /// Config captured at construction. `None` only on the deprecated /// [`AdminService::new`] path. auth_config: Option<Arc<AuthConfig>>, } impl<S> AdminService<S> { /// Create a new `AdminService` middleware wrapping the given inner service. #[deprecated( since = "1.2.0", note = "use `AdminService::with_auth_config(inner, db, auth_config)`. Removal no earlier than 1.5.0, effective in the next major." )] pub fn new(inner: S, db: DatabaseConnection) -> Self { Self { inner, db, auth_config: None, } } /// Create an `AdminService` that carries its own resolved /// [`AuthConfig`] — the supported constructor since RFC 0042. /// /// The config is captured **once at construction**, the same pattern /// `security_headers_middleware` uses in /// [`crate::app::RustwardenBuilder::build`]. Consequences: /// /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`) /// are honoured regardless of tower layer order. A middleware layered /// *outside* `Extension(Arc<AuthConfig>)` can never observe that /// extension, which is what made [`AdminService::new`] fall back to /// [`AuthConfig::from_env`] and discard those overrides. /// - The captured config takes precedence over any /// `Extension(Arc<AuthConfig>)` present in the request. /// - On every request the middleware inserts the captured `Arc` into the /// request extensions, so downstream extractors (`AuthUser`, /// `auth_required`, `admin_required`) observe the same instance even in /// hand-composed tower stacks. In the canonical wiring the inner /// `Extension` layer re-inserts the very same `Arc`, a no-op. /// /// ```no_run /// # use std::sync::Arc; /// # use rustwarden_core::config::auth::AuthConfig; /// # use rustwarden_core::middleware::admin_service::AdminService; /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { /// let auth_config = Arc::new(AuthConfig::from_env()?); /// let app = router.layer(tower::layer::layer_fn(move |inner| { /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone()) /// })); /// # let _ = app; Ok(()) } /// ``` pub fn with_auth_config( inner: S, db: DatabaseConnection, auth_config: Arc<AuthConfig>, ) -> Self { Self { inner, db, auth_config: Some(auth_config), } } } impl<S, B> Service<Request<B>> for AdminService<S> where S: Service<Request<B>, Response = Response> + Clone + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = Response; type Error = S::Error; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut req: Request<B>) -> Self::Future { let uri = req.uri().clone(); // Seed the captured config into the request extensions so downstream // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve // the exact same instance. In the canonical wiring the later // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which // is a no-op; seeding only matters for hand-composed tower stacks. if let Some(config) = &self.auth_config { req.extensions_mut().insert(config.clone()); } let captured = self.auth_config.clone(); let path = uri.path(); // Only apply admin_required middleware to /admin/* and /api/v1/admin/* routes if path.starts_with("/admin") || path.starts_with("/api/v1/admin") { let db = self.db.clone(); let mut inner = self.inner.clone(); tracing::debug!("AdminService: intercepting admin route: {}", path); Box::pin(async move { // Extract JWT and validate it // We use parts to extract JWT (no need for body) let (parts, body) = req.into_parts(); use crate::auth::jwt::JwtService; use crate::services::jwt_blacklist_service::JwtBlacklistService; // Resolve auth config up-front so every auth-failure branch // honours `login_path` / `auth_failure_mode`. let auth_config = match crate::middleware::auth_service::resolve_auth_config( captured.as_ref(), &parts.extensions, ) { Ok(config) => config, Err(err) => { tracing::error!( "AdminService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}" ); return Ok(( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Internal server error", ) .into_response()); } }; // Extract JWT token from parts let jwt_token = match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { Ok(token) => { tracing::debug!("AdminService: JWT token found"); token } Err(_) => { tracing::warn!("AdminService: No JWT token found"); return Ok(auth_service::auth_failure_response(&auth_config, &uri)); } }; if JwtBlacklistService::is_blacklisted(&db, &jwt_token) .await .is_ok_and(|b| b) { return Ok(auth_service::auth_failure_response(&auth_config, &uri)); } let claims = match JwtService::validate_token(&auth_config, &jwt_token) { Ok(claims) => claims, Err(_) => { return Ok(auth_service::auth_failure_response(&auth_config, &uri)); } }; // Check if user has admin role if !claims.roles.contains(&"admin".to_string()) { tracing::warn!( "Non-admin user {} attempted to access admin route: {}", claims.sub, uri ); return Ok(( axum::http::StatusCode::FORBIDDEN, "Access denied: Admin role required", ) .into_response()); } tracing::debug!("Admin user {} accessing: {}", claims.sub, uri); // Store claims in request extensions for handlers to use let mut parts_with_claims = parts; parts_with_claims.extensions.insert(claims.clone()); tracing::debug!( "AdminService: Claims inserted into extensions for user {}", claims.sub ); let req_with_claims = Request::from_parts(parts_with_claims, body); // Call inner service inner.call(req_with_claims).await }) } else { // Not an admin route, proceed normally let mut inner = self.inner.clone(); Box::pin(async move { inner.call(req).await }) } } } #[cfg(test)] mod tests { use super::AdminService; use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; use axum::body::Body; use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION}; use axum::response::Response; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; use std::sync::Arc; use tower::ServiceExt; // for `oneshot` fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { AuthConfig { jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), jwt_expiration_minutes: 15, jwt_issuer: "test".to_string(), refresh_token_expiration_days: 7, remember_me_expiration_days: 30, session_timeout_minutes: 30, session_backend: SessionBackend::Memory, redis_url: None, rate_limit_login_per_minute: 5, rate_limit_refresh_per_minute: 10, rate_limit_general_per_minute: 100, cookie_secure: false, cookie_same_site: "Strict".to_string(), login_path: login_path.to_string(), auth_failure_mode: mode, } } fn mock_db() -> DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres).into_connection() } /// Router wrapped with `AdminService::with_auth_config` and **no** /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only /// source of truth, exactly as RFC 0042 requires. fn admin_router(cfg: AuthConfig) -> axum::Router { let db = mock_db(); let cfg = Arc::new(cfg); axum::Router::new() .route("/admin", axum::routing::get(|| async { "admin" })) .route( "/api/v1/admin/x", axum::routing::get(|| async { "admin api" }), ) .layer(tower::layer::layer_fn(move |inner| { AdminService::with_auth_config(inner, db.clone(), cfg.clone()) })) } async fn get(router: axum::Router, uri: &str) -> Response { let req = HttpRequest::builder() .uri(uri) .body(Body::empty()) .expect("request"); router.oneshot(req).await.expect("oneshot") } async fn body_string(resp: Response) -> String { let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) .await .expect("body"); String::from_utf8(bytes.to_vec()).expect("utf-8 body") } /// T7 — both admin prefixes honour the captured `Json401` mode without /// any environment variable or `Extension` layer. #[tokio::test] async fn admin_routes_without_jwt_return_json_401_from_the_captured_config() { for uri in ["/admin", "/api/v1/admin/x"] { let resp = get( admin_router(test_config(AuthFailureMode::Json401, "/login")), uri, ) .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "uri = {uri}"); assert!(resp.headers().get(LOCATION).is_none(), "uri = {uri}"); let body = body_string(resp).await; assert!(body.contains("unauthorized"), "uri = {uri}, body = {body}"); } } /// T7 — `Redirect` mode honours the captured `login_path` override that /// the deprecated `AdminService::new` path used to discard. #[tokio::test] async fn admin_route_redirect_mode_uses_the_captured_login_path() { let resp = get( admin_router(test_config(AuthFailureMode::Redirect, "/auth/signin")), "/admin", ) .await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()), Some("/auth/signin?redirect=%2Fadmin") ); } }
GitRust