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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imphnen-utils"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
[dependencies]
+184 -226
View File
@@ -1,226 +1,184 @@
//! CSRF token generation and validation utilities.
//!
//! This module provides stateless CSRF token management using signed tokens
//! with timestamp validation to prevent cross-site request forgery attacks.
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Sha256, Digest};
use imphnen_entities::error_dto::error::Error;
use tracing::error;
#[derive(Debug, Serialize, Deserialize)]
struct CsrfPayload {
pub timestamp: u64,
pub random: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct OAuthCsrfPayload {
pub timestamp: u64,
pub random: String,
pub pkce_verifier: String,
}
/// Generate a signed CSRF token that can be validated without server-side storage
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
let random = uuid::Uuid::new_v4().to_string();
let payload = CsrfPayload {
timestamp,
random,
};
let payload_json = serde_json::to_string(&payload)
.map_err(|e| {
error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e);
Error::Auth("Failed to serialize CSRF payload".to_string())
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
// Create signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
Ok(format!("{payload_b64}.{signature}"))
}
/// Generate a signed OAuth CSRF token with PKCE verifier
pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
let random = uuid::Uuid::new_v4().to_string();
let payload = OAuthCsrfPayload {
timestamp,
random,
pkce_verifier: pkce_verifier.to_string(),
};
let payload_json = serde_json::to_string(&payload)
.map_err(|e| {
error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e);
Error::Auth("Failed to serialize OAuth CSRF payload".to_string())
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
// Create signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
Ok(format!("{payload_b64}.{signature}"))
}
/// Validate a CSRF token
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
// Verify signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid CSRF token signature".to_string()));
}
// Decode and validate payload
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
let payload: CsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?;
// Check timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
return Err(Error::Auth("CSRF token timestamp is in the future".to_string()));
}
Ok(())
}
/// Validate OAuth CSRF token and extract PKCE verifier
pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<String, Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
// Verify signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string()));
}
// Decode and validate payload
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
// Check timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string()));
}
Ok(payload.pkce_verifier)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_csrf_token_generation_and_validation() {
let secret = "test_secret";
// Generate token
let token = generate_csrf_token(secret).unwrap();
// Validate token (should pass)
assert!(validate_csrf_token(&token, secret, 300).is_ok());
// Validate with wrong secret (should fail)
assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err());
}
#[test]
fn test_csrf_token_expiration() {
let secret = "test_secret";
let token = generate_csrf_token(secret).unwrap();
// Add a 2 second delay to ensure the token expires when max_age is 1 second
std::thread::sleep(std::time::Duration::from_secs(2));
// Should fail with 1 second max age (token is now 2 seconds old)
assert!(validate_csrf_token(&token, secret, 1).is_err());
// Should still work with a large max age
assert!(validate_csrf_token(&token, secret, 300).is_ok());
}
#[test]
fn test_invalid_csrf_token_format() {
let secret = "test_secret";
// Invalid format (no dot)
assert!(validate_csrf_token("invalid_token", secret, 300).is_err());
// Invalid format (too many dots)
assert!(validate_csrf_token("a.b.c", secret, 300).is_err());
}
}
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use imphnen_entities::error_dto::error::Error;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::error;
#[derive(Debug, Serialize, Deserialize)]
struct CsrfPayload {
pub timestamp: u64,
pub random: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct OAuthCsrfPayload {
pub timestamp: u64,
pub random: String,
pub pkce_verifier: String,
}
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
let random = uuid::Uuid::new_v4().to_string();
let payload = CsrfPayload { timestamp, random };
let payload_json = serde_json::to_string(&payload).map_err(|e| {
error!(
"CSRF Token Generation: Failed to serialize CSRF payload: {:?}",
e
);
Error::Auth("Failed to serialize CSRF payload".to_string())
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
Ok(format!("{payload_b64}.{signature}"))
}
pub fn generate_oauth_csrf_token(
secret: &str,
pkce_verifier: &str,
) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
let random = uuid::Uuid::new_v4().to_string();
let payload = OAuthCsrfPayload {
timestamp,
random,
pkce_verifier: pkce_verifier.to_string(),
};
let payload_json = serde_json::to_string(&payload).map_err(|e| {
error!(
"OAuth CSRF Token Generation: Failed to serialize payload: {:?}",
e
);
Error::Auth("Failed to serialize OAuth CSRF payload".to_string())
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
Ok(format!("{payload_b64}.{signature}"))
}
pub fn validate_csrf_token(
token: &str,
secret: &str,
max_age_seconds: u64,
) -> Result<(), Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid CSRF token signature".to_string()));
}
let payload_json = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
let payload: CsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 {
return Err(Error::Auth(
"CSRF token timestamp is in the future".to_string(),
));
}
Ok(())
}
pub fn validate_oauth_csrf_token(
token: &str,
secret: &str,
max_age_seconds: u64,
) -> Result<String, Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth(
"Invalid OAuth CSRF token signature".to_string(),
));
}
let payload_json = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 {
return Err(Error::Auth(
"OAuth CSRF token timestamp is in the future".to_string(),
));
}
Ok(payload.pkce_verifier)
}
+124 -114
View File
@@ -1,114 +1,124 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Serialize)]
pub enum AppError {
ValidationError(String),
AuthenticationError(String),
AuthorizationError(String),
NotFoundError(String),
ConflictError(String),
InternalServerError(String),
BadRequestError(String),
ForbiddenError(String),
PaymentRequiredError(String),
MethodNotAllowedError(String),
NotAcceptableError(String),
RequestTimeoutError(String),
TooManyRequestsError(String),
GatewayTimeoutError(String),
ServiceUnavailableError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
AppError::AuthenticationError(msg) => write!(f, "Authentication failed: {}", msg),
AppError::AuthorizationError(msg) => write!(f, "Authorization failed: {}", msg),
AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg),
AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg),
AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg),
AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg),
AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg),
AppError::MethodNotAllowedError(msg) => write!(f, "Method not allowed: {}", msg),
AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg),
AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg),
AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg),
AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg),
AppError::ServiceUnavailableError(msg) => write!(f, "Service unavailable: {}", msg),
}
}
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED,
AppError::AuthorizationError(_) => StatusCode::FORBIDDEN,
AppError::NotFoundError(_) => StatusCode::NOT_FOUND,
AppError::ConflictError(_) => StatusCode::CONFLICT,
AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::BadRequestError(_) => StatusCode::BAD_REQUEST,
AppError::ForbiddenError(_) => StatusCode::FORBIDDEN,
AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED,
AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED,
AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE,
AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT,
AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS,
AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT,
AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub fn message(&self) -> String {
self.to_string()
}
}
impl From<sea_orm::DbErr> for AppError {
fn from(err: sea_orm::DbErr) -> Self {
AppError::InternalServerError(format!("Database error: {err}"))
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::InternalServerError(format!("Error: {err}"))
}
}
impl From<chrono::ParseError> for AppError {
fn from(err: chrono::ParseError) -> Self {
AppError::BadRequestError(format!("Date parsing error: {err}"))
}
}
impl From<uuid::Error> for AppError {
fn from(err: uuid::Error) -> Self {
AppError::BadRequestError(format!("UUID parsing error: {err}"))
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
(
status,
Json(json!({
"message": self.to_string(),
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
}
pub type Result<T, E = AppError> = std::result::Result<T, E>;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Serialize)]
pub enum AppError {
ValidationError(String),
AuthenticationError(String),
AuthorizationError(String),
NotFoundError(String),
ConflictError(String),
InternalServerError(String),
BadRequestError(String),
ForbiddenError(String),
PaymentRequiredError(String),
MethodNotAllowedError(String),
NotAcceptableError(String),
RequestTimeoutError(String),
TooManyRequestsError(String),
GatewayTimeoutError(String),
ServiceUnavailableError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
AppError::AuthenticationError(msg) => {
write!(f, "Authentication failed: {}", msg)
}
AppError::AuthorizationError(msg) => {
write!(f, "Authorization failed: {}", msg)
}
AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg),
AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg),
AppError::InternalServerError(msg) => {
write!(f, "Internal server error: {}", msg)
}
AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg),
AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg),
AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg),
AppError::MethodNotAllowedError(msg) => {
write!(f, "Method not allowed: {}", msg)
}
AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg),
AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg),
AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg),
AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg),
AppError::ServiceUnavailableError(msg) => {
write!(f, "Service unavailable: {}", msg)
}
}
}
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED,
AppError::AuthorizationError(_) => StatusCode::FORBIDDEN,
AppError::NotFoundError(_) => StatusCode::NOT_FOUND,
AppError::ConflictError(_) => StatusCode::CONFLICT,
AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::BadRequestError(_) => StatusCode::BAD_REQUEST,
AppError::ForbiddenError(_) => StatusCode::FORBIDDEN,
AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED,
AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED,
AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE,
AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT,
AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS,
AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT,
AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub fn message(&self) -> String {
self.to_string()
}
}
impl From<sea_orm::DbErr> for AppError {
fn from(err: sea_orm::DbErr) -> Self {
AppError::InternalServerError(format!("Database error: {err}"))
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::InternalServerError(format!("Error: {err}"))
}
}
impl From<chrono::ParseError> for AppError {
fn from(err: chrono::ParseError) -> Self {
AppError::BadRequestError(format!("Date parsing error: {err}"))
}
}
impl From<uuid::Error> for AppError {
fn from(err: uuid::Error) -> Self {
AppError::BadRequestError(format!("UUID parsing error: {err}"))
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
(
status,
Json(json!({
"message": self.to_string(),
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
}
pub type Result<T, E = AppError> = std::result::Result<T, E>;
+136 -155
View File
@@ -1,155 +1,136 @@
//! Email extraction utilities from authentication tokens.
//!
//! This module provides functions to extract email addresses from JWT tokens
//! and Google OAuth access tokens, supporting both synchronous and asynchronous
//! validation methods.
use tracing::{error, info};
use imphnen_libs::jsonwebtoken::decode_access_token;
use axum::http::{HeaderMap, header::AUTHORIZATION};
/// Extracts the email from the Authorization header, if present and valid.
/// Supports both our internal JWT tokens and Google access tokens.
pub fn extract_email(headers: &HeaderMap) -> Option<String> {
let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
None => {
error!("Authorization header missing in extract_email");
return None;
}
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email");
return None;
}
};
// First try to decode as our internal JWT token
match decode_access_token(token) {
Ok(data) => {
Some(data.claims.sub)
}
Err(_) => {
// If it fails, it might be a Google access token
// For Google tokens, we need async validation, so we'll return None here
// and handle Google tokens separately in the calling code
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly.");
None
}
}
}
/// Async version that can handle Google access tokens
pub async fn extract_email_async(headers: &HeaderMap) -> Option<String> {
let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
None => {
error!("Authorization header missing in extract_email_async");
return None;
}
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async");
return None;
}
};
// First try to decode as our internal JWT token
match decode_access_token(token) {
Ok(data) => {
Some(data.claims.sub)
}
Err(_) => {
// If it fails, try to validate as Google access token
extract_email_from_google_token(token).await
}
}
}
/// Extracts email from Google access token by calling Google's tokeninfo endpoint
async fn extract_email_from_google_token(token: &str) -> Option<String> {
use serde_json::Value;
let client = reqwest::Client::new();
let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={token}");
match client.get(&tokeninfo_url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<Value>().await {
Ok(token_info) => {
if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) {
info!(email = %email, "Successfully extracted email from Google token");
Some(email.to_string())
} else {
error!("Email not found in Google token info response");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to parse Google token info response");
None
}
}
} else {
error!(status = %response.status(), "Google token validation failed");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to validate Google token");
None
}
}
}
/// Extracts the email from a JWT token string.
/// Supports both our internal JWT tokens and Google access tokens.
pub fn extract_email_token(token: String) -> Option<String> {
match decode_access_token(&token) {
Ok(data) => {
Some(data.claims.sub)
}
Err(_) => {
// If it fails, it might be a Google access token
// For Google tokens, we need async validation, so we'll return None here
// and handle Google tokens separately in the calling code
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly.");
None
}
}
}
/// A simple helper to check if a token string looks like a JWT.
fn is_jwt(token: &str) -> bool {
let parts: Vec<_> = token.split('.').collect();
parts.len() == 3
}
/// Async version of extract_email_token that can handle Google access tokens
pub async fn extract_email_token_async(token: String) -> Option<String> {
if is_jwt(&token) && let Ok(data) = decode_access_token(&token) {
return Some(data.claims.sub);
}
// If it's not a valid internal JWT, try to validate as Google access token
extract_email_from_google_token(&token).await
}
use axum::http::{HeaderMap, header::AUTHORIZATION};
use imphnen_libs::jsonwebtoken::decode_access_token;
use tracing::{error, info};
pub fn extract_email(headers: &HeaderMap) -> Option<String> {
let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
None => {
error!("Authorization header missing in extract_email");
return None;
}
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(
auth_str,
"Authorization header does not start with 'Bearer ' in extract_email"
);
return None;
}
};
match decode_access_token(token) {
Ok(data) => Some(data.claims.sub),
Err(_) => {
error!(
"Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly."
);
None
}
}
}
pub async fn extract_email_async(headers: &HeaderMap) -> Option<String> {
let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
None => {
error!("Authorization header missing in extract_email_async");
return None;
}
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(
auth_str,
"Authorization header does not start with 'Bearer ' in extract_email_async"
);
return None;
}
};
match decode_access_token(token) {
Ok(data) => Some(data.claims.sub),
Err(_) => extract_email_from_google_token(token).await,
}
}
async fn extract_email_from_google_token(token: &str) -> Option<String> {
use serde_json::Value;
let client = reqwest::Client::new();
let tokeninfo_url =
format!("https://oauth2.googleapis.com/tokeninfo?access_token={token}");
match client.get(&tokeninfo_url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<Value>().await {
Ok(token_info) => {
if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) {
info!(email = %email, "Successfully extracted email from Google token");
Some(email.to_string())
} else {
error!("Email not found in Google token info response");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to parse Google token info response");
None
}
}
} else {
error!(status = %response.status(), "Google token validation failed");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to validate Google token");
None
}
}
}
pub fn extract_email_token(token: String) -> Option<String> {
match decode_access_token(&token) {
Ok(data) => Some(data.claims.sub),
Err(_) => {
error!(
"Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly."
);
None
}
}
}
fn is_jwt(token: &str) -> bool {
let parts: Vec<_> = token.split('.').collect();
parts.len() == 3
}
pub async fn extract_email_token_async(token: String) -> Option<String> {
if is_jwt(&token)
&& let Ok(data) = decode_access_token(&token)
{
return Some(data.claims.sub);
}
extract_email_from_google_token(&token).await
}
+136 -144
View File
@@ -1,144 +1,136 @@
use axum::http::HeaderMap;
/// Extract real client IP address from various headers commonly used in proxies
///
/// Priority order:
/// 1. X-Forwarded-For (first IP in the list)
/// 2. X-Real-IP
/// 3. CF-Connecting-IP (Cloudflare)
/// 4. True-Client-IP (Akamai and others)
/// 5. X-Cluster-Client-IP
/// 6. Forwarded (standard header)
/// 7. Direct connection IP (if available)
pub fn extract_real_ip(headers: &HeaderMap) -> Option<String> {
// Try different headers in priority order
if let Some(ip) = extract_from_x_forwarded_for(headers) {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "x-real-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "true-client-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") {
return Some(ip);
}
if let Some(ip) = extract_from_forwarded_header(headers) {
return Some(ip);
}
None
}
/// Extract the first IP from X-Forwarded-For header
fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option<String> {
let header_value = headers.get("x-forwarded-for")?;
let header_str = header_value.to_str().ok()?;
// X-Forwarded-For can contain multiple IPs separated by commas
// We take the first one (the original client IP)
header_str.split(',').next()
.map(|ip| ip.trim().to_string())
.filter(|ip| is_valid_ip(ip))
}
/// Extract IP from Forwarded header (RFC 7239)
fn extract_from_forwarded_header(headers: &HeaderMap) -> Option<String> {
let header_value = headers.get("forwarded")?;
let header_str = header_value.to_str().ok()?;
// Parse Forwarded header: for=192.0.2.60;proto=http;by=203.0.113.43
for part in header_str.split(';') {
if part.trim().starts_with("for=") {
let ip = part.trim().trim_start_matches("for=");
// Remove quotes and brackets if present
let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']');
if is_valid_ip(ip) {
return Some(ip.to_string());
}
}
}
None
}
/// Extract value from a specific header
fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option<String> {
let header_value = headers.get(header_name)?;
let value_str = header_value.to_str().ok()?;
if is_valid_ip(value_str) {
Some(value_str.to_string())
} else {
None
}
}
/// Basic IP validation
fn is_valid_ip(ip: &str) -> bool {
// Simple validation - check if it looks like an IP address
if ip.is_empty() || ip == "unknown" || ip == "undefined" {
return false;
}
// Check for IPv4 pattern
if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.') {
return true;
}
// Check for IPv6 pattern (simplified)
if ip.contains(':') {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn test_extract_from_x_forwarded_for() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1, 10.0.0.1"));
assert_eq!(extract_from_x_forwarded_for(&headers), Some("192.168.1.1".to_string()));
}
#[test]
fn test_extract_from_forwarded_header() {
let mut headers = HeaderMap::new();
headers.insert("forwarded", HeaderValue::from_static("for=192.168.1.1;proto=https"));
assert_eq!(extract_from_forwarded_header(&headers), Some("192.168.1.1".to_string()));
}
#[test]
fn test_extract_real_ip_priority() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1"));
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1"));
// Should prefer x-forwarded-for
assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string()));
}
#[test]
fn test_invalid_ip_rejection() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("unknown"));
assert_eq!(extract_real_ip(&headers), None);
}
}
use axum::http::HeaderMap;
pub fn extract_real_ip(headers: &HeaderMap) -> Option<String> {
if let Some(ip) = extract_from_x_forwarded_for(headers) {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "x-real-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "true-client-ip") {
return Some(ip);
}
if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") {
return Some(ip);
}
if let Some(ip) = extract_from_forwarded_header(headers) {
return Some(ip);
}
None
}
fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option<String> {
let header_value = headers.get("x-forwarded-for")?;
let header_str = header_value.to_str().ok()?;
header_str
.split(',')
.next()
.map(|ip| ip.trim().to_string())
.filter(|ip| is_valid_ip(ip))
}
fn extract_from_forwarded_header(headers: &HeaderMap) -> Option<String> {
let header_value = headers.get("forwarded")?;
let header_str = header_value.to_str().ok()?;
for part in header_str.split(';') {
if part.trim().starts_with("for=") {
let ip = part.trim().trim_start_matches("for=");
let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']');
if is_valid_ip(ip) {
return Some(ip.to_string());
}
}
}
None
}
fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option<String> {
let header_value = headers.get(header_name)?;
let value_str = header_value.to_str().ok()?;
if is_valid_ip(value_str) {
Some(value_str.to_string())
} else {
None
}
}
fn is_valid_ip(ip: &str) -> bool {
if ip.is_empty() || ip == "unknown" || ip == "undefined" {
return false;
}
if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.')
{
return true;
}
if ip.contains(':') {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn test_extract_from_x_forwarded_for() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("192.168.1.1, 10.0.0.1"),
);
assert_eq!(
extract_from_x_forwarded_for(&headers),
Some("192.168.1.1".to_string())
);
}
#[test]
fn test_extract_from_forwarded_header() {
let mut headers = HeaderMap::new();
headers.insert(
"forwarded",
HeaderValue::from_static("for=192.168.1.1;proto=https"),
);
assert_eq!(
extract_from_forwarded_header(&headers),
Some("192.168.1.1".to_string())
);
}
#[test]
fn test_extract_real_ip_priority() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1"));
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1"));
assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string()));
}
#[test]
fn test_invalid_ip_rejection() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("unknown"));
assert_eq!(extract_real_ip(&headers), None);
}
}
+27 -30
View File
@@ -1,30 +1,27 @@
use tracing::{info};
use chrono::{DateTime, Utc};
/// Returns the current UTC date/time as an RFC3339 string.
pub fn get_iso_date() -> String {
info!("get_iso_date called");
let now: DateTime<Utc> = Utc::now();
let date_str = now.to_rfc3339();
info!(date_str = %date_str, "get_iso_date returning RFC3339 date string");
date_str
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::DateTime;
#[test]
fn test_get_iso_date() {
let date_str = get_iso_date();
// Should be valid RFC3339
let parsed = DateTime::parse_from_rfc3339(&date_str);
assert!(parsed.is_ok());
// Should be recent (within last second)
let now = Utc::now();
let parsed = parsed.unwrap().with_timezone(&Utc);
let diff = (now - parsed).num_milliseconds().abs();
assert!(diff < 1000); // Within 1 second
}
}
use chrono::{DateTime, Utc};
use tracing::info;
pub fn get_iso_date() -> String {
info!("get_iso_date called");
let now: DateTime<Utc> = Utc::now();
let date_str = now.to_rfc3339();
info!(date_str = %date_str, "get_iso_date returning RFC3339 date string");
date_str
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::DateTime;
#[test]
fn test_get_iso_date() {
let date_str = get_iso_date();
let parsed = DateTime::parse_from_rfc3339(&date_str);
assert!(parsed.is_ok());
let now = Utc::now();
let parsed = parsed.unwrap().with_timezone(&Utc);
let diff = (now - parsed).num_milliseconds().abs();
assert!(diff < 1000);
}
}
+80 -87
View File
@@ -1,87 +1,80 @@
//! OTP generation utilities with time-based expiration and secure hashing.
//!
//! This module provides functionality to generate one-time passwords (OTPs) with
//! a 5-minute expiration time and SHA256 hashing for secure storage and validation,
//! preventing replay attacks.
use rand::{Rng, rng};
use sha2::{Sha256, Digest};
use chrono::{DateTime, Utc, Duration};
/// Represents an OTP with its code, hashed value and expiration time
#[derive(Debug, Clone)]
pub struct OtpData {
pub code: u32,
pub hash: String,
pub expires_at: DateTime<Utc>,
}
pub struct OtpManager;
impl OtpManager {
/// Generates a new OTP with a 5-minute expiration and SHA256 hash for secure storage
pub fn generate_otp() -> OtpData {
let code = rng().random_range(100_000..1_000_000);
let otp_str = code.to_string();
let mut hasher = Sha256::new();
hasher.update(otp_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let expires_at = Utc::now() + Duration::minutes(5);
OtpData { code, hash, expires_at }
}
/// Validates the user-provided OTP against the stored OTP data
/// Checks both hash match and expiration
pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool {
if Utc::now() > stored.expires_at {
return false;
}
let user_otp_str = user_otp.to_string();
let mut hasher = Sha256::new();
hasher.update(user_otp_str.as_bytes());
let user_hash = format!("{:x}", hasher.finalize());
user_hash == stored.hash
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_otp() {
let otp = OtpManager::generate_otp();
assert!(otp.code >= 100_000 && otp.code < 1_000_000);
assert!(!otp.hash.is_empty());
assert!(otp.expires_at > Utc::now());
assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5));
}
#[test]
fn test_validate_otp_valid() {
let otp = OtpManager::generate_otp();
assert!(OtpManager::validate_otp(&otp, otp.code));
}
#[test]
fn test_validate_otp_invalid_code() {
let otp = OtpManager::generate_otp();
assert!(!OtpManager::validate_otp(&otp, 123456)); // Wrong code
}
#[test]
fn test_validate_otp_expired() {
let mut otp = OtpManager::generate_otp();
otp.expires_at = Utc::now() - chrono::Duration::seconds(1); // Expired
assert!(!OtpManager::validate_otp(&otp, otp.code));
}
#[test]
fn test_otp_uniqueness() {
let otp1 = OtpManager::generate_otp();
let otp2 = OtpManager::generate_otp();
// Codes should be different (high probability)
assert_ne!(otp1.code, otp2.code);
assert_ne!(otp1.hash, otp2.hash);
}
}
use chrono::{DateTime, Duration, Utc};
use rand::{Rng, rng};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct OtpData {
pub code: u32,
pub hash: String,
pub expires_at: DateTime<Utc>,
}
pub struct OtpManager;
impl OtpManager {
pub fn generate_otp() -> OtpData {
let code = rng().random_range(100_000..1_000_000);
let otp_str = code.to_string();
let mut hasher = Sha256::new();
hasher.update(otp_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let expires_at = Utc::now() + Duration::minutes(5);
OtpData {
code,
hash,
expires_at,
}
}
pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool {
if Utc::now() > stored.expires_at {
return false;
}
let user_otp_str = user_otp.to_string();
let mut hasher = Sha256::new();
hasher.update(user_otp_str.as_bytes());
let user_hash = format!("{:x}", hasher.finalize());
user_hash == stored.hash
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_otp() {
let otp = OtpManager::generate_otp();
assert!(otp.code >= 100_000 && otp.code < 1_000_000);
assert!(!otp.hash.is_empty());
assert!(otp.expires_at > Utc::now());
assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5));
}
#[test]
fn test_validate_otp_valid() {
let otp = OtpManager::generate_otp();
assert!(OtpManager::validate_otp(&otp, otp.code));
}
#[test]
fn test_validate_otp_invalid_code() {
let otp = OtpManager::generate_otp();
assert!(!OtpManager::validate_otp(&otp, 123456));
}
#[test]
fn test_validate_otp_expired() {
let mut otp = OtpManager::generate_otp();
otp.expires_at = Utc::now() - chrono::Duration::seconds(1);
assert!(!OtpManager::validate_otp(&otp, otp.code));
}
#[test]
fn test_otp_uniqueness() {
let otp1 = OtpManager::generate_otp();
let otp2 = OtpManager::generate_otp();
assert_ne!(otp1.code, otp2.code);
assert_ne!(otp1.hash, otp2.hash);
}
}
+20 -18
View File
@@ -1,18 +1,20 @@
pub mod csrf_token;
pub mod pagination;
pub mod errors;
pub mod extract_email;
pub mod extract_ip;
pub mod generate_date;
pub mod generate_otp;
pub mod logger;
pub mod response_format;
pub mod sanitization;
// Re-export commonly used functions
pub use extract_email::{extract_email, extract_email_async};
pub use extract_ip::extract_real_ip;
pub use generate_date::get_iso_date;
pub use response_format::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage};
pub use sanitization::{sanitize_html, sanitize_dangerous_patterns, sanitize_filename, sanitize_user_text, normalize_whitespace, sanitize_email, sanitize_url};
pub use errors::{AppError, Result};
pub mod csrf_token;
pub mod errors;
pub mod extract_email;
pub mod extract_ip;
pub mod generate_date;
pub mod generate_otp;
pub mod logger;
pub mod pagination;
pub mod response_format;
pub mod sanitization;
pub use errors::{AppError, Result};
pub use extract_email::{extract_email, extract_email_async};
pub use extract_ip::extract_real_ip;
pub use generate_date::get_iso_date;
pub use response_format::{ApiCreated, ApiMessage, ApiPaginated, ApiSuccess};
pub use sanitization::{
normalize_whitespace, sanitize_dangerous_patterns, sanitize_email,
sanitize_filename, sanitize_html, sanitize_url, sanitize_user_text,
};
+12 -18
View File
@@ -1,18 +1,12 @@
use dotenvy::dotenv;
use tracing_subscriber::{EnvFilter, fmt};
/// Initializes the logger using tracing and tracing-subscriber.
/// Loads environment variables from `.env` and sets log level from `RUST_LOG`.
pub fn init_logger() {
dotenv().ok();
// Set up the tracing subscriber with EnvFilter from RUST_LOG
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("warn"))
.unwrap();
fmt()
.with_env_filter(filter)
.init();
}
use dotenvy::dotenv;
use tracing_subscriber::{EnvFilter, fmt};
pub fn init_logger() {
dotenv().ok();
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("warn"))
.expect("valid log filter");
fmt().with_env_filter(filter).init();
}
+2 -2
View File
@@ -1,4 +1,4 @@
pub use paginator_axum::PaginationQuery;
pub use paginator_rs::{PaginatorBuilder, PaginationParams};
pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
pub use paginator_rs::{PaginationParams, PaginatorBuilder};
pub use paginator_sea_orm::paginate_with_sort;
pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
+77 -58
View File
@@ -1,91 +1,110 @@
use crate::errors::AppError;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use imphnen_entities::error_dto::error::Error;
use paginator_utils::PaginatorResponse;
use serde::Serialize;
use serde_json::json;
use paginator_utils::PaginatorResponse;
use imphnen_entities::error_dto::error::Error;
use crate::errors::AppError;
impl From<Error> for AppError {
fn from(error: Error) -> Self {
match error {
Error::Db(detail) => AppError::InternalServerError(format!("Database error: {detail}")),
Error::Anyhow(detail) => AppError::InternalServerError(format!("Internal server error: {detail}")),
Error::StatusCode(status) => AppError::InternalServerError(format!("HTTP error: {status}")),
Error::Auth(detail) => AppError::AuthenticationError(format!("Authentication error: {detail}")),
Error::Validation(detail) => AppError::ValidationError(format!("Validation error: {detail}")),
}
}
fn from(error: Error) -> Self {
match error {
Error::Db(detail) => {
AppError::InternalServerError(format!("Database error: {detail}"))
}
Error::Anyhow(detail) => {
AppError::InternalServerError(format!("Internal server error: {detail}"))
}
Error::StatusCode(status) => {
AppError::InternalServerError(format!("HTTP error: {status}"))
}
Error::Auth(detail) => {
AppError::AuthenticationError(format!("Authentication error: {detail}"))
}
Error::Validation(detail) => {
AppError::ValidationError(format!("Validation error: {detail}"))
}
}
}
}
pub struct ApiSuccess<T: Serialize>(pub T);
impl<T: Serialize> IntoResponse for ApiSuccess<T> {
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
pub struct ApiCreated<T: Serialize>(pub T);
impl<T: Serialize> IntoResponse for ApiCreated<T> {
fn into_response(self) -> Response {
(
StatusCode::CREATED,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
fn into_response(self) -> Response {
(
StatusCode::CREATED,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
pub struct ApiPaginated<T: Serialize>(pub PaginatorResponse<T>);
impl<T: Serialize> IntoResponse for ApiPaginated<T> {
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({
"data": self.0.data,
"meta": self.0.meta,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({
"data": self.0.data,
"meta": self.0.meta,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
}
pub struct ApiMessage {
pub status: StatusCode,
pub message: String,
pub status: StatusCode,
pub message: String,
}
impl ApiMessage {
pub fn ok(message: impl Into<String>) -> Self {
Self { status: StatusCode::OK, message: message.into() }
}
pub fn ok(message: impl Into<String>) -> Self {
Self {
status: StatusCode::OK,
message: message.into(),
}
}
pub fn created(message: impl Into<String>) -> Self {
Self { status: StatusCode::CREATED, message: message.into() }
}
pub fn created(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CREATED,
message: message.into(),
}
}
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into() }
}
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
}
impl IntoResponse for ApiMessage {
fn into_response(self) -> Response {
(
self.status,
Json(json!({ "message": self.message, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
fn into_response(self) -> Response {
(
self.status,
Json(json!({ "message": self.message, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
+91 -208
View File
@@ -1,208 +1,91 @@
//! Input sanitization utilities for security
//!
//! This module provides utilities to sanitize user input and prevent
//! common security vulnerabilities like XSS, HTML injection, SQL injection, etc.
//! Specifically optimized for PostgreSQL backend (SurrealDB migration complete).
use regex::Regex;
use std::sync::LazyLock;
// Note: HTML escaping is done via char-by-char mapping for better performance
// No regex needed for basic HTML entity escaping
/// PostgreSQL-specific SQL injection patterns
///
/// Comprehensive pattern set targeting PostgreSQL vulnerabilities while maintaining
/// compatibility with standard SQL injection prevention
static SQL_INJECTION_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|truncate|vacuum|analyze|reindex|cluster|copy|exec|script|javascript|onerror|onload|with|from|where|join|group by|order by|limit|offset|having|distinct|into|values|union all|union distinct|::|%|:=|current_user|session_user|user|version|current_date|current_time|now|pg_sleep|pg_user|pg_database|pg_tables|pg_columns|chr|ascii|substring|position|strpos|concat|concat_ws|string_agg|array_agg|array_to_string|string_to_array)").unwrap()
});
/// Path traversal patterns
static PATH_TRAVERSAL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\.\.(/|\\)").unwrap()
});
/// Sanitize HTML by escaping special characters
///
/// # Example
/// ```rust
/// use imphnen_utils::sanitize_html;
///
/// let dirty = "<script>alert('xss')</script>";
/// let clean = sanitize_html(dirty);
/// assert_eq!(clean, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
/// ```
pub fn sanitize_html(input: &str) -> String {
input
.chars()
.map(|c| match c {
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
'&' => "&amp;".to_string(),
_ => c.to_string(),
})
.collect()
}
/// Sanitize string to prevent SQL injection and other dangerous patterns
///
/// PostgreSQL-optimized sanitization that removes potentially dangerous patterns
/// while preserving legitimate user input where possible
pub fn sanitize_dangerous_patterns(input: &str) -> String {
// First pass: Remove SQL injection patterns
let without_sql_injection = SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]");
// Second pass: Additional PostgreSQL-specific protection
let without_postgres_specific = without_sql_injection.replace(";--", ";[FILTERED]");
without_postgres_specific.to_owned()
}
/// Check if string contains path traversal attempts
pub fn contains_path_traversal(input: &str) -> bool {
PATH_TRAVERSAL_REGEX.is_match(input)
}
/// Sanitize a string for safe usage in file names
///
/// Removes or replaces characters that could cause issues in file systems
pub fn sanitize_filename(input: &str) -> String {
input
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect()
}
/// Sanitize user input text (removes HTML and dangerous patterns)
///
/// Use this for fields like names, descriptions, bios, etc.
pub fn sanitize_user_text(input: &str) -> String {
let without_html = sanitize_html(input);
sanitize_dangerous_patterns(&without_html)
}
/// Trim and normalize whitespace in a string
pub fn normalize_whitespace(input: &str) -> String {
input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
/// Validate and sanitize email format
pub fn sanitize_email(email: &str) -> Option<String> {
let trimmed = email.trim().to_lowercase();
// Basic email validation
if trimmed.contains('@') && trimmed.contains('.') {
Some(trimmed)
} else {
None
}
}
/// Sanitize URL to prevent javascript: and data: schemes
pub fn sanitize_url(url: &str) -> Option<String> {
let trimmed = url.trim();
// Block dangerous URL schemes
let lower = trimmed.to_lowercase();
if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("vbscript:") {
return None;
}
// Allow http, https, and relative URLs
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("/") {
Some(trimmed.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_html() {
assert_eq!(
sanitize_html("<script>alert('xss')</script>"),
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
);
assert_eq!(
sanitize_html("Normal text"),
"Normal text"
);
}
#[test]
fn test_sanitize_dangerous_patterns() {
// Test basic SQL injection
assert!(sanitize_dangerous_patterns("SELECT * FROM users").contains("[FILTERED]"));
// Test PostgreSQL-specific patterns
assert!(sanitize_dangerous_patterns("SELECT current_user;").contains("[FILTERED]"));
assert!(sanitize_dangerous_patterns("SELECT version();").contains("[FILTERED]"));
assert!(sanitize_dangerous_patterns("SELECT 'a'::text;").contains("[FILTERED]"));
assert!(sanitize_dangerous_patterns("SELECT 'a'%'b';").contains("[FILTERED]"));
// Test comment injection
assert!(sanitize_dangerous_patterns("'; DROP TABLE users; --").contains("[FILTERED]"));
// Test legitimate input remains unchanged
assert_eq!(
sanitize_dangerous_patterns("Normal search query using 'quotes' and ; semicolons"),
"Normal search query using 'quotes' and ; semicolons"
);
// Test PostgreSQL function filtering
assert!(sanitize_dangerous_patterns("SELECT pg_sleep(10);").contains("[FILTERED]"));
assert!(sanitize_dangerous_patterns("SELECT concat('a', 'b');").contains("[FILTERED]"));
}
#[test]
fn test_path_traversal() {
assert!(contains_path_traversal("../../../etc/passwd"));
assert!(contains_path_traversal("..\\windows\\system32"));
assert!(!contains_path_traversal("normal/path/to/file"));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(
sanitize_filename("file<name>.txt"),
"file_name_.txt"
);
assert_eq!(
sanitize_filename("normal_file.pdf"),
"normal_file.pdf"
);
}
#[test]
fn test_sanitize_url() {
assert_eq!(
sanitize_url("https://example.com"),
Some("https://example.com".to_string())
);
assert_eq!(sanitize_url("javascript:alert('xss')"), None);
assert_eq!(sanitize_url("data:text/html,<script>alert('xss')</script>"), None);
}
#[test]
fn test_normalize_whitespace() {
assert_eq!(
normalize_whitespace(" multiple spaces "),
"multiple spaces"
);
}
}
use regex::Regex;
use std::sync::LazyLock;
static SQL_INJECTION_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|truncate|vacuum|analyze|reindex|cluster|copy|exec|script|javascript|onerror|onload|with|from|where|join|group by|order by|limit|offset|having|distinct|into|values|union all|union distinct|::|%|:=|current_user|session_user|user|version|current_date|current_time|now|pg_sleep|pg_user|pg_database|pg_tables|pg_columns|chr|ascii|substring|position|strpos|concat|concat_ws|string_agg|array_agg|array_to_string|string_to_array)").expect("valid sql injection regex")
});
static PATH_TRAVERSAL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\.\.(/|\\)").expect("valid path traversal regex"));
pub fn sanitize_html(input: &str) -> String {
input
.chars()
.map(|c| match c {
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
'&' => "&amp;".to_string(),
_ => c.to_string(),
})
.collect()
}
pub fn sanitize_dangerous_patterns(input: &str) -> String {
let without_sql_injection =
SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]");
let without_postgres_specific =
without_sql_injection.replace(";--", ";[FILTERED]");
without_postgres_specific.to_owned()
}
pub fn contains_path_traversal(input: &str) -> bool {
PATH_TRAVERSAL_REGEX.is_match(input)
}
pub fn sanitize_filename(input: &str) -> String {
input
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect()
}
pub fn sanitize_user_text(input: &str) -> String {
let without_html = sanitize_html(input);
sanitize_dangerous_patterns(&without_html)
}
pub fn normalize_whitespace(input: &str) -> String {
input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
pub fn sanitize_email(email: &str) -> Option<String> {
let trimmed = email.trim().to_lowercase();
if trimmed.contains('@') && trimmed.contains('.') {
Some(trimmed)
} else {
None
}
}
pub fn sanitize_url(url: &str) -> Option<String> {
let trimmed = url.trim();
let lower = trimmed.to_lowercase();
if lower.starts_with("javascript:")
|| lower.starts_with("data:")
|| lower.starts_with("vbscript:")
{
return None;
}
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("/")
{
Some(trimmed.to_string())
} else {
None
}
}