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:
co-authored by
Claude Sonnet 4.6
parent
2ae43b3bcc
commit
331a4a4e88
+184
-226
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "<script>alert('xss')</script>");
|
||||
/// ```
|
||||
pub fn sanitize_html(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
'&' => "&".to_string(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sanitize string to prevent 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>"),
|
||||
"<script>alert('xss')</script>"
|
||||
);
|
||||
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 {
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
'&' => "&".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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user