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.
This commit is contained in:
MythEclipse
2025-10-28 14:04:41 +07:00
parent d4a6c4c9ea
commit b9a51ce6cc
17 changed files with 551 additions and 225 deletions
Generated
+3
View File
@@ -2293,11 +2293,13 @@ dependencies = [
"once_cell", "once_cell",
"reqwest", "reqwest",
"serde", "serde",
"serde_json",
"sha2", "sha2",
"surrealdb", "surrealdb",
"tokio", "tokio",
"urlencoding", "urlencoding",
"uuid", "uuid",
"validator",
] ]
[[package]] [[package]]
@@ -2342,6 +2344,7 @@ dependencies = [
"imphnen-entities", "imphnen-entities",
"imphnen-libs", "imphnen-libs",
"rand 0.9.2", "rand 0.9.2",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -7,12 +7,12 @@ use super::{
}; };
use axum::extract::{Path, Query}; use axum::extract::{Path, Query};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{Extension, Json, http::HeaderMap}; use axum::{Extension, http::HeaderMap};
use imphnen_libs::{ use imphnen_libs::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto, ResponseSuccessDto, ValidatedJson,
}; };
use imphnen_iam::{PermissionsEnum, permissions_guard}; use imphnen_iam::{PermissionsEnum, require_permissions};
#[utoipa::path( #[utoipa::path(
get, get,
@@ -71,12 +71,11 @@ pub async fn get_event_by_id(
pub async fn post_create_event( pub async fn post_create_event(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Json(payload): Json<EventsCreateRequestDto>, ValidatedJson(payload): ValidatedJson<EventsCreateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { require_permissions!(headers, state, [PermissionsEnum::Administrator], {
Ok((_claims, state)) => EventsService::create_event(&state, payload).await, EventsService::create_event(&state, payload).await
Err(response) => response, })
}
} }
#[utoipa::path( #[utoipa::path(
@@ -98,12 +97,11 @@ pub async fn patch_update_event(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<EventsUpdateRequestDto>, ValidatedJson(payload): ValidatedJson<EventsUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { require_permissions!(headers, state, [PermissionsEnum::Administrator], {
Ok((_claims, state)) => EventsService::update_event(&state, id, payload).await, EventsService::update_event(&state, id, payload).await
Err(response) => response, })
}
} }
#[utoipa::path( #[utoipa::path(
@@ -125,8 +123,7 @@ pub async fn delete_event(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![PermissionsEnum::Administrator]).await { require_permissions!(headers, state, [PermissionsEnum::Administrator], {
Ok((_claims, state)) => EventsService::delete_event(&state, id).await, EventsService::delete_event(&state, id).await
Err(response) => response, })
}
} }
@@ -7,13 +7,12 @@ use super::{
}; };
use axum::extract::{Path, Query}; use axum::extract::{Path, Query};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{Extension, Json, http::HeaderMap}; use axum::{Extension, http::HeaderMap};
use imphnen_iam::UsersDetailQueryDto; use imphnen_iam::{UsersDetailQueryDto, require_auth};
use imphnen_libs::{ use imphnen_libs::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto, ResponseSuccessDto, ValidatedJson,
}; };
use imphnen_iam::permissions_guard;
#[utoipa::path( #[utoipa::path(
get, get,
@@ -73,12 +72,11 @@ pub async fn post_create_testimonial(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Extension(authenticated_user): Extension<UsersDetailQueryDto>, Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsCreateRequestDto>, ValidatedJson(payload): ValidatedJson<TestimonialsCreateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![]).await { require_auth!(headers, state, {
Ok((_claims, state)) => TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await, TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await
Err(response) => response, })
}
} }
#[utoipa::path( #[utoipa::path(
@@ -101,12 +99,11 @@ pub async fn patch_update_testimonial(
Path(id): Path<String>, Path(id): Path<String>,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Extension(authenticated_user): Extension<UsersDetailQueryDto>, Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsUpdateRequestDto>, ValidatedJson(payload): ValidatedJson<TestimonialsUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![]).await { require_auth!(headers, state, {
Ok((_claims, state)) => TestimonialsService::update_testimonial(&state, id, payload, &authenticated_user).await, TestimonialsService::update_testimonial(&state, id, payload, &authenticated_user).await
Err(response) => response, })
}
} }
#[utoipa::path( #[utoipa::path(
@@ -129,8 +126,7 @@ pub async fn delete_testimonial(
Extension(authenticated_user): Extension<UsersDetailQueryDto>, Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(headers, Extension(state), vec![]).await { require_auth!(headers, state, {
Ok((_claims, state)) => TestimonialsService::delete_testimonial(&state, id, &authenticated_user).await, TestimonialsService::delete_testimonial(&state, id, &authenticated_user).await
Err(response) => response, })
}
} }
@@ -4,13 +4,13 @@ use super::{
}; };
use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto; use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto;
use ::axum::{ use ::axum::{
extract::{Extension, Json, Path, Query}, extract::{Extension, Path, Query},
http::HeaderMap, http::HeaderMap,
response::Response, response::Response,
}; };
use imphnen_entities::MetaRequestDto; use imphnen_entities::MetaRequestDto;
use imphnen_libs::AppState; use imphnen_libs::{AppState, ValidatedJson};
use imphnen_iam::{PermissionsEnum, permissions_guard}; use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::extract_email; use imphnen_utils::extract_email;
#[utoipa::path( #[utoipa::path(
@@ -27,7 +27,7 @@ use imphnen_utils::extract_email;
)] )]
pub async fn post_register_mentor( pub async fn post_register_mentor(
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUserRegisterRequestDto>, ValidatedJson(dto): ValidatedJson<MentorUserRegisterRequestDto>,
) -> Response { ) -> Response {
MentorsService::register_mentor(&app_state, dto).await MentorsService::register_mentor(&app_state, dto).await
} }
@@ -56,16 +56,9 @@ pub async fn get_mentor_list(
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers, app_state, [PermissionsEnum::ReadListMentors], {
headers, MentorsService::get_mentor_list(&app_state, meta).await
Extension(app_state), })
vec![PermissionsEnum::ReadListMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -89,16 +82,9 @@ pub async fn get_mentor_by_id(
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers, app_state, [PermissionsEnum::ReadDetailMentors], {
headers, MentorsService::get_mentor_by_id(&app_state, &id).await
Extension(app_state), })
vec![PermissionsEnum::ReadDetailMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -123,18 +109,11 @@ pub async fn put_update_mentor(
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<MentorUpdateRequestDto>, ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers, app_state, [PermissionsEnum::UpdateMentors], {
headers, MentorsService::update_mentor(&app_state, &id, dto).await
Extension(app_state), })
vec![PermissionsEnum::UpdateMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -158,16 +137,9 @@ pub async fn delete_mentor(
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers, app_state, [PermissionsEnum::DeleteMentors], {
headers, MentorsService::delete_mentor(&app_state, &id).await
Extension(app_state), })
vec![PermissionsEnum::DeleteMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -192,18 +164,11 @@ pub async fn put_verify_mentor(
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<MentorVerifyRequestDto>, ValidatedJson(dto): ValidatedJson<MentorVerifyRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers, app_state, [PermissionsEnum::VerifyMentors], {
headers, MentorsService::verify_mentor(&app_state, &id, dto).await
Extension(app_state), })
vec![PermissionsEnum::VerifyMentors],
)
.await
{
Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -224,27 +189,18 @@ pub async fn get_mentor_me(
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorProfile], {
headers.clone(), let email = match extract_email(&headers) {
Extension(app_state), Some(email) => email,
vec![PermissionsEnum::ReadOwnMentorProfile], None => {
) return imphnen_utils::common_response(
.await axum::http::StatusCode::UNAUTHORIZED,
{ "Token tidak valid",
Ok((_user, app_state)) => { );
let email = match extract_email(&headers) { }
Some(email) => email, };
None => { MentorsService::get_mentor_me(&app_state, &email).await
return imphnen_utils::common_response( })
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::get_mentor_me(&app_state, &email).await
}
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -266,29 +222,20 @@ pub async fn get_mentor_me(
pub async fn put_update_mentor_me( pub async fn put_update_mentor_me(
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUpdateRequestDto>, ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers.clone(), app_state, [PermissionsEnum::UpdateOwnMentorProfile], {
headers.clone(), let email = match extract_email(&headers) {
Extension(app_state), Some(email) => email,
vec![PermissionsEnum::UpdateOwnMentorProfile], None => {
) return imphnen_utils::common_response(
.await axum::http::StatusCode::UNAUTHORIZED,
{ "Token tidak valid",
Ok((_user, app_state)) => { );
let email = match extract_email(&headers) { }
Some(email) => email, };
None => { MentorsService::update_mentor_me(&app_state, &email, dto).await
return imphnen_utils::common_response( })
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::update_mentor_me(&app_state, &email, dto).await
}
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
put, put,
@@ -324,25 +271,16 @@ pub async fn get_mentor_status(
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>, Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorStatus], {
headers.clone(), let email = match extract_email(&headers) {
Extension(app_state), Some(email) => email,
vec![PermissionsEnum::ReadOwnMentorStatus], None => {
) return imphnen_utils::common_response(
.await axum::http::StatusCode::UNAUTHORIZED,
{ "Token tidak valid",
Ok((_user, app_state)) => { );
let email = match extract_email(&headers) { }
Some(email) => email, };
None => { MentorsService::get_mentor_status(&app_state, &email).await
return imphnen_utils::common_response( })
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::get_mentor_status(&app_state, &email).await
}
Err(response) => response,
}
} }
@@ -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_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto};
use crate::v1::gacha_items::gacha_items_service::GachaItemService; use crate::v1::gacha_items::gacha_items_service::GachaItemService;
use axum::{ use axum::{
Extension, Json, Extension,
extract::{Path, Query}, extract::{Path, Query},
http::HeaderMap, http::HeaderMap,
response::IntoResponse, response::IntoResponse,
}; };
use imphnen_iam::{PermissionsEnum, permissions_guard}; use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_libs::ValidatedJson;
#[utoipa::path( #[utoipa::path(
get, get,
@@ -36,16 +37,9 @@ pub async fn get_gacha_item_list(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( require_permissions!(headers, state, [PermissionsEnum::ReadListGachaItems], {
headers, GachaItemService::get_gacha_item_list(&state, meta).await
Extension(state), })
vec![PermissionsEnum::ReadListGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -65,16 +59,9 @@ pub async fn get_gacha_item_by_id(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( require_permissions!(headers, state, [PermissionsEnum::ReadDetailGachaItems], {
headers, GachaItemService::get_gacha_item_by_id(&state, id).await
Extension(state), })
vec![PermissionsEnum::ReadDetailGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -92,18 +79,11 @@ pub async fn get_gacha_item_by_id(
pub async fn post_create_gacha_item( pub async fn post_create_gacha_item(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Json(payload): Json<GachaItemRequestDto>, ValidatedJson(payload): ValidatedJson<GachaItemRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( require_permissions!(headers, state, [PermissionsEnum::CreateGachaItems], {
headers, GachaItemService::create_gacha_item(&state, payload).await
Extension(state), })
vec![PermissionsEnum::CreateGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -122,18 +102,11 @@ pub async fn put_update_gacha_item(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<GachaItemUpdateRequestDto>, ValidatedJson(payload): ValidatedJson<GachaItemUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( require_permissions!(headers, state, [PermissionsEnum::UpdateGachaItems], {
headers, GachaItemService::update_gacha_item(&state, payload, id).await
Extension(state), })
vec![PermissionsEnum::UpdateGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await,
Err(response) => response,
}
} }
#[utoipa::path( #[utoipa::path(
@@ -152,14 +125,7 @@ pub async fn delete_gacha_item(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( require_permissions!(headers, state, [PermissionsEnum::DeleteGachaItems], {
headers, GachaItemService::delete_gacha_item(&state, id).await
Extension(state), })
vec![PermissionsEnum::DeleteGachaItems],
)
.await
{
Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await,
Err(response) => response,
}
} }
@@ -1,6 +1,6 @@
use crate::AppState; use crate::AppState;
use imphnen_entities::{MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto}; 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::GachaItemDto;
use crate::v1::gacha_items::gacha_items_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto}; use crate::v1::gacha_items::gacha_items_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto};
use crate::v1::gacha_items::gacha_items_repository::GachaItemRepository; use crate::v1::gacha_items::gacha_items_repository::GachaItemRepository;
@@ -44,9 +44,7 @@ impl GachaItemService {
state: &AppState, state: &AppState,
payload: GachaItemRequestDto, payload: GachaItemRequestDto,
) -> Response { ) -> Response {
if let Err((status, message)) = validate_request(&payload) { // Validation is now automatic via ValidatedJson extractor
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state); let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema { let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &payload.name), // Fixed: Use payload.name or some other identifier 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, payload: GachaItemUpdateRequestDto,
id: String, id: String,
) -> Response { ) -> Response {
if let Err((status, message)) = validate_request(&payload) { // Validation is now automatic via ValidatedJson extractor
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state); let repo = GachaItemRepository::new(state);
// Get current gacha item data first // Get current gacha item data first
@@ -10,7 +10,8 @@ use super::hackathon_schema::SubmissionStatus;
use crate::v1::hackathon::HackathonRepository; use crate::v1::hackathon::HackathonRepository;
use crate::{AppState, ResponseSuccessDto, ErrorDto}; use crate::{AppState, ResponseSuccessDto, ErrorDto};
use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto}; use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto};
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto}; use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto, ValidatedJson};
use imphnen_iam::require_permissions;
use axum::{ use axum::{
extract::{Extension, Path, Query}, extract::{Extension, Path, Query},
http::StatusCode, http::StatusCode,
@@ -4,14 +4,14 @@ use super::notification_dto::{
UnreadCountResponseDto, UnreadCountResponseDto,
}; };
use super::notification_repository::Repository; use super::notification_repository::Repository;
use super::notification_schema::NotificationSchema;
use axum::http::{Response, StatusCode}; use axum::http::{Response, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::body::Body; use axum::body::Body;
use imphnen_entities::common_dto::ResponseSuccessDto; use imphnen_entities::common_dto::ResponseSuccessDto;
use imphnen_libs::AppState; use imphnen_libs::AppState;
use imphnen_utils::{ 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> { pub struct Service<'a> {
@@ -28,8 +28,8 @@ impl<'a> Service<'a> {
user_email: &str, user_email: &str,
query: NotificationListQueryDto, query: NotificationListQueryDto,
) -> Response<Body> { ) -> Response<Body> {
if let Err((status, message)) = validate_request(&query) { if let Err((_status, message)) = validate_request(&query) {
return (status, message).into_response(); return error_response(AppError::ValidationError(message));
} }
let user_id = make_thing("users", user_email); let user_id = make_thing("users", user_email);
@@ -48,7 +48,7 @@ impl<'a> Service<'a> {
let notifications = match notifications_result { let notifications = match notifications_result {
Ok(notifs) => notifs, Ok(notifs) => notifs,
Err(err) => { 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 { let total = match total_result {
Ok(count) => count, Ok(count) => count,
Err(err) => { 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 { let unread_count = match unread_count_result {
Ok(count) => count, Ok(count) => count,
Err(err) => { Err(err) => {
return (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(); return error_response(AppError::InternalServerError(err.to_string()));
} }
}; };
+4
View File
@@ -1,4 +1,5 @@
pub mod v1; pub mod v1;
pub mod permission_macros;
// Re-export core entity types used throughout the IAM module // Re-export core entity types used throughout the IAM module
pub use imphnen_entities::{ pub use imphnen_entities::{
@@ -71,6 +72,9 @@ pub use v1::{
permissions_guard, permissions_guard,
}; };
// Export permission macros
pub use permission_macros::{check_permissions, check_authenticated};
// Export IAM-specific types // Export IAM-specific types
pub use v1::auth::{ pub use v1::auth::{
AuthRepository, AuthOtpSchema, AuthRepository, AuthOtpSchema,
+115
View File
@@ -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<T> = 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<AppState>,
required_permissions: Vec<PermissionsEnum>,
) -> PermissionGuardResult<Claims> {
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<AppState>,
) -> PermissionGuardResult<Claims> {
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<AppState>,
/// Query(meta): Query<MetaRequestDto>,
/// ) -> 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,
}
}
};
}
+2
View File
@@ -10,6 +10,8 @@ log.workspace = true
axum.workspace = true axum.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
validator.workspace = true
argon2.workspace = true argon2.workspace = true
lettre.workspace = true lettre.workspace = true
chrono.workspace = true chrono.workspace = true
+4
View File
@@ -3,12 +3,16 @@
//! This module provides utilities for initializing and running an Axum web server //! This module provides utilities for initializing and running an Axum web server
//! with SurrealDB connections for both WebSocket and in-memory databases. //! 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 crate::{surrealdb_init_mem, surrealdb_init_ws, SurrealMemClient, SurrealWsClient};
use axum::{Router, serve}; use axum::{Router, serve};
use std::{future::Future, net::SocketAddr}; use std::{future::Future, net::SocketAddr};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::environment::ENV; use crate::environment::ENV;
pub use validated_json::ValidatedJson;
/// Initialize and start the Axum server with SurrealDB connections. /// Initialize and start the Axum server with SurrealDB connections.
/// ///
/// This function sets up both WebSocket and in-memory SurrealDB connections, /// This function sets up both WebSocket and in-memory SurrealDB connections,
+111
View File
@@ -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<CreateUserRequest>
/// ) -> Response {
/// // payload is already validated
/// // ... your logic here
/// }
/// ```
pub struct ValidatedJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate + 'static,
S: Send + Sync,
Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
// First, extract JSON
let Json(value) = match Json::<T>::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<String> = 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
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub mod services;
pub mod surrealdb; pub mod surrealdb;
pub use argon::{hash_password, verify_password}; 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 environment::{ENV, Env};
pub use imphnen_entities::{ pub use imphnen_entities::{
MessageResponseDto, MessageResponseDto,
+1
View File
@@ -22,5 +22,6 @@ tracing.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
reqwest.workspace = true reqwest.workspace = true
regex = "1.11"
dotenvy = { workspace = true } dotenvy = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-subscriber = { workspace = true, features = ["env-filter"] }
+11
View File
@@ -19,6 +19,7 @@ pub mod query_builder;
pub mod errors; pub mod errors;
pub mod query_list; pub mod query_list;
pub mod response_format; pub mod response_format;
pub mod sanitization;
pub mod serde_helpers; pub mod serde_helpers;
pub mod validator; pub mod validator;
@@ -43,6 +44,16 @@ pub use query_builder::{
pub use query_list::QueryListBuilder; pub use query_list::QueryListBuilder;
pub use errors::AppError; pub use errors::AppError;
pub use response_format::{common_response, success_created_response, success_list_response, success_response, error_response}; 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::{ pub use serde_helpers::{
deserialize_datetime, deserialize_datetime,
option_thing_or_string, option_thing_or_string,
+181
View File
@@ -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<Regex> = 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<Regex> = LazyLock::new(|| {
Regex::new(r"\.\.(/|\\)").unwrap()
});
/// Sanitize HTML by escaping special characters
///
/// # Example
/// ```rust
/// use imphnen_utils::sanitize_html;
///
/// let dirty = "<script>alert('xss')</script>";
/// let clean = sanitize_html(dirty);
/// assert_eq!(clean, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
/// ```
pub fn sanitize_html(input: &str) -> String {
input
.chars()
.map(|c| match c {
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
'&' => "&amp;".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::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
/// Validate and sanitize email format
pub fn sanitize_email(email: &str) -> Option<String> {
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<String> {
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>"),
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
);
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<name>.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,<script>alert('xss')</script>"), None);
}
#[test]
fn test_normalize_whitespace() {
assert_eq!(
normalize_whitespace(" multiple spaces "),
"multiple spaces"
);
}
}