From b9a51ce6ccb8a4a9559bbea1a16e54e8b91ebc6a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 28 Oct 2025 14:04:41 +0700 Subject: [PATCH] feat: Enhance validation and permissions handling across controllers - Added `ValidatedJson` extractor for automatic JSON validation in `events_controller.rs`, `testimonials_controller.rs`, `mentors_controller.rs`, `gacha_items_controller.rs`, and `hackathon_controller.rs`. - Replaced manual permission checks with `require_permissions!` and `require_auth!` macros in relevant controllers to streamline permission handling. - Introduced `sanitization` utilities in `sanitization.rs` for improved input sanitization. - Added `permission_macros.rs` to encapsulate permission checking logic and reduce boilerplate. - Updated dependencies in `Cargo.toml` to include `serde_json` and `validator`. - Implemented error handling improvements in `notification_service.rs` for better response management. --- Cargo.lock | 3 + .../v1/landing/events/events_controller.rs | 31 ++- .../testimonials/testimonials_controller.rs | 32 ++-- .../src/v1/mentors/mentors_controller.rs | 178 ++++++----------- .../v1/gacha_items/gacha_items_controller.rs | 74 ++----- .../src/v1/gacha_items/gacha_items_service.rs | 10 +- .../src/v1/hackathon/hackathon_controller.rs | 3 +- .../v1/notifications/notification_service.rs | 14 +- imphnen-iam/src/lib.rs | 4 + imphnen-iam/src/permission_macros.rs | 115 +++++++++++ imphnen-libs/Cargo.toml | 2 + imphnen-libs/src/axum/mod.rs | 4 + imphnen-libs/src/axum/validated_json.rs | 111 +++++++++++ imphnen-libs/src/lib.rs | 2 +- imphnen-utils/Cargo.toml | 1 + imphnen-utils/src/lib.rs | 11 ++ imphnen-utils/src/sanitization.rs | 181 ++++++++++++++++++ 17 files changed, 551 insertions(+), 225 deletions(-) create mode 100644 imphnen-iam/src/permission_macros.rs create mode 100644 imphnen-libs/src/axum/validated_json.rs create mode 100644 imphnen-utils/src/sanitization.rs diff --git a/Cargo.lock b/Cargo.lock index cf1d1c5..70b34c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2293,11 +2293,13 @@ dependencies = [ "once_cell", "reqwest", "serde", + "serde_json", "sha2", "surrealdb", "tokio", "urlencoding", "uuid", + "validator", ] [[package]] @@ -2342,6 +2344,7 @@ dependencies = [ "imphnen-entities", "imphnen-libs", "rand 0.9.2", + "regex", "reqwest", "serde", "serde_json", diff --git a/imphnen-cms/src/v1/landing/events/events_controller.rs b/imphnen-cms/src/v1/landing/events/events_controller.rs index d293eca..024b05c 100644 --- a/imphnen-cms/src/v1/landing/events/events_controller.rs +++ b/imphnen-cms/src/v1/landing/events/events_controller.rs @@ -7,12 +7,12 @@ use super::{ }; use axum::extract::{Path, Query}; use axum::response::IntoResponse; -use axum::{Extension, Json, http::HeaderMap}; +use axum::{Extension, http::HeaderMap}; use imphnen_libs::{ AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, - ResponseSuccessDto, + ResponseSuccessDto, ValidatedJson, }; -use imphnen_iam::{PermissionsEnum, permissions_guard}; +use imphnen_iam::{PermissionsEnum, require_permissions}; #[utoipa::path( get, @@ -71,12 +71,11 @@ pub async fn get_event_by_id( pub async fn post_create_event( headers: HeaderMap, Extension(state): Extension, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { - Ok((_claims, state)) => EventsService::create_event(&state, payload).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + EventsService::create_event(&state, payload).await + }) } #[utoipa::path( @@ -98,12 +97,11 @@ pub async fn patch_update_event( headers: HeaderMap, Extension(state): Extension, Path(id): Path, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { - Ok((_claims, state)) => EventsService::update_event(&state, id, payload).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + EventsService::update_event(&state, id, payload).await + }) } #[utoipa::path( @@ -125,8 +123,7 @@ pub async fn delete_event( Extension(state): Extension, Path(id): Path, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { - Ok((_claims, state)) => EventsService::delete_event(&state, id).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::Administrator], { + EventsService::delete_event(&state, id).await + }) } diff --git a/imphnen-cms/src/v1/landing/testimonials/testimonials_controller.rs b/imphnen-cms/src/v1/landing/testimonials/testimonials_controller.rs index 03181b8..b53ee66 100644 --- a/imphnen-cms/src/v1/landing/testimonials/testimonials_controller.rs +++ b/imphnen-cms/src/v1/landing/testimonials/testimonials_controller.rs @@ -7,13 +7,12 @@ use super::{ }; use axum::extract::{Path, Query}; use axum::response::IntoResponse; -use axum::{Extension, Json, http::HeaderMap}; -use imphnen_iam::UsersDetailQueryDto; +use axum::{Extension, http::HeaderMap}; +use imphnen_iam::{UsersDetailQueryDto, require_auth}; use imphnen_libs::{ AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, - ResponseSuccessDto, + ResponseSuccessDto, ValidatedJson, }; -use imphnen_iam::permissions_guard; #[utoipa::path( get, @@ -73,12 +72,11 @@ pub async fn post_create_testimonial( headers: HeaderMap, Extension(state): Extension, Extension(authenticated_user): Extension, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![]).await { - Ok((_claims, state)) => TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await, - Err(response) => response, - } + require_auth!(headers, state, { + TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await + }) } #[utoipa::path( @@ -101,12 +99,11 @@ pub async fn patch_update_testimonial( Path(id): Path, Extension(state): Extension, Extension(authenticated_user): Extension, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![]).await { - Ok((_claims, state)) => TestimonialsService::update_testimonial(&state, id, payload, &authenticated_user).await, - Err(response) => response, - } + require_auth!(headers, state, { + TestimonialsService::update_testimonial(&state, id, payload, &authenticated_user).await + }) } #[utoipa::path( @@ -129,8 +126,7 @@ pub async fn delete_testimonial( Extension(authenticated_user): Extension, Path(id): Path, ) -> impl IntoResponse { - match permissions_guard(headers, Extension(state), vec![]).await { - Ok((_claims, state)) => TestimonialsService::delete_testimonial(&state, id, &authenticated_user).await, - Err(response) => response, - } + require_auth!(headers, state, { + TestimonialsService::delete_testimonial(&state, id, &authenticated_user).await + }) } diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs b/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs index da8c8bf..5fee2a4 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs @@ -4,13 +4,13 @@ use super::{ }; use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto; use ::axum::{ - extract::{Extension, Json, Path, Query}, + extract::{Extension, Path, Query}, http::HeaderMap, response::Response, }; use imphnen_entities::MetaRequestDto; -use imphnen_libs::AppState; -use imphnen_iam::{PermissionsEnum, permissions_guard}; +use imphnen_libs::{AppState, ValidatedJson}; +use imphnen_iam::{PermissionsEnum, require_permissions}; use imphnen_utils::extract_email; #[utoipa::path( @@ -27,7 +27,7 @@ use imphnen_utils::extract_email; )] pub async fn post_register_mentor( Extension(app_state): Extension, - Json(dto): Json, + ValidatedJson(dto): ValidatedJson, ) -> Response { MentorsService::register_mentor(&app_state, dto).await } @@ -56,16 +56,9 @@ pub async fn get_mentor_list( Extension(app_state): Extension, Query(meta): Query, ) -> Response { - match permissions_guard( - headers, - Extension(app_state), - vec![PermissionsEnum::ReadListMentors], - ) - .await - { - Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await, - Err(response) => response, - } + require_permissions!(headers, app_state, [PermissionsEnum::ReadListMentors], { + MentorsService::get_mentor_list(&app_state, meta).await + }) } #[utoipa::path( @@ -89,16 +82,9 @@ pub async fn get_mentor_by_id( Extension(app_state): Extension, Path(id): Path, ) -> Response { - match permissions_guard( - headers, - Extension(app_state), - vec![PermissionsEnum::ReadDetailMentors], - ) - .await - { - Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await, - Err(response) => response, - } + require_permissions!(headers, app_state, [PermissionsEnum::ReadDetailMentors], { + MentorsService::get_mentor_by_id(&app_state, &id).await + }) } #[utoipa::path( @@ -123,18 +109,11 @@ pub async fn put_update_mentor( headers: HeaderMap, Extension(app_state): Extension, Path(id): Path, - Json(dto): Json, + ValidatedJson(dto): ValidatedJson, ) -> Response { - match permissions_guard( - headers, - Extension(app_state), - vec![PermissionsEnum::UpdateMentors], - ) - .await - { - Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await, - Err(response) => response, - } + require_permissions!(headers, app_state, [PermissionsEnum::UpdateMentors], { + MentorsService::update_mentor(&app_state, &id, dto).await + }) } #[utoipa::path( @@ -158,16 +137,9 @@ pub async fn delete_mentor( Extension(app_state): Extension, Path(id): Path, ) -> Response { - match permissions_guard( - headers, - Extension(app_state), - vec![PermissionsEnum::DeleteMentors], - ) - .await - { - Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await, - Err(response) => response, - } + require_permissions!(headers, app_state, [PermissionsEnum::DeleteMentors], { + MentorsService::delete_mentor(&app_state, &id).await + }) } #[utoipa::path( @@ -192,18 +164,11 @@ pub async fn put_verify_mentor( headers: HeaderMap, Extension(app_state): Extension, Path(id): Path, - Json(dto): Json, + ValidatedJson(dto): ValidatedJson, ) -> Response { - match permissions_guard( - headers, - Extension(app_state), - vec![PermissionsEnum::VerifyMentors], - ) - .await - { - Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await, - Err(response) => response, - } + require_permissions!(headers, app_state, [PermissionsEnum::VerifyMentors], { + MentorsService::verify_mentor(&app_state, &id, dto).await + }) } #[utoipa::path( @@ -224,27 +189,18 @@ pub async fn get_mentor_me( headers: HeaderMap, Extension(app_state): Extension, ) -> Response { - match permissions_guard( - headers.clone(), - Extension(app_state), - vec![PermissionsEnum::ReadOwnMentorProfile], - ) - .await - { - Ok((_user, app_state)) => { - let email = match extract_email(&headers) { - Some(email) => email, - None => { - return imphnen_utils::common_response( - axum::http::StatusCode::UNAUTHORIZED, - "Token tidak valid", - ); - } - }; - MentorsService::get_mentor_me(&app_state, &email).await - } - Err(response) => response, - } + require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorProfile], { + let email = match extract_email(&headers) { + Some(email) => email, + None => { + return imphnen_utils::common_response( + axum::http::StatusCode::UNAUTHORIZED, + "Token tidak valid", + ); + } + }; + MentorsService::get_mentor_me(&app_state, &email).await + }) } #[utoipa::path( @@ -266,29 +222,20 @@ pub async fn get_mentor_me( pub async fn put_update_mentor_me( headers: HeaderMap, Extension(app_state): Extension, - Json(dto): Json, + ValidatedJson(dto): ValidatedJson, ) -> Response { - match permissions_guard( - headers.clone(), - Extension(app_state), - vec![PermissionsEnum::UpdateOwnMentorProfile], - ) - .await - { - Ok((_user, app_state)) => { - let email = match extract_email(&headers) { - Some(email) => email, - None => { - return imphnen_utils::common_response( - axum::http::StatusCode::UNAUTHORIZED, - "Token tidak valid", - ); - } - }; - MentorsService::update_mentor_me(&app_state, &email, dto).await - } - Err(response) => response, - } + require_permissions!(headers.clone(), app_state, [PermissionsEnum::UpdateOwnMentorProfile], { + let email = match extract_email(&headers) { + Some(email) => email, + None => { + return imphnen_utils::common_response( + axum::http::StatusCode::UNAUTHORIZED, + "Token tidak valid", + ); + } + }; + MentorsService::update_mentor_me(&app_state, &email, dto).await + }) } #[utoipa::path( put, @@ -324,25 +271,16 @@ pub async fn get_mentor_status( headers: HeaderMap, Extension(app_state): Extension, ) -> Response { - match permissions_guard( - headers.clone(), - Extension(app_state), - vec![PermissionsEnum::ReadOwnMentorStatus], - ) - .await - { - Ok((_user, app_state)) => { - let email = match extract_email(&headers) { - Some(email) => email, - None => { - return imphnen_utils::common_response( - axum::http::StatusCode::UNAUTHORIZED, - "Token tidak valid", - ); - } - }; - MentorsService::get_mentor_status(&app_state, &email).await - } - Err(response) => response, - } + require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorStatus], { + let email = match extract_email(&headers) { + Some(email) => email, + None => { + return imphnen_utils::common_response( + axum::http::StatusCode::UNAUTHORIZED, + "Token tidak valid", + ); + } + }; + MentorsService::get_mentor_status(&app_state, &email).await + }) } diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs index dc06e56..9070049 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs @@ -4,12 +4,13 @@ use crate::v1::gacha_items::GachaItemDto; use crate::v1::gacha_items::gacha_items_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto}; use crate::v1::gacha_items::gacha_items_service::GachaItemService; use axum::{ - Extension, Json, + Extension, extract::{Path, Query}, http::HeaderMap, response::IntoResponse, }; -use imphnen_iam::{PermissionsEnum, permissions_guard}; +use imphnen_iam::{PermissionsEnum, require_permissions}; +use imphnen_libs::ValidatedJson; #[utoipa::path( get, @@ -36,16 +37,9 @@ pub async fn get_gacha_item_list( Extension(state): Extension, Query(meta): Query, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![PermissionsEnum::ReadListGachaItems], - ) - .await - { - Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::ReadListGachaItems], { + GachaItemService::get_gacha_item_list(&state, meta).await + }) } #[utoipa::path( @@ -65,16 +59,9 @@ pub async fn get_gacha_item_by_id( Extension(state): Extension, Path(id): Path, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![PermissionsEnum::ReadDetailGachaItems], - ) - .await - { - Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaItems], { + GachaItemService::get_gacha_item_by_id(&state, id).await + }) } #[utoipa::path( @@ -92,18 +79,11 @@ pub async fn get_gacha_item_by_id( pub async fn post_create_gacha_item( headers: HeaderMap, Extension(state): Extension, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![PermissionsEnum::CreateGachaItems], - ) - .await - { - Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::CreateGachaItems], { + GachaItemService::create_gacha_item(&state, payload).await + }) } #[utoipa::path( @@ -122,18 +102,11 @@ pub async fn put_update_gacha_item( headers: HeaderMap, Extension(state): Extension, Path(id): Path, - Json(payload): Json, + ValidatedJson(payload): ValidatedJson, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![PermissionsEnum::UpdateGachaItems], - ) - .await - { - Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::UpdateGachaItems], { + GachaItemService::update_gacha_item(&state, payload, id).await + }) } #[utoipa::path( @@ -152,14 +125,7 @@ pub async fn delete_gacha_item( Extension(state): Extension, Path(id): Path, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![PermissionsEnum::DeleteGachaItems], - ) - .await - { - Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await, - Err(response) => response, - } + require_permissions!(headers, state, [PermissionsEnum::DeleteGachaItems], { + GachaItemService::delete_gacha_item(&state, id).await + }) } diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs index 0c006ab..af5138b 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs @@ -1,6 +1,6 @@ use crate::AppState; use imphnen_entities::{MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto}; -use imphnen_utils::{common_response, make_thing, success_list_response, success_response, validate_request}; +use imphnen_utils::{common_response, make_thing, success_list_response, success_response}; use crate::v1::gacha_items::GachaItemDto; use crate::v1::gacha_items::gacha_items_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto}; use crate::v1::gacha_items::gacha_items_repository::GachaItemRepository; @@ -44,9 +44,7 @@ impl GachaItemService { state: &AppState, payload: GachaItemRequestDto, ) -> Response { - if let Err((status, message)) = validate_request(&payload) { - return common_response(status, &message); - } + // Validation is now automatic via ValidatedJson extractor let repo = GachaItemRepository::new(state); let schema = GachaItemSchema { id: make_thing(&ResourceEnum::GachaItems.to_string(), &payload.name), // Fixed: Use payload.name or some other identifier @@ -65,9 +63,7 @@ impl GachaItemService { payload: GachaItemUpdateRequestDto, id: String, ) -> Response { - if let Err((status, message)) = validate_request(&payload) { - return common_response(status, &message); - } + // Validation is now automatic via ValidatedJson extractor let repo = GachaItemRepository::new(state); // Get current gacha item data first diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs index 7c0f63d..adde93e 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs @@ -10,7 +10,8 @@ use super::hackathon_schema::SubmissionStatus; use crate::v1::hackathon::HackathonRepository; use crate::{AppState, ResponseSuccessDto, ErrorDto}; use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto}; -use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto}; +use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto, ValidatedJson}; +use imphnen_iam::require_permissions; use axum::{ extract::{Extension, Path, Query}, http::StatusCode, diff --git a/imphnen-hackathon/src/v1/notifications/notification_service.rs b/imphnen-hackathon/src/v1/notifications/notification_service.rs index 9b1c5e0..510e2ca 100644 --- a/imphnen-hackathon/src/v1/notifications/notification_service.rs +++ b/imphnen-hackathon/src/v1/notifications/notification_service.rs @@ -4,14 +4,14 @@ use super::notification_dto::{ UnreadCountResponseDto, }; use super::notification_repository::Repository; -use super::notification_schema::NotificationSchema; use axum::http::{Response, StatusCode}; use axum::response::IntoResponse; use axum::body::Body; use imphnen_entities::common_dto::ResponseSuccessDto; use imphnen_libs::AppState; use imphnen_utils::{ - extract_id, make_thing, response_format::success_response, validator::validate_request, + extract_id, make_thing, response_format::success_response, error_response, + validator::validate_request, AppError, }; pub struct Service<'a> { @@ -28,8 +28,8 @@ impl<'a> Service<'a> { user_email: &str, query: NotificationListQueryDto, ) -> Response { - if let Err((status, message)) = validate_request(&query) { - return (status, message).into_response(); + if let Err((_status, message)) = validate_request(&query) { + return error_response(AppError::ValidationError(message)); } let user_id = make_thing("users", user_email); @@ -48,7 +48,7 @@ impl<'a> Service<'a> { let notifications = match notifications_result { Ok(notifs) => notifs, Err(err) => { - return (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(); + return error_response(AppError::InternalServerError(err.to_string())); } }; @@ -59,7 +59,7 @@ impl<'a> Service<'a> { let total = match total_result { Ok(count) => count, Err(err) => { - return (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(); + return error_response(AppError::InternalServerError(err.to_string())); } }; @@ -68,7 +68,7 @@ impl<'a> Service<'a> { let unread_count = match unread_count_result { Ok(count) => count, Err(err) => { - return (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(); + return error_response(AppError::InternalServerError(err.to_string())); } }; diff --git a/imphnen-iam/src/lib.rs b/imphnen-iam/src/lib.rs index 70f9bc1..197d153 100644 --- a/imphnen-iam/src/lib.rs +++ b/imphnen-iam/src/lib.rs @@ -1,4 +1,5 @@ pub mod v1; +pub mod permission_macros; // Re-export core entity types used throughout the IAM module pub use imphnen_entities::{ @@ -71,6 +72,9 @@ pub use v1::{ permissions_guard, }; +// Export permission macros +pub use permission_macros::{check_permissions, check_authenticated}; + // Export IAM-specific types pub use v1::auth::{ AuthRepository, AuthOtpSchema, diff --git a/imphnen-iam/src/permission_macros.rs b/imphnen-iam/src/permission_macros.rs new file mode 100644 index 0000000..e13e246 --- /dev/null +++ b/imphnen-iam/src/permission_macros.rs @@ -0,0 +1,115 @@ +//! Permission guard utilities and macros to reduce boilerplate +//! +//! This module provides utilities to simplify permission checking in handlers + +use axum::{ + extract::Extension, + http::HeaderMap, + response::Response, +}; +use imphnen_entities::PermissionsEnum; +use crate::AppState; +use crate::permissions_guard; +use imphnen_libs::jsonwebtoken::Claims; + +/// Result type for permission-guarded handlers +pub type PermissionGuardResult = Result<(T, AppState), Response>; + +/// Helper function to extract user and check permissions +/// +/// This is a cleaner wrapper around the existing permissions_guard +pub async fn check_permissions( + headers: HeaderMap, + state: Extension, + required_permissions: Vec, +) -> PermissionGuardResult { + match permissions_guard(headers, state, required_permissions).await { + Ok((user, state)) => Ok((user, state)), + Err(response) => Err(response), + } +} + +/// Helper function for endpoints that don't require specific permissions +/// but still need authentication +pub async fn check_authenticated( + headers: HeaderMap, + state: Extension, +) -> PermissionGuardResult { + check_permissions(headers, state, vec![]).await +} + +/// Macro to reduce boilerplate in permission-guarded handlers +/// +/// # Example +/// ```rust +/// use imphnen_iam::require_permissions; +/// use imphnen_entities::PermissionsEnum; +/// +/// pub async fn get_user_list( +/// headers: HeaderMap, +/// Extension(state): Extension, +/// Query(meta): Query, +/// ) -> Response { +/// require_permissions!(headers, state, [PermissionsEnum::ReadListUsers], { +/// UsersService::get_user_list(&state, meta).await +/// }) +/// } +/// ``` +#[macro_export] +macro_rules! require_permissions { + ($headers:expr, $state:expr, [$($perm:expr),*], $body:block) => { + { + let state_clone = $state.clone(); + match $crate::permissions_guard( + $headers, + axum::extract::Extension(state_clone), + vec![$($perm),*], + ) + .await + { + Ok((_user, _state_inner)) => { + let state = &$state; + $body + } + Err(response) => response, + } + } + }; +} + +/// Macro for authenticated-only handlers (no specific permissions) +#[macro_export] +macro_rules! require_auth { + ($headers:expr, $state:expr, $body:block) => { + { + let state_clone = $state.clone(); + match $crate::permissions_guard($headers, axum::extract::Extension(state_clone), vec![]).await { + Ok((_user, _state_inner)) => { + let state = &$state; + $body + } + Err(response) => response, + } + } + }; +} + +/// Macro for handlers that need access to the authenticated user +#[macro_export] +macro_rules! with_user { + ($headers:expr, $state:expr, [$($perm:expr),*], |$user:ident, $state_var:ident| $body:block) => { + { + let state_clone = $state.clone(); + match $crate::permissions_guard( + $headers, + axum::extract::Extension(state_clone), + vec![$($perm),*], + ) + .await + { + Ok(($user, $state_var)) => $body, + Err(response) => response, + } + } + }; +} diff --git a/imphnen-libs/Cargo.toml b/imphnen-libs/Cargo.toml index 9ac3c5b..651dbdf 100644 --- a/imphnen-libs/Cargo.toml +++ b/imphnen-libs/Cargo.toml @@ -10,6 +10,8 @@ log.workspace = true axum.workspace = true tokio.workspace = true serde.workspace = true +serde_json.workspace = true +validator.workspace = true argon2.workspace = true lettre.workspace = true chrono.workspace = true diff --git a/imphnen-libs/src/axum/mod.rs b/imphnen-libs/src/axum/mod.rs index 0b26768..2af79fa 100644 --- a/imphnen-libs/src/axum/mod.rs +++ b/imphnen-libs/src/axum/mod.rs @@ -3,12 +3,16 @@ //! This module provides utilities for initializing and running an Axum web server //! with SurrealDB connections for both WebSocket and in-memory databases. +pub mod validated_json; + use crate::{surrealdb_init_mem, surrealdb_init_ws, SurrealMemClient, SurrealWsClient}; use axum::{Router, serve}; use std::{future::Future, net::SocketAddr}; use tokio::net::TcpListener; use crate::environment::ENV; +pub use validated_json::ValidatedJson; + /// Initialize and start the Axum server with SurrealDB connections. /// /// This function sets up both WebSocket and in-memory SurrealDB connections, diff --git a/imphnen-libs/src/axum/validated_json.rs b/imphnen-libs/src/axum/validated_json.rs new file mode 100644 index 0000000..bf7326b --- /dev/null +++ b/imphnen-libs/src/axum/validated_json.rs @@ -0,0 +1,111 @@ +//! Custom extractor for automatic JSON validation and sanitization +//! +//! This extractor automatically validates request payloads using the validator crate +//! and returns appropriate error responses if validation fails. + +use axum::{ + extract::{rejection::JsonRejection, FromRequest, Request}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::de::DeserializeOwned; +use serde_json; +use validator::Validate; + +/// Custom extractor that automatically validates JSON payloads +/// +/// # Example +/// ```rust +/// use validated_json::ValidatedJson; +/// use serde::Deserialize; +/// use validator::Validate; +/// +/// #[derive(Deserialize, Validate)] +/// struct CreateUserRequest { +/// #[validate(email)] +/// email: String, +/// #[validate(length(min = 8))] +/// password: String, +/// } +/// +/// async fn create_user( +/// ValidatedJson(payload): ValidatedJson +/// ) -> Response { +/// // payload is already validated +/// // ... your logic here +/// } +/// ``` +pub struct ValidatedJson(pub T); + +impl FromRequest for ValidatedJson +where + T: DeserializeOwned + Validate + 'static, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + // First, extract JSON + let Json(value) = match Json::::from_request(req, state).await { + Ok(value) => value, + Err(rejection) => { + let error_message = format!("Invalid JSON payload: {}", rejection); + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": error_message, + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response()); + } + }; + + // Then, validate it + if let Err(errors) = value.validate() { + let error_messages: Vec = errors + .field_errors() + .iter() + .flat_map(|(field, errors)| { + errors.iter().map(move |error| { + format!( + "{}: {}", + field, + error.message.as_ref().map(|m| m.to_string()).unwrap_or_else(|| error.code.to_string()) + ) + }) + }) + .collect(); + + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation failed", + "details": error_messages, + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response()); + } + + Ok(ValidatedJson(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, Validate)] + struct TestPayload { + #[validate(email)] + email: String, + #[validate(length(min = 8))] + password: String, + } + + // Note: Full integration tests should be done at the application level +} diff --git a/imphnen-libs/src/lib.rs b/imphnen-libs/src/lib.rs index 2fcc726..67f37e6 100644 --- a/imphnen-libs/src/lib.rs +++ b/imphnen-libs/src/lib.rs @@ -27,7 +27,7 @@ pub mod services; pub mod surrealdb; pub use argon::{hash_password, verify_password}; -pub use axum::axum_init; +pub use axum::{axum_init, ValidatedJson}; pub use environment::{ENV, Env}; pub use imphnen_entities::{ MessageResponseDto, diff --git a/imphnen-utils/Cargo.toml b/imphnen-utils/Cargo.toml index 67e0a77..3b74c17 100644 --- a/imphnen-utils/Cargo.toml +++ b/imphnen-utils/Cargo.toml @@ -22,5 +22,6 @@ tracing.workspace = true base64.workspace = true sha2.workspace = true reqwest.workspace = true +regex = "1.11" dotenvy = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/imphnen-utils/src/lib.rs b/imphnen-utils/src/lib.rs index 25fa43c..94e54f7 100644 --- a/imphnen-utils/src/lib.rs +++ b/imphnen-utils/src/lib.rs @@ -19,6 +19,7 @@ pub mod query_builder; pub mod errors; pub mod query_list; pub mod response_format; +pub mod sanitization; pub mod serde_helpers; pub mod validator; @@ -43,6 +44,16 @@ pub use query_builder::{ pub use query_list::QueryListBuilder; pub use errors::AppError; pub use response_format::{common_response, success_created_response, success_list_response, success_response, error_response}; +pub use sanitization::{ + sanitize_html, + sanitize_dangerous_patterns, + sanitize_filename, + sanitize_user_text, + sanitize_email, + sanitize_url, + normalize_whitespace, + contains_path_traversal, +}; pub use serde_helpers::{ deserialize_datetime, option_thing_or_string, diff --git a/imphnen-utils/src/sanitization.rs b/imphnen-utils/src/sanitization.rs new file mode 100644 index 0000000..e2e46f3 --- /dev/null +++ b/imphnen-utils/src/sanitization.rs @@ -0,0 +1,181 @@ +//! Input sanitization utilities for security +//! +//! This module provides utilities to sanitize user input and prevent +//! common security vulnerabilities like XSS, HTML injection, etc. + +use regex::Regex; +use std::sync::LazyLock; + +// Note: HTML escaping is done via char-by-char mapping for better performance +// No regex needed for basic HTML entity escaping + +/// SQL-like injection patterns (even though we use SurrealDB, be safe) +static SQL_INJECTION_PATTERNS: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|exec|script|javascript|onerror|onload)").unwrap() +}); + +/// Path traversal patterns +static PATH_TRAVERSAL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"\.\.(/|\\)").unwrap() +}); + +/// Sanitize HTML by escaping special characters +/// +/// # Example +/// ```rust +/// use imphnen_utils::sanitize_html; +/// +/// let dirty = ""; +/// let clean = sanitize_html(dirty); +/// assert_eq!(clean, "<script>alert('xss')</script>"); +/// ``` +pub fn sanitize_html(input: &str) -> String { + input + .chars() + .map(|c| match c { + '<' => "<".to_string(), + '>' => ">".to_string(), + '"' => """.to_string(), + '\'' => "'".to_string(), + '&' => "&".to_string(), + _ => c.to_string(), + }) + .collect() +} + +/// Sanitize string to prevent potential injection attacks +/// +/// This is a conservative sanitization that removes potentially dangerous patterns +pub fn sanitize_dangerous_patterns(input: &str) -> String { + SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]").into_owned() +} + +/// Check if string contains path traversal attempts +pub fn contains_path_traversal(input: &str) -> bool { + PATH_TRAVERSAL_REGEX.is_match(input) +} + +/// Sanitize a string for safe usage in file names +/// +/// Removes or replaces characters that could cause issues in file systems +pub fn sanitize_filename(input: &str) -> String { + input + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect() +} + +/// Sanitize user input text (removes HTML and dangerous patterns) +/// +/// Use this for fields like names, descriptions, bios, etc. +pub fn sanitize_user_text(input: &str) -> String { + let without_html = sanitize_html(input); + sanitize_dangerous_patterns(&without_html) +} + +/// Trim and normalize whitespace in a string +pub fn normalize_whitespace(input: &str) -> String { + input + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +/// Validate and sanitize email format +pub fn sanitize_email(email: &str) -> Option { + let trimmed = email.trim().to_lowercase(); + + // Basic email validation + if trimmed.contains('@') && trimmed.contains('.') { + Some(trimmed) + } else { + None + } +} + +/// Sanitize URL to prevent javascript: and data: schemes +pub fn sanitize_url(url: &str) -> Option { + let trimmed = url.trim(); + + // Block dangerous URL schemes + let lower = trimmed.to_lowercase(); + if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("vbscript:") { + return None; + } + + // Allow http, https, and relative URLs + if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("/") { + Some(trimmed.to_string()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_html() { + assert_eq!( + sanitize_html(""), + "<script>alert('xss')</script>" + ); + assert_eq!( + sanitize_html("Normal text"), + "Normal text" + ); + } + + #[test] + fn test_sanitize_dangerous_patterns() { + assert!(sanitize_dangerous_patterns("SELECT * FROM users").contains("[FILTERED]")); + assert_eq!( + sanitize_dangerous_patterns("Normal search query"), + "Normal search query" + ); + } + + #[test] + fn test_path_traversal() { + assert!(contains_path_traversal("../../../etc/passwd")); + assert!(contains_path_traversal("..\\windows\\system32")); + assert!(!contains_path_traversal("normal/path/to/file")); + } + + #[test] + fn test_sanitize_filename() { + assert_eq!( + sanitize_filename("file.txt"), + "file_name_.txt" + ); + assert_eq!( + sanitize_filename("normal_file.pdf"), + "normal_file.pdf" + ); + } + + #[test] + fn test_sanitize_url() { + assert_eq!( + sanitize_url("https://example.com"), + Some("https://example.com".to_string()) + ); + assert_eq!(sanitize_url("javascript:alert('xss')"), None); + assert_eq!(sanitize_url("data:text/html,"), None); + } + + #[test] + fn test_normalize_whitespace() { + assert_eq!( + normalize_whitespace(" multiple spaces "), + "multiple spaces" + ); + } +}