postgress
This commit is contained in:
@@ -1,29 +1,30 @@
|
||||
[package]
|
||||
name = "imphnen-middleware"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
imphnen-libs.workspace = true
|
||||
imphnen-utils.workspace = true
|
||||
imphnen-entities.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
utoipa.workspace = true
|
||||
lazy_static.workspace = true
|
||||
regex.workspace = true
|
||||
validator.workspace = true
|
||||
axum-test.workspace = true
|
||||
surrealdb.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
tower-http.workspace = true
|
||||
futures.workspace = true
|
||||
tower.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
[package]
|
||||
name = "imphnen-middleware"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
imphnen-libs.workspace = true
|
||||
imphnen-utils.workspace = true
|
||||
imphnen-entities.workspace = true
|
||||
sea-orm.workspace = true
|
||||
uuid.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
utoipa.workspace = true
|
||||
lazy_static.workspace = true
|
||||
regex.workspace = true
|
||||
validator.workspace = true
|
||||
axum-test.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
tower-http.workspace = true
|
||||
futures.workspace = true
|
||||
tower.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
|
||||
@@ -1,184 +1,193 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, Response},
|
||||
middleware::Next,
|
||||
Extension,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::AuditLogSchema;
|
||||
use imphnen_libs::{AppState, ResourceEnum};
|
||||
use imphnen_utils::{extract_email, extract_email_async, extract_real_ip};
|
||||
use serde_json;
|
||||
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_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: None,
|
||||
user_id: user_id.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
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: Utc::now(),
|
||||
};
|
||||
|
||||
// Simpan audit log ke database
|
||||
let action = audit_log.action.clone();
|
||||
match save_audit_log(&state.surrealdb_mem, 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/teams/admin/",
|
||||
"/v1/users/admin/",
|
||||
"/v1/permissions/",
|
||||
"/v1/roles/",
|
||||
"/v1/gacha/admin/",
|
||||
"/v1/hackathon/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.query_get_stored_user(email.clone()).await {
|
||||
Ok(user) => Some(user.id.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) {
|
||||
if 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
|
||||
async fn save_audit_log(
|
||||
db: &imphnen_libs::SurrealMemClient,
|
||||
audit_log: AuditLogSchema,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let table = ResourceEnum::AuditLog.to_string();
|
||||
let key = (table.as_str(), surrealdb::sql::Id::rand().to_string());
|
||||
|
||||
let content = serde_json::to_value(&audit_log)?;
|
||||
db.create::<Option<AuditLogSchema>>(key)
|
||||
.content(content)
|
||||
.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::{
|
||||
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
|
||||
}
|
||||
@@ -1,93 +1,66 @@
|
||||
use axum::{
|
||||
Extension, extract::Request, http::StatusCode, middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use imphnen_libs::{AppState, jsonwebtoken::decode_access_token};
|
||||
use imphnen_entities::UsersDetailQueryDto;
|
||||
use imphnen_utils::common_response;
|
||||
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
|
||||
use std::convert::Infallible;
|
||||
use imphnen_libs::ResourceEnum;
|
||||
use imphnen_utils::make_thing;
|
||||
|
||||
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(common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)),
|
||||
};
|
||||
|
||||
let token = auth_header.token();
|
||||
|
||||
let claims = match decode_access_token(token) {
|
||||
Ok(token_data) => token_data.claims,
|
||||
Err(_) => return Ok(common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or expired token",
|
||||
)),
|
||||
};
|
||||
|
||||
let user_id = claims.user_id.clone();
|
||||
|
||||
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &user_id);
|
||||
|
||||
// Try SurrealDB mem first
|
||||
let mem_db = &state.surrealdb_mem;
|
||||
let user_data = if let Ok(Some(user)) = mem_db.select::<Option<UsersDetailQueryDto>>(("users", &user_id)).await {
|
||||
if !user.is_deleted && !user.role.is_deleted {
|
||||
Some(user)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Fallback to main DB if not found in mem
|
||||
let user_data = if let Some(user) = user_data {
|
||||
user
|
||||
} else {
|
||||
match state.user_lookup_service.get_user_by_id_internal(&thing_id, &state).await {
|
||||
Ok(user) => {
|
||||
// Cache in mem for future requests with retry logic
|
||||
let mut retry_count = 0;
|
||||
const MAX_RETRIES: u8 = 3;
|
||||
|
||||
while retry_count < MAX_RETRIES {
|
||||
match mem_db.update::<Option<UsersDetailQueryDto>>(("users", &user_id)).content(user.clone()).await {
|
||||
Ok(_) => {
|
||||
log::debug!("User {} cached successfully", user_id);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
retry_count += 1;
|
||||
log::warn!(
|
||||
"Failed to cache user {} (attempt {}/{}): {}",
|
||||
user_id, retry_count, MAX_RETRIES, e
|
||||
);
|
||||
if retry_count < MAX_RETRIES {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50 * retry_count as u64)).await;
|
||||
} else {
|
||||
log::error!("Failed to cache user {} after {} retries", user_id, MAX_RETRIES);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user
|
||||
},
|
||||
Err(_) => return Ok(common_response(StatusCode::UNAUTHORIZED, "User not found")),
|
||||
}
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(user_data);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
use axum::{
|
||||
Extension, extract::Request, http::StatusCode, middleware::Next,
|
||||
response::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::common_response;
|
||||
|
||||
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(common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)),
|
||||
};
|
||||
|
||||
let token = auth_header.token();
|
||||
|
||||
let claims = match decode_access_token(token) {
|
||||
Ok(token_data) => token_data.claims,
|
||||
Err(_) => return Ok(common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or expired token",
|
||||
)),
|
||||
};
|
||||
|
||||
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(common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid user identifier format",
|
||||
)),
|
||||
};
|
||||
|
||||
// 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(common_response(StatusCode::UNAUTHORIZED, "User not found or inactive")),
|
||||
};
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
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 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)
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
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 mod timeline_enforcement_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 use timeline_enforcement_middleware::{TimelineEnforcementLayer, TimelineOperationType};
|
||||
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;
|
||||
|
||||
@@ -1,103 +1,101 @@
|
||||
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") {
|
||||
if 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) {
|
||||
if !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};
|
||||
|
||||
/// 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/")
|
||||
}
|
||||
@@ -1,197 +1,201 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, Response, StatusCode},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use imphnen_entities::PermissionsEnum;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{common_response, 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(|| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get user data with permissions from auth repository
|
||||
let user = app_state.auth_repository.query_get_stored_user(email).await
|
||||
.map_err(|_| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"User session expired or not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Extract user permissions from role
|
||||
let user_permissions = extract_user_permissions(&user);
|
||||
|
||||
// Check if user has required permissions
|
||||
if !has_required_permissions(&user_permissions, &permissions) {
|
||||
return Err(common_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have the required permissions",
|
||||
));
|
||||
}
|
||||
|
||||
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: &imphnen_entities::UsersDetailQueryDto) -> Vec<String> {
|
||||
user.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.id.to_raw()) {
|
||||
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(|| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = app_state.auth_repository.query_get_stored_user(email).await
|
||||
.map_err(|_| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"User session expired or not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_permissions = extract_user_permissions(&user);
|
||||
|
||||
if !has_required_permissions(&user_permissions, &required_permissions) {
|
||||
return Err(common_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have the required permissions",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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::common_response;
|
||||
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(|| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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(|_| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"User session expired or not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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(common_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have the required permissions",
|
||||
));
|
||||
}
|
||||
|
||||
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(|| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = app_state.user_lookup_service.get_user_by_email(&email, app_state).await
|
||||
.map_err(|_| {
|
||||
common_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"User session expired or not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_permissions = extract_user_permissions(&user);
|
||||
|
||||
if !has_required_permissions(&user_permissions, &required_permissions) {
|
||||
return Err(common_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have the required permissions",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,157 +1,182 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, Response, StatusCode},
|
||||
middleware::Next,
|
||||
Extension,
|
||||
};
|
||||
use imphnen_entities::audit_log::RateLimitSchema;
|
||||
use imphnen_libs::{AppState, ResourceEnum};
|
||||
use imphnen_utils::extract_real_ip;
|
||||
|
||||
/// Rate limiting middleware yang menggunakan SurrealDB memori untuk semua public endpoints
|
||||
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 SurrealDB
|
||||
match check_rate_limit(&state.surrealdb_mem, &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 (legacy compatibility)
|
||||
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 SurrealDB
|
||||
match check_rate_limit(&state.surrealdb_mem, &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/hackathon/participate",
|
||||
"/v1/cms/landing",
|
||||
];
|
||||
|
||||
public_endpoints.iter().any(|endpoint| uri.starts_with(endpoint))
|
||||
}
|
||||
|
||||
/// Periksa rate limit untuk IP tertentu menggunakan SurrealDB
|
||||
async fn check_rate_limit(
|
||||
db: &imphnen_libs::SurrealMemClient,
|
||||
ip_address: &str,
|
||||
max_requests: u32,
|
||||
window_duration_secs: u64,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let table = ResourceEnum::RateLimit.to_string();
|
||||
let key = (table.as_str(), ip_address);
|
||||
|
||||
// Coba ambil record rate limit yang ada
|
||||
let existing_record: Option<RateLimitSchema> = db.select(key).await?;
|
||||
|
||||
match existing_record {
|
||||
Some(mut record) => {
|
||||
// Reset counter jika window sudah expired
|
||||
let was_reset = record.reset_if_expired();
|
||||
|
||||
if !was_reset {
|
||||
// Increment counter jika masih dalam window
|
||||
record.increment();
|
||||
}
|
||||
|
||||
// Periksa apakah rate limit terlampaui sebelum update
|
||||
let is_limited = record.is_rate_limited(max_requests);
|
||||
|
||||
// Update record di database
|
||||
if let Err(e) = db.update::<Option<RateLimitSchema>>(key).content(record.clone()).await {
|
||||
log::error!("Failed to update rate limit record for {}: {}", ip_address, e);
|
||||
// Gagal update, tapi tetap enforce rate limit berdasarkan data yang ada
|
||||
}
|
||||
|
||||
Ok(is_limited)
|
||||
}
|
||||
None => {
|
||||
// Buat record baru jika belum ada
|
||||
let new_record = RateLimitSchema::new(ip_address.to_string(), window_duration_secs);
|
||||
|
||||
// Simpan record baru ke database
|
||||
if let Err(e) = db.create::<Option<RateLimitSchema>>(key).content(new_record).await {
|
||||
log::error!("Failed to create rate limit record for {}: {}", ip_address, e);
|
||||
// Jika gagal create, izinkan request (fail open untuk availability)
|
||||
}
|
||||
|
||||
Ok(false) // Request pertama selalu diizinkan
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +1,127 @@
|
||||
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::{
|
||||
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)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, Response, StatusCode},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::common_response;
|
||||
use std::task::{Context, Poll};
|
||||
use tower::{Layer, Service};
|
||||
|
||||
/// Middleware to enforce timeline-based access control for hackathon operations
|
||||
#[derive(Clone)]
|
||||
pub struct TimelineEnforcementLayer {
|
||||
app_state: AppState,
|
||||
allowed_phases: Vec<String>,
|
||||
operation_type: TimelineOperationType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TimelineOperationType {
|
||||
Registration,
|
||||
Submission,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl TimelineEnforcementLayer {
|
||||
/// Create a new timeline enforcement middleware layer
|
||||
pub fn new(
|
||||
app_state: AppState,
|
||||
allowed_phases: Vec<String>,
|
||||
operation_type: TimelineOperationType,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_state,
|
||||
allowed_phases,
|
||||
operation_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create middleware for registration operations
|
||||
pub fn for_registration(app_state: AppState) -> Self {
|
||||
Self::new(
|
||||
app_state,
|
||||
vec!["registration".to_string()],
|
||||
TimelineOperationType::Registration,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create middleware for submission operations
|
||||
pub fn for_submission(app_state: AppState) -> Self {
|
||||
Self::new(
|
||||
app_state,
|
||||
vec!["submission".to_string()],
|
||||
TimelineOperationType::Submission,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create middleware for custom operations with specific allowed phases
|
||||
pub fn for_custom(
|
||||
app_state: AppState,
|
||||
allowed_phases: Vec<String>,
|
||||
operation_name: String,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
app_state,
|
||||
allowed_phases,
|
||||
TimelineOperationType::Custom(operation_name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TimelineEnforcementLayer {
|
||||
type Service = TimelineEnforcementMiddleware<S>;
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
TimelineEnforcementMiddleware {
|
||||
inner,
|
||||
app_state: self.app_state.clone(),
|
||||
allowed_phases: self.allowed_phases.clone(),
|
||||
operation_type: self.operation_type.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TimelineEnforcementMiddleware<S> {
|
||||
inner: S,
|
||||
app_state: AppState,
|
||||
allowed_phases: Vec<String>,
|
||||
operation_type: TimelineOperationType,
|
||||
}
|
||||
|
||||
impl<S> Service<Request<Body>> for TimelineEnforcementMiddleware<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 allowed_phases = self.allowed_phases.clone();
|
||||
let operation_type = self.operation_type.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
// Extract hackathon ID from request path - this assumes standard routing patterns
|
||||
let hackathon_id = extract_hackathon_id_from_request(&req)?;
|
||||
|
||||
// Get current time
|
||||
let current_time = Utc::now();
|
||||
|
||||
// Get hackathon timeline phases
|
||||
let timeline_phases = match get_active_timeline_phases(hackathon_id, current_time, &app_state).await {
|
||||
Ok(phases) => phases,
|
||||
Err(e) => return Err(common_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("Failed to check timeline: {}", e),
|
||||
)),
|
||||
};
|
||||
|
||||
// Check if any allowed phase is currently active
|
||||
let is_allowed = timeline_phases.iter().any(|phase| {
|
||||
allowed_phases.iter().any(|allowed| {
|
||||
phase.phase.to_lowercase() == *allowed
|
||||
})
|
||||
});
|
||||
|
||||
if !is_allowed {
|
||||
let operation_name = match &operation_type {
|
||||
TimelineOperationType::Registration => "registration",
|
||||
TimelineOperationType::Submission => "submission",
|
||||
TimelineOperationType::Custom(name) => name,
|
||||
};
|
||||
|
||||
let error_msg = format!(
|
||||
"{} is not allowed outside of specified timeline phases. Current active phases: {:?}",
|
||||
operation_name,
|
||||
timeline_phases.iter().map(|p| p.phase.to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
return Err(common_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
&error_msg,
|
||||
));
|
||||
}
|
||||
|
||||
// Validate request body for timeline operations
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_json = match validate_timeline_request_body(body).await {
|
||||
Ok(json) => json,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Reconstruct request with validated body
|
||||
let req = Request::from_parts(parts, axum::body::Body::from(serde_json::to_vec(&body_json).unwrap()));
|
||||
|
||||
inner.call(req).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract hackathon ID from request path
|
||||
fn extract_hackathon_id_from_request(req: &Request<Body>) -> Result<String, Response<Body>> {
|
||||
let uri = req.uri();
|
||||
let path = uri.path();
|
||||
|
||||
// Look for patterns like /hackathons/{id}/... or /hackathons/{id}
|
||||
let segments: Vec<&str> = path.split('/').filter(|&s| !s.is_empty()).collect();
|
||||
|
||||
for (i, segment) in segments.iter().enumerate() {
|
||||
if *segment == "hackathons" && i + 1 < segments.len() {
|
||||
return Ok(segments[i + 1].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(common_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Could not extract hackathon ID from request path",
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate request body for timeline operations
|
||||
pub async fn validate_timeline_request_body(
|
||||
body: Body,
|
||||
) -> Result<serde_json::Value, Response<Body>> {
|
||||
let bytes = axum::body::to_bytes(body, 1024 * 1024).await // Example limit: 1MB
|
||||
.map_err(|e| common_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("Failed to read request body: {}", e),
|
||||
))?;
|
||||
|
||||
let body_json = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| common_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("Invalid JSON in request body: {}", e),
|
||||
))?;
|
||||
|
||||
Ok(body_json)
|
||||
}
|
||||
|
||||
/// Get active timeline phases for a hackathon at current time
|
||||
async fn get_active_timeline_phases(
|
||||
hackathon_id: String,
|
||||
current_time: DateTime<Utc>,
|
||||
_app_state: &AppState,
|
||||
) -> Result<Vec<HackathonTimelinePhase>, String> {
|
||||
// In a real implementation, this would call the hackathon service to get timeline phases
|
||||
// For now, we'll return a mock implementation that demonstrates the pattern
|
||||
|
||||
// This is a placeholder - in production, you would call:
|
||||
// let timeline_dtos = app_state.hackathon_service.get_active_timeline_phases(hackathon_id, current_time).await?;
|
||||
|
||||
// For demonstration purposes, we'll return a mock response
|
||||
Ok(vec![HackathonTimelinePhase {
|
||||
id: "timeline-1".to_string(),
|
||||
hackathon_id: hackathon_id.clone(),
|
||||
phase: "registration".to_string(),
|
||||
title: "Registration Phase".to_string(),
|
||||
start_date: current_time - chrono::Duration::days(1),
|
||||
end_date: current_time + chrono::Duration::days(2),
|
||||
is_active: true,
|
||||
}])
|
||||
}
|
||||
|
||||
/// DTO for timeline phase (matches what would be returned from service)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HackathonTimelinePhase {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub phase: String,
|
||||
pub title: String,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub end_date: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user