feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS

- Enforce axum best practices across all 13 workspace crates
  (max 200 LOC/file, no comments, no unwrap, clean architecture)
- Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin
- Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates
- Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS
- Centralize SMTP through imphnen-email; remove dead HackathonConfig
- Centralize database: QR crate now shares main DB pool (single DATABASE_URL)
- Rename QR users table to qr_users to avoid collision with main users table
- Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14)
- Restructure imphnen-hackathon flat modules into clean architecture
- Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra)
- Fix Dockerfile to include all current workspace crates
- Bump all crate versions 0.2.0 → 0.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 22:29:08 +07:00
co-authored by Claude Sonnet 4.6
parent 2ae43b3bcc
commit 331a4a4e88
442 changed files with 22226 additions and 18700 deletions
@@ -1,193 +1,181 @@
use axum::{
body::Body,
http::{Request, Response},
middleware::Next,
Extension,
};
use chrono::{DateTime, FixedOffset, Utc};
use imphnen_entities::seaorm::common::audit_log::Model as AuditLogSchema;
use imphnen_libs::AppState;
use sea_orm::{ActiveModelTrait, Set};
use sea_orm::prelude::Uuid;
use imphnen_utils::{extract_email, extract_email_async, extract_real_ip};
use std::convert::Infallible;
/// Middleware untuk mencatat semua aksi admin ke dalam audit log
pub async fn audit_logging_middleware(
Extension(state): Extension<AppState>,
req: Request<Body>,
next: Next,
) -> Result<Response<Body>, Infallible> {
let uri = req.uri().path().to_string();
// Hanya catat aksi admin (endpoint yang memerlukan permissions)
if is_admin_action(&uri) {
// Extract informasi pengguna dari headers
let headers = req.headers();
let user_email = extract_user_email(headers).await;
let user_id = extract_user_id(&state, &user_email).await;
let ip_address = extract_real_ip(headers).unwrap_or_else(|| "unknown".to_string());
let user_id_uuid = Uuid::parse_str(&user_id.clone().unwrap_or_else(|| "unknown".to_string())).unwrap_or(Uuid::nil());
let user_agent = extract_user_agent(headers);
// Ekstrak informasi aksi dari request
let action = extract_action(&uri, req.method().as_str());
let resource = extract_resource(&uri);
let resource_id = extract_resource_id(&uri);
// Simpan audit log sebelum memproses request
let audit_log = AuditLogSchema {
id: Uuid::new_v4(),
user_id: user_id_uuid,
user_email: user_email.clone().unwrap_or_else(|| "unknown".to_string()),
action,
resource,
resource_id,
old_data: None, // Untuk UPDATE/DELETE, perlu diisi setelah request
new_data: None, // Untuk CREATE/UPDATE, perlu diisi setelah request
ip_address,
user_agent,
timestamp: DateTime::<FixedOffset>::from(Utc::now()),
};
// Simpan audit log ke database
let action = audit_log.action.clone();
match save_audit_log(&state.postgres_connection.conn, audit_log.clone()).await {
Ok(_) => log::debug!("Audit log saved for action: {}", action),
Err(e) => log::error!("Failed to save audit log: {}", e),
}
}
// Lanjutkan dengan request
let response = next.run(req).await;
Ok(response)
}
/// Periksa apakah endpoint termasuk aksi admin
fn is_admin_action(uri: &str) -> bool {
// Daftar endpoint admin yang perlu diaudit
let admin_endpoints = [
"/v1/admin/",
"/v1/users/admin/",
"/v1/permissions/",
"/v1/roles/",
"/v1/gacha/admin/",
"/v1/cms/admin/",
];
admin_endpoints.iter().any(|endpoint| uri.starts_with(endpoint))
}
/// Extract email pengguna dari headers
async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option<String> {
// Coba extract email secara synchronous terlebih dahulu
match extract_email(headers) {
Some(email) => Some(email),
None => {
// Jika tidak ada, coba secara asynchronous
extract_email_async(headers).await
}
}
}
/// Extract user ID dari email menggunakan auth repository
async fn extract_user_id(state: &AppState, email: &Option<String>) -> Option<String> {
if let Some(email) = email {
match state.auth_repository.get_user_for_auth(&email.clone(), state).await {
Ok(user) => Some(user.id.to_string()),
Err(_) => None,
}
} else {
None
}
}
/// Extract user agent dari headers
fn extract_user_agent(headers: &axum::http::HeaderMap) -> Option<String> {
headers.get("user-agent")
.and_then(|value| value.to_str().ok())
.map(|s| s.to_string())
}
/// Extract tipe aksi dari URI dan method
fn extract_action(uri: &str, method: &str) -> String {
match method {
"POST" => "CREATE",
"PUT" | "PATCH" => "UPDATE",
"DELETE" => "DELETE",
"GET" => {
if uri.contains("/admin/") {
"VIEW"
} else {
"ACCESS"
}
},
_ => "UNKNOWN",
}.to_string()
}
/// Extract resource dari URI
fn extract_resource(uri: &str) -> String {
// Ambil bagian setelah /v1/ sebagai resource
if let Some(resource_part) = uri.split("/v1/").nth(1)
&& let Some(resource) = resource_part.split('/').next() {
return resource.to_string();
}
"unknown".to_string()
}
/// Extract resource ID dari URI
fn extract_resource_id(uri: &str) -> Option<String> {
// Cari bagian yang seperti UUID atau ID numerik
let segments = uri.split('/').collect::<Vec<&str>>();
for segment in segments.iter().rev() {
if segment.len() == 36 && segment.contains('-') {
// Kemungkinan UUID
return Some(segment.to_string());
} else if segment.chars().all(|c| c.is_ascii_digit()) {
// Kemungkinan ID numerik
return Some(segment.to_string());
}
}
None
}
/// Simpan audit log ke database menggunakan SeaORM
async fn save_audit_log(
db: &sea_orm::DatabaseConnection,
audit_log: AuditLogSchema,
) -> Result<(), Box<dyn std::error::Error>> {
use imphnen_entities::seaorm::common::audit_log::ActiveModel as AuditLogActiveModel;
let audit_log_model = AuditLogActiveModel {
id: Set(audit_log.id),
user_id: Set(audit_log.user_id),
user_email: Set(audit_log.user_email),
action: Set(audit_log.action.clone()),
resource: Set(audit_log.resource),
resource_id: Set(audit_log.resource_id),
old_data: Set(audit_log.old_data),
new_data: Set(audit_log.new_data),
ip_address: Set(audit_log.ip_address),
user_agent: Set(audit_log.user_agent),
timestamp: Set(audit_log.timestamp),
};
audit_log_model.insert(db).await?;
log::debug!("Audit log saved for action: {}", audit_log.action);
Ok(())
}
/// Middleware khusus untuk aksi UPDATE/DELETE yang menangkap data sebelum dan sesudah
pub async fn detailed_audit_logging_middleware(
Extension(state): Extension<AppState>,
req: Request<Body>,
next: Next,
) -> Result<Response<Body>, Infallible> {
// Implementasi ini akan lebih kompleks dan membutuhkan intercept response
// Untuk sekarang, gunakan basic audit logging
audit_logging_middleware(Extension(state), req, next).await
}
use axum::{
Extension,
body::Body,
http::{Request, Response},
middleware::Next,
};
use chrono::{DateTime, FixedOffset, Utc};
use imphnen_entities::seaorm::common::audit_log::Model as AuditLogSchema;
use imphnen_libs::AppState;
use imphnen_utils::{extract_email, extract_email_async, extract_real_ip};
use sea_orm::prelude::Uuid;
use sea_orm::{ActiveModelTrait, Set};
use std::convert::Infallible;
pub async fn audit_logging_middleware(
Extension(state): Extension<AppState>,
req: Request<Body>,
next: Next,
) -> Result<Response<Body>, Infallible> {
let uri = req.uri().path().to_string();
if is_admin_action(&uri) {
let headers = req.headers();
let user_email = extract_user_email(headers).await;
let user_id = extract_user_id(&state, &user_email).await;
let ip_address =
extract_real_ip(headers).unwrap_or_else(|| "unknown".to_string());
let user_id_uuid =
Uuid::parse_str(&user_id.clone().unwrap_or_else(|| "unknown".to_string()))
.unwrap_or(Uuid::nil());
let user_agent = extract_user_agent(headers);
let action = extract_action(&uri, req.method().as_str());
let resource = extract_resource(&uri);
let resource_id = extract_resource_id(&uri);
let audit_log = AuditLogSchema {
id: Uuid::new_v4(),
user_id: user_id_uuid,
user_email: user_email.clone().unwrap_or_else(|| "unknown".to_string()),
action,
resource,
resource_id,
old_data: None,
new_data: None,
ip_address,
user_agent,
timestamp: DateTime::<FixedOffset>::from(Utc::now()),
};
let action = audit_log.action.clone();
match save_audit_log(&state.postgres_connection.conn, audit_log.clone()).await {
Ok(_) => log::debug!("Audit log saved for action: {}", action),
Err(e) => log::error!("Failed to save audit log: {}", e),
}
}
let response = next.run(req).await;
Ok(response)
}
fn is_admin_action(uri: &str) -> bool {
let admin_endpoints = [
"/v1/admin/",
"/v1/users/admin/",
"/v1/permissions/",
"/v1/roles/",
"/v1/gacha/admin/",
"/v1/cms/admin/",
];
admin_endpoints
.iter()
.any(|endpoint| uri.starts_with(endpoint))
}
async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option<String> {
match extract_email(headers) {
Some(email) => Some(email),
None => extract_email_async(headers).await,
}
}
async fn extract_user_id(
state: &AppState,
email: &Option<String>,
) -> Option<String> {
if let Some(email) = email {
match state
.auth_repository
.get_user_for_auth(&email.clone(), state)
.await
{
Ok(user) => Some(user.id.to_string()),
Err(_) => None,
}
} else {
None
}
}
fn extract_user_agent(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
.map(|s| s.to_string())
}
fn extract_action(uri: &str, method: &str) -> String {
match method {
"POST" => "CREATE",
"PUT" | "PATCH" => "UPDATE",
"DELETE" => "DELETE",
"GET" => {
if uri.contains("/admin/") {
"VIEW"
} else {
"ACCESS"
}
}
_ => "UNKNOWN",
}
.to_string()
}
fn extract_resource(uri: &str) -> String {
if let Some(resource_part) = uri.split("/v1/").nth(1)
&& let Some(resource) = resource_part.split('/').next()
{
return resource.to_string();
}
"unknown".to_string()
}
fn extract_resource_id(uri: &str) -> Option<String> {
let segments = uri.split('/').collect::<Vec<&str>>();
for segment in segments.iter().rev() {
if (segment.len() == 36 && segment.contains('-'))
|| segment.chars().all(|c| c.is_ascii_digit())
{
return Some(segment.to_string());
}
}
None
}
async fn save_audit_log(
db: &sea_orm::DatabaseConnection,
audit_log: AuditLogSchema,
) -> Result<(), Box<dyn std::error::Error>> {
use imphnen_entities::seaorm::common::audit_log::ActiveModel as AuditLogActiveModel;
let audit_log_model = AuditLogActiveModel {
id: Set(audit_log.id),
user_id: Set(audit_log.user_id),
user_email: Set(audit_log.user_email),
action: Set(audit_log.action.clone()),
resource: Set(audit_log.resource),
resource_id: Set(audit_log.resource_id),
old_data: Set(audit_log.old_data),
new_data: Set(audit_log.new_data),
ip_address: Set(audit_log.ip_address),
user_agent: Set(audit_log.user_agent),
timestamp: Set(audit_log.timestamp),
};
audit_log_model.insert(db).await?;
log::debug!("Audit log saved for action: {}", audit_log.action);
Ok(())
}
pub async fn detailed_audit_logging_middleware(
Extension(state): Extension<AppState>,
req: Request<Body>,
next: Next,
) -> Result<Response<Body>, Infallible> {
audit_logging_middleware(Extension(state), req, next).await
}
+73 -66
View File
@@ -1,66 +1,73 @@
use axum::{
Extension, extract::Request, http::StatusCode, middleware::Next,
response::{IntoResponse, Response},
};
use imphnen_libs::{AppState, jsonwebtoken::decode_access_token};
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
use std::convert::Infallible;
use uuid::Uuid;
use imphnen_utils::response_format::ApiMessage;
pub async fn auth_middleware(
Extension(state): Extension<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, Infallible> {
let auth_header = match req
.headers()
.typed_get::<Authorization<Bearer>>() {
Some(header) => header,
None => return Ok(ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
).into_response()),
};
let token = auth_header.token();
let claims = match decode_access_token(token) {
Ok(token_data) => token_data.claims,
Err(_) => return Ok(ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or expired token",
).into_response()),
};
let user_id = claims.user_id.clone();
// Validate UUID format
let user_uuid = match Uuid::parse_str(&user_id) {
Ok(uuid) => uuid,
Err(_) => return Ok(ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid user identifier format",
).into_response()),
};
// Use UserLookupService to fetch full user details including roles/permissions
// This ensures consistency and populates the DTO expected by controllers
let user_info = match state.user_lookup_service.get_user_by_id(user_uuid, &state).await {
Ok(info) => info,
Err(_) => return Ok(ApiMessage::new(StatusCode::UNAUTHORIZED, "User not found or inactive").into_response()),
};
// Insert the Model (reconstructed or fetched? Wait, UserLookupService returns ExtendedUserInfo)
// We need to insert what the controllers expect.
// Some controllers might expect Model, others DTO.
// Let's fetch Model separately if needed, or better, insert DTO.
// The error said "Extension of type `imphnen_entities::users::UsersDetailQueryDto` was not found".
req.extensions_mut().insert(user_info.basic_info);
// If controllers also need Model, we might need to insert it too.
// But usually they switch to DTO.
// Let's try inserting DTO first.
Ok(next.run(req).await)
}
use axum::{
Extension,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer};
use imphnen_libs::{AppState, jsonwebtoken::decode_access_token};
use imphnen_utils::response_format::ApiMessage;
use std::convert::Infallible;
use uuid::Uuid;
pub async fn auth_middleware(
Extension(state): Extension<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, Infallible> {
let auth_header = match req.headers().typed_get::<Authorization<Bearer>>() {
Some(header) => header,
None => {
return Ok(
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
.into_response(),
);
}
};
let token = auth_header.token();
let claims = match decode_access_token(token) {
Ok(token_data) => token_data.claims,
Err(_) => {
return Ok(
ApiMessage::new(StatusCode::UNAUTHORIZED, "Invalid or expired token")
.into_response(),
);
}
};
let user_id = claims.user_id.clone();
let user_uuid = match Uuid::parse_str(&user_id) {
Ok(uuid) => uuid,
Err(_) => {
return Ok(
ApiMessage::new(StatusCode::UNAUTHORIZED, "Invalid user identifier format")
.into_response(),
);
}
};
let user_info = match state
.user_lookup_service
.get_user_by_id(user_uuid, &state)
.await
{
Ok(info) => info,
Err(_) => {
return Ok(
ApiMessage::new(StatusCode::UNAUTHORIZED, "User not found or inactive")
.into_response(),
);
}
};
req.extensions_mut().insert(user_info.basic_info);
Ok(next.run(req).await)
}
+23 -37
View File
@@ -1,37 +1,23 @@
use axum::http::{HeaderValue, Method, header};
use imphnen_libs::environment::ENV;
use tower_http::cors::CorsLayer;
pub fn cors_middleware() -> CorsLayer {
let env = &ENV;
let cors_origins = match env.rust_env.as_str() {
"development" => {
let mut origins = vec!["http://localhost:3000".to_string()];
origins.push(format!("http://localhost:{}", env.port));
origins
},
"production" => {
vec![
"https://gacha.imphnen.dev".to_string(),
"https://imphnen.dev".to_string(),
"https://dimentorin.imphnen.dev".to_string(),
]
}
_ => vec![
"http://localhost:3000".to_string(),
"https://gacha.imphnen.dev".to_string(),
"https://imphnen.dev".to_string(),
"https://dimentorin.imphnen.dev".to_string(),
],
};
let allowed_origins: Vec<HeaderValue> = cors_origins
.into_iter()
.filter_map(|origin| origin.parse::<HeaderValue>().ok())
.collect();
CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true)
}
use axum::http::{HeaderValue, Method, header};
use imphnen_libs::environment::ENV;
use tower_http::cors::CorsLayer;
pub fn cors_middleware() -> CorsLayer {
let allowed_origins: Vec<HeaderValue> = ENV
.cors_allowed_origins
.iter()
.filter_map(|origin| origin.parse::<HeaderValue>().ok())
.collect();
CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true)
}
+15 -16
View File
@@ -1,16 +1,15 @@
pub mod audit_logging_middleware;
pub mod auth_middleware;
pub mod cors_middleware;
pub mod payment_middleware;
pub mod permissions_middleware;
pub mod rate_limiting_middleware;
pub mod security_headers_middleware;
// Re-export all middleware for easy access
pub use audit_logging_middleware::audit_logging_middleware;
pub use auth_middleware::auth_middleware;
pub use cors_middleware::cors_middleware;
pub use payment_middleware::PaymentLayer;
pub use permissions_middleware::{PermissionsMiddlewareLayer, check_permissions};
pub use rate_limiting_middleware::rate_limiting_middleware;
pub use security_headers_middleware::security_headers_middleware;
pub mod audit_logging_middleware;
pub mod auth_middleware;
pub mod cors_middleware;
pub mod payment_middleware;
pub mod permissions_middleware;
pub mod rate_limiting_middleware;
pub mod security_headers_middleware;
pub use audit_logging_middleware::audit_logging_middleware;
pub use auth_middleware::auth_middleware;
pub use cors_middleware::cors_middleware;
pub use payment_middleware::PaymentLayer;
pub use permissions_middleware::{PermissionsMiddlewareLayer, check_permissions};
pub use rate_limiting_middleware::rate_limiting_middleware;
pub use security_headers_middleware::security_headers_middleware;
+95 -101
View File
@@ -1,101 +1,95 @@
use axum::{
body::Body,
http::{Request, Response, StatusCode},
};
use futures::future::BoxFuture;
use imphnen_libs::AppState;
use std::task::{Context, Poll};
use tower::{Layer, Service};
/// Placeholder middleware layer for payment processing.
/// Currently a pass-through implementation.
#[derive(Clone)]
pub struct PaymentLayer {
app_state: AppState,
}
impl PaymentLayer {
/// Create a new payment middleware layer
pub fn new(app_state: AppState) -> Self {
Self { app_state }
}
}
impl<S> Layer<S> for PaymentLayer {
type Service = PaymentMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
PaymentMiddleware {
inner,
app_state: self.app_state.clone(),
}
}
}
#[derive(Clone)]
pub struct PaymentMiddleware<S> {
inner: S,
app_state: AppState,
}
impl<S> Service<Request<Body>> for PaymentMiddleware<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Response<Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let mut inner = self.inner.clone();
let _app_state = self.app_state.clone();
Box::pin(async move {
// Payment validation logic
// Check for payment-related headers or query parameters
let headers = req.headers();
// Validate payment token if present
if let Some(payment_token) = headers.get("X-Payment-Token")
&& let Ok(token_str) = payment_token.to_str() {
// Basic validation: check token format
if !is_valid_payment_token(token_str) {
let error_response = Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.body(Body::from("Invalid payment token"))
.unwrap();
return Err(error_response);
}
}
// Check if endpoint requires payment verification
let uri_path = req.uri().path();
if requires_payment_verification(uri_path)
&& !headers.contains_key("X-Payment-Token") {
let error_response = Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.body(Body::from("Payment required for this endpoint"))
.unwrap();
return Err(error_response);
}
// Pass through if payment validation succeeds or not required
inner.call(req).await
})
}
}
/// Validate payment token format
fn is_valid_payment_token(token: &str) -> bool {
// Basic validation: token should be alphanumeric and at least 16 chars
token.len() >= 16 && token.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
/// Check if URI path requires payment verification
fn requires_payment_verification(path: &str) -> bool {
// Premium endpoints that require payment
path.contains("/premium/") ||
path.contains("/paid/") ||
path.contains("/subscription/")
}
use axum::{
body::Body,
http::{Request, Response, StatusCode},
};
use futures::future::BoxFuture;
use imphnen_libs::AppState;
use std::task::{Context, Poll};
use tower::{Layer, Service};
#[derive(Clone)]
pub struct PaymentLayer {
app_state: AppState,
}
impl PaymentLayer {
pub fn new(app_state: AppState) -> Self {
Self { app_state }
}
}
impl<S> Layer<S> for PaymentLayer {
type Service = PaymentMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
PaymentMiddleware {
inner,
app_state: self.app_state.clone(),
}
}
}
#[derive(Clone)]
pub struct PaymentMiddleware<S> {
inner: S,
app_state: AppState,
}
impl<S> Service<Request<Body>> for PaymentMiddleware<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Response<Body>>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let mut inner = self.inner.clone();
let _app_state = self.app_state.clone();
Box::pin(async move {
let headers = req.headers();
if let Some(payment_token) = headers.get("X-Payment-Token")
&& let Ok(token_str) = payment_token.to_str()
&& !is_valid_payment_token(token_str)
{
let error_response = Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.body(Body::from("Invalid payment token"))
.expect("valid payment error response");
return Err(error_response);
}
let uri_path = req.uri().path();
if requires_payment_verification(uri_path)
&& !headers.contains_key("X-Payment-Token")
{
let error_response = Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.body(Body::from("Payment required for this endpoint"))
.expect("valid payment required response");
return Err(error_response);
}
inner.call(req).await
})
}
}
fn is_valid_payment_token(token: &str) -> bool {
token.len() >= 16
&& token
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
fn requires_payment_verification(path: &str) -> bool {
path.contains("/premium/")
|| path.contains("/paid/")
|| path.contains("/subscription/")
}
@@ -1,202 +1,199 @@
use axum::{
body::Body,
http::{Request, Response, StatusCode},
};
use futures::future::BoxFuture;
use imphnen_entities::PermissionsEnum;
use imphnen_libs::{AppState, services::ExtendedUserInfo};
use imphnen_utils::response_format::ApiMessage;
use axum::response::IntoResponse;
use imphnen_utils::{extract_email, extract_email_async};
use std::task::{Context, Poll};
use tower::{Layer, Service};
/// Unified middleware layer for enforcing user permissions on requests.
/// This replaces the legacy permissions_guard function calls with a consistent middleware approach.
#[derive(Clone)]
pub struct PermissionsMiddlewareLayer {
app_state: AppState,
permissions: Vec<PermissionsEnum>,
}
impl PermissionsMiddlewareLayer {
/// Create a new permissions middleware layer with the required permissions
pub fn new(app_state: AppState, permissions: Vec<PermissionsEnum>) -> Self {
Self {
app_state,
permissions,
}
}
/// Create a middleware layer that requires administrator permissions
pub fn admin_only(app_state: AppState) -> Self {
Self::new(app_state, vec![PermissionsEnum::Administrator])
}
/// Create a middleware layer that requires specific permission
pub fn with_permission(app_state: AppState, permission: PermissionsEnum) -> Self {
Self::new(app_state, vec![permission])
}
}
impl<S> Layer<S> for PermissionsMiddlewareLayer {
type Service = PermissionsMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
PermissionsMiddleware {
inner,
app_state: self.app_state.clone(),
permissions: self.permissions.clone(),
}
}
}
#[derive(Clone)]
pub struct PermissionsMiddleware<S> {
inner: S,
app_state: AppState,
permissions: Vec<PermissionsEnum>,
}
impl<S> Service<Request<Body>> for PermissionsMiddleware<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Response<Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let mut inner = self.inner.clone();
let app_state = self.app_state.clone();
let permissions = self.permissions.clone();
Box::pin(async move {
let headers = req.headers();
// Extract user email from authorization headers
let email = extract_user_email(headers).await
.ok_or_else(|| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
).into_response()
})?;
// Get user data with permissions from user lookup service
let user = app_state.user_lookup_service.get_user_by_email(&email, &app_state).await
.map_err(|_| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
).into_response()
})?;
// Extract user permissions from role
let user_permissions = extract_user_permissions(&user);
println!("DEBUG: User Permissions: {:?}", user_permissions);
println!("DEBUG: Required Permissions: {:?}", permissions);
// Check if user has required permissions
if !has_required_permissions(&user_permissions, &permissions) {
return Err(ApiMessage::new(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
).into_response());
}
inner.call(req).await
})
}
}
/// Extract user email from headers (sync and async fallback)
async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option<String> {
// Try synchronous extraction first
match extract_email(headers) {
Some(email) => Some(email),
None => {
// Fallback to async extraction for Google tokens
extract_email_async(headers).await
}
}
}
/// Extract user permissions from user data
fn extract_user_permissions(user: &ExtendedUserInfo) -> Vec<String> {
user.basic_info.role
.permissions
.as_ref()
.unwrap_or(&vec![])
.iter()
.filter_map(|p| p.as_ref())
.flat_map(|pp| {
let mut permissions = Vec::new();
// Add permission name if available
if let Some(name) = pp.name.clone() {
permissions.push(name);
}
// Add permission ID if available
if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) {
permissions.push(id);
}
permissions
})
.collect()
}
/// Check if user has required permissions
fn has_required_permissions(user_permissions: &[String], required_permissions: &[PermissionsEnum]) -> bool {
// Administrator has access to everything
let admin_name = PermissionsEnum::Administrator.to_string();
let admin_id = PermissionsEnum::Administrator.id();
if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) {
return true;
}
// Check if user has all required permissions
required_permissions.iter().all(|required| {
let required_name = required.to_string();
let required_id = required.id();
user_permissions.contains(&required_name) || user_permissions.contains(&required_id)
})
}
/// Simple permission check function for use in controllers (legacy compatibility)
/// This provides a bridge between old permissions_guard calls and new middleware approach
pub async fn check_permissions(
headers: &axum::http::HeaderMap,
app_state: &AppState,
required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response<Body>> {
let email = extract_user_email(headers).await
.ok_or_else(|| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
).into_response()
})?;
let user = app_state.user_lookup_service.get_user_by_email(&email, app_state).await
.map_err(|_| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
).into_response()
})?;
let user_permissions = extract_user_permissions(&user);
if !has_required_permissions(&user_permissions, &required_permissions) {
return Err(ApiMessage::new(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
).into_response());
}
Ok(())
}
use axum::response::IntoResponse;
use axum::{
body::Body,
http::{Request, Response, StatusCode},
};
use futures::future::BoxFuture;
use imphnen_entities::PermissionsEnum;
use imphnen_libs::{AppState, services::ExtendedUserInfo};
use imphnen_utils::response_format::ApiMessage;
use imphnen_utils::{extract_email, extract_email_async};
use std::task::{Context, Poll};
use tower::{Layer, Service};
#[derive(Clone)]
pub struct PermissionsMiddlewareLayer {
app_state: AppState,
permissions: Vec<PermissionsEnum>,
}
impl PermissionsMiddlewareLayer {
pub fn new(app_state: AppState, permissions: Vec<PermissionsEnum>) -> Self {
Self {
app_state,
permissions,
}
}
pub fn admin_only(app_state: AppState) -> Self {
Self::new(app_state, vec![PermissionsEnum::Administrator])
}
pub fn with_permission(app_state: AppState, permission: PermissionsEnum) -> Self {
Self::new(app_state, vec![permission])
}
}
impl<S> Layer<S> for PermissionsMiddlewareLayer {
type Service = PermissionsMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
PermissionsMiddleware {
inner,
app_state: self.app_state.clone(),
permissions: self.permissions.clone(),
}
}
}
#[derive(Clone)]
pub struct PermissionsMiddleware<S> {
inner: S,
app_state: AppState,
permissions: Vec<PermissionsEnum>,
}
impl<S> Service<Request<Body>> for PermissionsMiddleware<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Response<Body>>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let mut inner = self.inner.clone();
let app_state = self.app_state.clone();
let permissions = self.permissions.clone();
Box::pin(async move {
let headers = req.headers();
let email = extract_user_email(headers).await.ok_or_else(|| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
.into_response()
})?;
let user = app_state
.user_lookup_service
.get_user_by_email(&email, &app_state)
.await
.map_err(|_| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
)
.into_response()
})?;
let user_permissions = extract_user_permissions(&user);
if !has_required_permissions(&user_permissions, &permissions) {
return Err(
ApiMessage::new(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
)
.into_response(),
);
}
inner.call(req).await
})
}
}
async fn extract_user_email(headers: &axum::http::HeaderMap) -> Option<String> {
match extract_email(headers) {
Some(email) => Some(email),
None => extract_email_async(headers).await,
}
}
fn extract_user_permissions(user: &ExtendedUserInfo) -> Vec<String> {
user
.basic_info
.role
.permissions
.as_ref()
.unwrap_or(&vec![])
.iter()
.filter_map(|p| p.as_ref())
.flat_map(|pp| {
let mut permissions = Vec::new();
if let Some(name) = pp.name.clone() {
permissions.push(name);
}
if let Some(id) = pp.id.as_ref().map(|id| id.to_string()) {
permissions.push(id);
}
permissions
})
.collect()
}
fn has_required_permissions(
user_permissions: &[String],
required_permissions: &[PermissionsEnum],
) -> bool {
let admin_name = PermissionsEnum::Administrator.to_string();
let admin_id = PermissionsEnum::Administrator.id();
if user_permissions.contains(&admin_name) || user_permissions.contains(&admin_id) {
return true;
}
required_permissions.iter().all(|required| {
let required_name = required.to_string();
let required_id = required.id();
user_permissions.contains(&required_name)
|| user_permissions.contains(&required_id)
})
}
pub async fn check_permissions(
headers: &axum::http::HeaderMap,
app_state: &AppState,
required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response<Body>> {
let email = extract_user_email(headers).await.ok_or_else(|| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
.into_response()
})?;
let user = app_state
.user_lookup_service
.get_user_by_email(&email, app_state)
.await
.map_err(|_| {
ApiMessage::new(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
)
.into_response()
})?;
let user_permissions = extract_user_permissions(&user);
if !has_required_permissions(&user_permissions, &required_permissions) {
return Err(
ApiMessage::new(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
)
.into_response(),
);
}
Ok(())
}
@@ -1,182 +1,178 @@
use axum::{
body::Body,
http::{Request, Response, StatusCode},
middleware::Next,
Extension,
};
use chrono::{DateTime, FixedOffset, Utc, Duration};
use imphnen_libs::{AppState};
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, Set, ActiveModelTrait};
use uuid::Uuid;
use imphnen_utils::extract_real_ip;
use imphnen_entities::seaorm::common::rate_limit::Entity as RateLimitEntity;
use imphnen_entities::seaorm::common::rate_limit::ActiveModel as RateLimitActiveModel;
use imphnen_entities::seaorm::common::rate_limit::Column as RateLimitColumn;
/// Rate limiting middleware yang menggunakan PostgreSQL (SeaORM) untuk semua public endpoints
///
/// Migration dari SurrealDB ke PostgreSQL selesai - kini menggunakan sistem rate limiting
/// yang lebih scalable dan terintegrasi dengan backend utama
pub async fn rate_limiting_middleware(
Extension(state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let uri = req.uri().path().to_string();
// Terapkan rate limiting pada semua public endpoints
if is_public_endpoint(&uri) {
// Extract real client IP dari headers
let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| {
log::warn!("Could not extract real IP, using fallback");
"unknown".to_string()
});
// Konfigurasi rate limiting
let max_requests = 100; // 100 requests per minute
let window_duration_secs = 60; // 1 minute window
// Periksa rate limit menggunakan PostgreSQL (SeaORM)
match check_rate_limit(&state.postgres_connection.conn, &client_ip, max_requests, window_duration_secs).await {
Ok(is_limited) => {
if is_limited {
return Ok(Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", "60")
.body("Too Many Requests: Rate limit exceeded".into())
.unwrap());
}
}
Err(e) => {
log::error!("Rate limit check failed: {}", e);
// Jika terjadi error, izinkan request untuk menjaga availability
}
}
}
Ok(next.run(req).await)
}
/// Middleware rate limiting khusus untuk endpoint autentikasi
///
/// Menggunakan PostgreSQL (SeaORM) sebagai backend - kompatibilitas legacy dengan SurrealDB
/// telah dihapus selain fungsionalitas yang sama
pub async fn auth_rate_limiting_middleware(
Extension(state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let uri = req.uri().path().to_string();
// Hanya terapkan pada endpoint auth
if uri == "/v1/auth/login" || uri == "/v1/auth/register" {
// Extract real client IP dari headers
let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| {
log::warn!("Could not extract real IP, using fallback");
"unknown".to_string()
});
// Konfigurasi rate limiting yang lebih ketat untuk auth
let max_requests = 10; // 10 requests per minute
let window_duration_secs = 60; // 1 minute window
// Periksa rate limit menggunakan PostgreSQL (SeaORM)
match check_rate_limit(&state.postgres_connection.conn, &client_ip, max_requests, window_duration_secs).await {
Ok(is_limited) => {
if is_limited {
return Ok(Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", "60")
.body("Too Many Requests: Rate limit exceeded for authentication endpoint".into())
.unwrap());
}
}
Err(e) => {
log::error!("Auth rate limit check failed: {}", e);
// Jika terjadi error, izinkan request untuk menjaga availability
}
}
}
Ok(next.run(req).await)
}
/// Periksa apakah endpoint termasuk public endpoint
fn is_public_endpoint(uri: &str) -> bool {
// Daftar endpoint yang memerlukan rate limiting
let public_endpoints = [
"/v1/auth/login",
"/v1/auth/register",
"/v1/auth/refresh",
"/v1/auth/logout",
"/v1/gacha/roll",
"/v1/gacha/credits",
"/v1/cms/landing",
];
public_endpoints.iter().any(|endpoint| uri.starts_with(endpoint))
}
/// Periksa rate limit untuk IP tertentu menggunakan PostgreSQL (SeaORM)
///
/// Implementasi rate limiting yang didesain untuk skala besar dengan PostgreSQL,
/// menggantikan implementasi SurrealDB yang sebelumnya
async fn check_rate_limit(
db: &sea_orm::DatabaseConnection,
ip_address: &str,
max_requests: u32,
window_duration_secs: u64,
) -> Result<bool, Box<dyn std::error::Error>> {
let now = Utc::now();
let window_start = now - Duration::seconds(window_duration_secs as i64);
// Cari record rate limit untuk IP ini
let existing_record = RateLimitEntity::find()
.filter(RateLimitColumn::IpAddress.eq(ip_address))
.one(db)
.await?;
match existing_record {
Some(record) => {
// Konversi ke ActiveModel untuk modifikasi
let mut active_model: RateLimitActiveModel = record.into();
// Reset counter jika window sudah expired
let was_reset = if active_model.last_request_time.clone().unwrap() <= window_start {
active_model.request_count = Set(0);
active_model.last_request_time = Set(DateTime::<FixedOffset>::from(now));
true
} else {
false
};
if !was_reset {
// Increment counter jika masih dalam window
let current_count = active_model.request_count.clone().unwrap();
active_model.request_count = Set(current_count + 1);
}
// Simpan perubahan ke database
let updated_model = active_model.update(db).await?;
// Periksa apakah rate limit terlampaui
Ok(updated_model.request_count > max_requests)
}
None => {
// Buat record baru dengan nilai awal
let new_record = RateLimitActiveModel {
id: Set(Uuid::new_v4().to_string()),
ip_address: Set(ip_address.to_string()),
request_count: Set(1),
first_request_time: Set(DateTime::<FixedOffset>::from(now)),
last_request_time: Set(DateTime::<FixedOffset>::from(now)),
window_duration_secs: Set(window_duration_secs as i64),
};
// Simpan record baru ke database
new_record.insert(db).await?;
Ok(false) // Request pertama selalu diizinkan
}
}
}
use axum::{
Extension,
body::Body,
http::{Request, Response, StatusCode},
middleware::Next,
};
use chrono::{DateTime, Duration, FixedOffset, Utc};
use imphnen_entities::seaorm::common::rate_limit::ActiveModel as RateLimitActiveModel;
use imphnen_entities::seaorm::common::rate_limit::Column as RateLimitColumn;
use imphnen_entities::seaorm::common::rate_limit::Entity as RateLimitEntity;
use imphnen_libs::AppState;
use imphnen_utils::extract_real_ip;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
pub async fn rate_limiting_middleware(
Extension(state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let uri = req.uri().path().to_string();
if is_public_endpoint(&uri) {
let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| {
log::warn!("Could not extract real IP, using fallback");
"unknown".to_string()
});
let max_requests = 100;
let window_duration_secs = 60;
match check_rate_limit(
&state.postgres_connection.conn,
&client_ip,
max_requests,
window_duration_secs,
)
.await
{
Ok(is_limited) => {
if is_limited {
return Ok(
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", "60")
.body("Too Many Requests: Rate limit exceeded".into())
.expect("valid rate limit response"),
);
}
}
Err(e) => {
log::error!("Rate limit check failed: {}", e);
}
}
}
Ok(next.run(req).await)
}
pub async fn auth_rate_limiting_middleware(
Extension(state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let uri = req.uri().path().to_string();
if uri == "/v1/auth/login" || uri == "/v1/auth/register" {
let client_ip = extract_real_ip(req.headers()).unwrap_or_else(|| {
log::warn!("Could not extract real IP, using fallback");
"unknown".to_string()
});
let max_requests = 10;
let window_duration_secs = 60;
match check_rate_limit(
&state.postgres_connection.conn,
&client_ip,
max_requests,
window_duration_secs,
)
.await
{
Ok(is_limited) => {
if is_limited {
return Ok(
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", "60")
.body(
"Too Many Requests: Rate limit exceeded for authentication endpoint"
.into(),
)
.expect("valid auth rate limit response"),
);
}
}
Err(e) => {
log::error!("Auth rate limit check failed: {}", e);
}
}
}
Ok(next.run(req).await)
}
fn is_public_endpoint(uri: &str) -> bool {
let public_endpoints = [
"/v1/auth/login",
"/v1/auth/register",
"/v1/auth/refresh",
"/v1/auth/logout",
"/v1/gacha/roll",
"/v1/gacha/credits",
"/v1/cms/landing",
];
public_endpoints
.iter()
.any(|endpoint| uri.starts_with(endpoint))
}
async fn check_rate_limit(
db: &sea_orm::DatabaseConnection,
ip_address: &str,
max_requests: u32,
window_duration_secs: u64,
) -> Result<bool, Box<dyn std::error::Error>> {
let now = Utc::now();
let window_start = now - Duration::seconds(window_duration_secs as i64);
let existing_record = RateLimitEntity::find()
.filter(RateLimitColumn::IpAddress.eq(ip_address))
.one(db)
.await?;
match existing_record {
Some(record) => {
let mut active_model: RateLimitActiveModel = record.into();
let last_request = active_model
.last_request_time
.clone()
.take()
.unwrap_or(DateTime::<FixedOffset>::from(window_start));
let was_reset = if last_request <= window_start {
active_model.request_count = Set(0);
active_model.last_request_time = Set(DateTime::<FixedOffset>::from(now));
true
} else {
false
};
if !was_reset {
let current_count = active_model.request_count.clone().take().unwrap_or(0);
active_model.request_count = Set(current_count + 1);
}
let updated_model = active_model.update(db).await?;
Ok(updated_model.request_count > max_requests)
}
None => {
let new_record = RateLimitActiveModel {
id: Set(Uuid::new_v4().to_string()),
ip_address: Set(ip_address.to_string()),
request_count: Set(1),
first_request_time: Set(DateTime::<FixedOffset>::from(now)),
last_request_time: Set(DateTime::<FixedOffset>::from(now)),
window_duration_secs: Set(window_duration_secs as i64),
};
new_record.insert(db).await?;
Ok(false)
}
}
}
@@ -1,127 +1,101 @@
use axum::{
http::{HeaderValue, Request, Response},
middleware::Next,
Extension,
};
use imphnen_libs::{AppState, ENV};
use rand::RngCore;
use std::convert::Infallible;
/// Security headers middleware that adds various security-related HTTP headers to all responses.
///
/// This middleware implements security best practices by adding headers that help protect
/// against common web attacks like clickjacking, XSS, and information leakage.
pub async fn security_headers_middleware(
Extension(_state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<axum::body::Body>, Infallible> {
// Generate nonce for CSP if in development mode
let nonce = if ENV.rust_env != "production" {
generate_nonce()
} else {
String::new()
};
let res = next.run(req).await;
let res = add_security_headers(res, &nonce);
Ok(res)
}
/// Adds security headers to a response based on the current environment.
///
/// # Arguments
/// * `res` - The response to add headers to
/// * `nonce` - Nonce value for CSP (empty in production)
///
/// # Returns
/// The response with security headers added
fn add_security_headers(mut res: Response<axum::body::Body>, nonce: &str) -> Response<axum::body::Body> {
let headers = res.headers_mut();
// Strict-Transport-Security (HSTS)
// Prevents downgrade attacks and cookie hijacking
// Only enable in production to avoid HSTS pinning issues during development
if ENV.rust_env == "production" {
headers.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
);
} else {
headers.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=0"),
);
}
// Content-Security-Policy (CSP)
// Mitigates XSS and data injection attacks
let csp = if ENV.rust_env == "production" {
// Production CSP - strict policy for production
"default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-violation-report-endpoint".to_string()
} else {
// Development CSP - secure nonce-based approach
if nonce.is_empty() {
// Fallback if nonce generation fails
"default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000; style-src 'self' http://localhost:3000; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'".to_string()
} else {
// Nonce-based CSP for development
format!("default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000 'nonce-{}'; style-src 'self' http://localhost:3000 'nonce-{}'; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'", nonce, nonce)
}
};
headers.insert("Content-Security-Policy", HeaderValue::from_str(&csp).unwrap());
// Add nonce to response headers for frontend use (development only)
if ENV.rust_env != "production" && !nonce.is_empty() {
headers.insert("X-CSP-Nonce", HeaderValue::from_str(nonce).unwrap());
}
// X-Frame-Options
// Prevents clickjacking attacks
headers.insert(
"X-Frame-Options",
HeaderValue::from_static("DENY"),
);
// X-Content-Type-Options
// Prevents MIME sniffing attacks
headers.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
// Referrer-Policy
// Controls how much referrer information should be included with requests
headers.insert(
"Referrer-Policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
// Permissions-Policy (Feature Policy)
// Controls which features and APIs can be used
headers.insert(
"Permissions-Policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
// X-XSS-Protection
// Provides basic XSS protection (note: this is a legacy header and CSP is preferred)
headers.insert(
"X-XSS-Protection",
HeaderValue::from_static("1; mode=block"),
);
res
}
/// Generate a random nonce for CSP
fn generate_nonce() -> String {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let mut rng = rand::rng();
let mut random_bytes = [0u8; 16];
rng.fill_bytes(&mut random_bytes);
STANDARD.encode(random_bytes)
}
use axum::{
Extension,
http::{HeaderValue, Request, Response},
middleware::Next,
};
use imphnen_libs::{AppState, ENV};
use rand::RngCore;
use std::convert::Infallible;
pub async fn security_headers_middleware(
Extension(_state): Extension<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<axum::body::Body>, Infallible> {
let nonce = if ENV.rust_env != "production" {
generate_nonce()
} else {
String::new()
};
let res = next.run(req).await;
let res = add_security_headers(res, &nonce);
Ok(res)
}
fn add_security_headers(
mut res: Response<axum::body::Body>,
nonce: &str,
) -> Response<axum::body::Body> {
let headers = res.headers_mut();
if ENV.rust_env == "production" {
headers.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
);
} else {
headers.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=0"),
);
}
let csp = if ENV.rust_env == "production" {
"default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-violation-report-endpoint".to_string()
} else {
if nonce.is_empty() {
"default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000; style-src 'self' http://localhost:3000; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'".to_string()
} else {
format!(
"default-src 'self' http://localhost:3000; script-src 'self' http://localhost:3000 'nonce-{}'; style-src 'self' http://localhost:3000 'nonce-{}'; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'",
nonce, nonce
)
}
};
if let Ok(csp_value) = HeaderValue::from_str(&csp) {
headers.insert("Content-Security-Policy", csp_value);
}
if ENV.rust_env != "production"
&& !nonce.is_empty()
&& let Ok(nonce_value) = HeaderValue::from_str(nonce)
{
headers.insert("X-CSP-Nonce", nonce_value);
}
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
headers.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"Referrer-Policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
"Permissions-Policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
headers.insert(
"X-XSS-Protection",
HeaderValue::from_static("1; mode=block"),
);
res
}
fn generate_nonce() -> String {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let mut rng = rand::rng();
let mut random_bytes = [0u8; 16];
rng.fill_bytes(&mut random_bytes);
STANDARD.encode(random_bytes)
}