diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 4592f6ac..579a5111 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,3 +1,23 @@ +//! Authentication and Authorization Module +//! +//! This module provides JWT-based authentication and authorization for the application. +//! It includes token generation, validation, cookie management, and middleware for +//! protecting routes. +//! +//! # Security Features +//! - HS256 JWT tokens with configurable expiration +//! - Secure, HttpOnly session cookies +//! - High-entropy secret validation +//! - Bearer token and cookie-based authentication +//! - Automatic token expiration handling +//! +//! # Usage +//! Before using any authentication functions, initialize the JWT secret: +//! ```rust,no_run +//! use linux_tutorial_cms::auth; +//! auth::init_jwt_secret().expect("Failed to initialize JWT secret"); +//! ``` + use axum::{ extract::FromRequestParts, http::{ @@ -15,34 +35,78 @@ use std::env; use std::sync::OnceLock; use time::{Duration as TimeDuration, OffsetDateTime}; +/// Global storage for the JWT secret key. +/// Initialized once at application startup via init_jwt_secret(). pub static JWT_SECRET: OnceLock = OnceLock::new(); +/// List of known placeholder secrets that must not be used in production. +/// These are common defaults found in example configurations. const SECRET_BLACKLIST: &[&str] = &[ "CHANGE_ME_OR_APP_WILL_FAIL", "your-super-secret-jwt-key-min-32-chars-change-me-in-production", "PLEASE-SET-THIS-VIA-DOCKER-COMPOSE-ENV", ]; +/// Minimum length for JWT secret to ensure adequate entropy (~256 bits). +/// Base64-encoded secrets should be at least this long. const MIN_SECRET_LENGTH: usize = 43; +/// Minimum number of unique characters required in the secret. +/// Helps detect low-entropy secrets like repeated characters. const MIN_UNIQUE_CHARS: usize = 10; +/// Minimum number of character classes (lowercase, uppercase, digits, symbols). +/// Ensures the secret has good diversity. const MIN_CHAR_CLASSES: usize = 3; +/// Name of the HTTP-only authentication cookie. pub const AUTH_COOKIE_NAME: &str = "ltcms_session"; +/// Authentication cookie time-to-live in seconds (24 hours). const AUTH_COOKIE_TTL_SECONDS: i64 = 24 * 60 * 60; +/// Initializes the JWT secret from the environment variable. +/// +/// This function must be called once at application startup before any +/// authentication operations. It validates the secret for security and +/// stores it in global state. +/// +/// # Security Validation +/// The secret is checked for: +/// - Presence (not missing or empty) +/// - Blacklisted placeholder values +/// - Minimum length (43 characters for ~256 bits of entropy) +/// - Character diversity (at least 3 character classes) +/// - Uniqueness (at least 10 unique characters) +/// +/// # Returns +/// - `Ok(())` if the secret was successfully initialized +/// - `Err(String)` with a descriptive error message if validation fails +/// +/// # Errors +/// - JWT_SECRET environment variable not set +/// - Secret is empty or whitespace only +/// - Secret uses a known placeholder value +/// - Secret has insufficient entropy +/// - Secret was already initialized (can only be called once) +/// +/// # Example +/// ```rust,no_run +/// use linux_tutorial_cms::auth; +/// auth::init_jwt_secret().expect("Failed to initialize JWT secret"); +/// ``` pub fn init_jwt_secret() -> Result<(), String> { - + // Load secret from environment let secret = env::var("JWT_SECRET") .map_err(|_| "JWT_SECRET environment variable not set".to_string())?; let trimmed = secret.trim(); + // Check for empty secret if trimmed.is_empty() { return Err("JWT_SECRET cannot be empty or whitespace".to_string()); } + // Check against known placeholder values if SECRET_BLACKLIST .iter() .any(|candidate| candidate.eq_ignore_ascii_case(trimmed)) @@ -53,6 +117,7 @@ pub fn init_jwt_secret() -> Result<(), String> { ); } + // Validate entropy if !secret_has_min_entropy(trimmed) { return Err( "JWT_SECRET must be a high-entropy value (~256 bits). Use a cryptographically random string of at least 43 characters mixing upper, lower, digits, and symbols." @@ -60,6 +125,7 @@ pub fn init_jwt_secret() -> Result<(), String> { ); } + // Store secret in global state (can only be done once) JWT_SECRET .set(trimmed.to_string()) .map_err(|_| "JWT_SECRET already initialized".to_string())?; @@ -67,6 +133,13 @@ pub fn init_jwt_secret() -> Result<(), String> { Ok(()) } +/// Retrieves the JWT secret from global state. +/// +/// # Panics +/// Panics if init_jwt_secret() has not been called yet. +/// +/// # Returns +/// A reference to the JWT secret string. fn get_jwt_secret() -> &'static str { JWT_SECRET .get() @@ -74,20 +147,41 @@ fn get_jwt_secret() -> &'static str { .as_str() } +/// JWT claims structure containing user identity and authorization information. +/// +/// These claims are encoded into the JWT token and can be extracted when +/// validating authenticated requests. +/// +/// # Fields +/// - `sub`: Subject (username) - identifies the user +/// - `role`: User role (e.g., "admin", "user") - for authorization +/// - `exp`: Expiration timestamp (Unix epoch) - prevents token reuse #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { - + /// Subject: the username of the authenticated user pub sub: String, + /// User role for authorization (e.g., "admin") pub role: String, + /// Expiration time as Unix timestamp (seconds since epoch) pub exp: usize, } impl Claims { - + /// Creates new JWT claims with a 24-hour expiration. + /// + /// # Arguments + /// * `username` - The username to include in the token + /// * `role` - The user's role for authorization + /// + /// # Returns + /// A new Claims instance with expiration set to 24 hours from now + /// + /// # Panics + /// Panics if the system time is severely misconfigured pub fn new(username: String, role: String) -> Self { - + // Calculate expiration time (24 hours from now) let expiration = Utc::now() .checked_add_signed(Duration::hours(24)) .and_then(|dt| usize::try_from(dt.timestamp()).ok()) @@ -103,12 +197,37 @@ impl Claims { } } +/// Creates a signed JWT token for a user. +/// +/// This generates a new JWT token with the user's identity and role, +/// signed with the application's secret key. +/// +/// # Arguments +/// * `username` - The username to encode in the token +/// * `role` - The user's role for authorization +/// +/// # Returns +/// - `Ok(String)` - The encoded JWT token +/// - `Err(jsonwebtoken::errors::Error)` - If token generation fails +/// +/// # Security +/// The token is signed using HS256 with the JWT secret, ensuring it +/// cannot be forged without knowledge of the secret key. +/// +/// # Example +/// ```rust,no_run +/// use linux_tutorial_cms::auth; +/// let token = auth::create_jwt("admin".to_string(), "admin".to_string())?; +/// # Ok::<(), jsonwebtoken::errors::Error>(()) +/// ``` pub fn create_jwt(username: String, role: String) -> Result { - + // Create claims with 24-hour expiration let claims = Claims::new(username, role); + // Get the initialized JWT secret let secret = get_jwt_secret(); + // Encode and sign the token encode( &Header::default(), &claims, @@ -116,14 +235,38 @@ pub fn create_jwt(username: String, role: String) -> Result Result { - + // Get the initialized JWT secret let secret = get_jwt_secret(); + // Configure validation rules let mut validation = Validation::default(); - validation.leeway = 60; - validation.validate_exp = true; + validation.leeway = 60; // Allow 60 seconds of clock skew + validation.validate_exp = true; // Ensure token hasn't expired + // Decode and validate the token let token_data = decode::( token, &DecodingKey::from_secret(secret.as_bytes()), @@ -133,14 +276,32 @@ pub fn verify_jwt(token: &str) -> Result { Ok(token_data.claims) } +/// Builds a secure authentication cookie containing the JWT token. +/// +/// Creates an HttpOnly cookie with appropriate security flags for +/// storing the JWT token in the client's browser. +/// +/// # Arguments +/// * `token` - The JWT token to store in the cookie +/// +/// # Returns +/// A Cookie configured for secure authentication token storage +/// +/// # Security Features +/// - HttpOnly: Prevents JavaScript access (XSS protection) +/// - SameSite=Lax: CSRF protection while allowing navigation +/// - Secure flag: HTTPS-only (when AUTH_COOKIE_SECURE is not false) +/// - 24-hour expiration: Matches JWT expiration +/// - Path=/: Available to all routes pub fn build_auth_cookie(token: &str) -> Cookie<'static> { - + // Build cookie with security flags let mut builder = Cookie::build((AUTH_COOKIE_NAME, token.to_owned())) .path("/") .http_only(true) .same_site(SameSite::Lax) .max_age(TimeDuration::seconds(AUTH_COOKIE_TTL_SECONDS)); + // Add Secure flag in production (HTTPS only) if cookies_should_be_secure() { builder = builder.secure(true); } @@ -148,8 +309,21 @@ pub fn build_auth_cookie(token: &str) -> Cookie<'static> { builder.build() } +/// Builds a cookie that removes the authentication cookie. +/// +/// Creates a cookie with expired timestamp to instruct the browser +/// to delete the authentication cookie (used for logout). +/// +/// # Returns +/// A Cookie configured to remove the authentication cookie +/// +/// # Mechanism +/// - Empty value +/// - Expiration set to Unix epoch (Jan 1, 1970) +/// - Max-age of 0 +/// - Same path and security flags as the auth cookie pub fn build_cookie_removal() -> Cookie<'static> { - + // Build cookie with expiration in the past to trigger removal let mut builder = Cookie::build((AUTH_COOKIE_NAME, "")) .path("/") .http_only(true) @@ -157,6 +331,7 @@ pub fn build_cookie_removal() -> Cookie<'static> { .expires(OffsetDateTime::UNIX_EPOCH) .max_age(TimeDuration::seconds(0)); + // Match security settings of auth cookie if cookies_should_be_secure() { builder = builder.secure(true); } @@ -164,19 +339,33 @@ pub fn build_cookie_removal() -> Cookie<'static> { builder.build() } +/// AXUM extractor implementation for Claims. +/// +/// This allows Claims to be used as a function parameter in route handlers, +/// automatically extracting and validating the JWT token from the request. +/// +/// # Extraction order +/// 1. Check if claims already in request extensions (from middleware) +/// 2. Extract token from Authorization header or cookie +/// 3. Validate token and decode claims +/// +/// # Errors +/// Returns 401 Unauthorized if: +/// - No token found in headers or cookies +/// - Token is invalid or expired impl FromRequestParts for Claims where S: Send + Sync, { - type Rejection = (StatusCode, String); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - + // Check if claims already extracted by middleware if let Some(claims) = parts.extensions.get::() { return Ok(claims.clone()); } + // Extract token from Authorization header or cookie let token = extract_token(&parts.headers).ok_or_else(|| { ( StatusCode::UNAUTHORIZED, @@ -184,6 +373,7 @@ where ) })?; + // Verify and decode the token let claims = verify_jwt(&token) .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?; @@ -191,22 +381,54 @@ where } } +/// Appends an authentication cookie to the response headers. +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// * `cookie` - The cookie to append +/// +/// # Error Handling +/// Logs an error if the cookie cannot be serialized (should never happen) pub fn append_auth_cookie(headers: &mut HeaderMap, cookie: Cookie<'static>) { - + // Convert cookie to header value if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { headers.append(SET_COOKIE, value); } else { - + // This should never happen with valid cookie values tracing::error!("Failed to serialize auth cookie for Set-Cookie header"); } } +/// Validates that a secret has minimum entropy requirements. +/// +/// This function checks if a secret meets security requirements to prevent +/// use of weak or predictable secrets. +/// +/// # Arguments +/// * `secret` - The secret string to validate +/// +/// # Returns +/// - `true` if the secret meets all entropy requirements +/// - `false` if the secret is too weak +/// +/// # Requirements +/// - At least 43 characters long (~256 bits base64-encoded) +/// - At least 3 character classes (lowercase, uppercase, digits, symbols) +/// - At least 10 unique characters +/// +/// # Purpose +/// Prevents use of weak secrets like: +/// - Short secrets +/// - Repeated characters +/// - Dictionary words +/// - Sequential patterns fn secret_has_min_entropy(secret: &str) -> bool { - + // Check minimum length if secret.len() < MIN_SECRET_LENGTH { return false; } + // Count character classes present let mut classes = 0; if secret.chars().any(|c| c.is_ascii_lowercase()) { classes += 1; @@ -221,30 +443,59 @@ fn secret_has_min_entropy(secret: &str) -> bool { classes += 1; } + // Require at least 3 different character classes if classes < MIN_CHAR_CLASSES { return false; } + // Check for sufficient character diversity let unique_chars: HashSet = secret.chars().collect(); unique_chars.len() >= MIN_UNIQUE_CHARS } +/// Determines whether authentication cookies should use the Secure flag. +/// +/// # Returns +/// - `true` by default (cookies only sent over HTTPS) +/// - `false` if AUTH_COOKIE_SECURE is explicitly set to "false" +/// +/// # Security Warning +/// Setting this to false allows cookies over HTTP, which exposes tokens +/// to network sniffing. Only use this in trusted development environments. +/// +/// # Environment Variable +/// Set AUTH_COOKIE_SECURE=false to disable (logs a warning) pub fn cookies_should_be_secure() -> bool { match env::var("AUTH_COOKIE_SECURE") { - + // Only disable if explicitly set to false Ok(value) if value.trim().eq_ignore_ascii_case("false") => { tracing::warn!( "AUTH_COOKIE_SECURE explicitly set to false. Cookies will be sent over HTTP; only use this in trusted development environments." ); false } - + // Default to secure cookies _ => true, } } +/// Extracts the JWT token from request headers. +/// +/// Supports two authentication methods: +/// 1. Authorization header with Bearer scheme +/// 2. Session cookie +/// +/// # Arguments +/// * `headers` - The request headers to search +/// +/// # Returns +/// - `Some(String)` if a token was found +/// - `None` if no token was found in either location +/// +/// # Priority +/// Authorization header is checked first, falling back to cookies fn extract_token(headers: &HeaderMap) -> Option { - + // First check Authorization header if let Some(header_value) = headers.get(AUTHORIZATION) { if let Ok(value_str) = header_value.to_str() { if let Some(token) = parse_bearer_token(value_str) { @@ -253,28 +504,70 @@ fn extract_token(headers: &HeaderMap) -> Option { } } + // Fall back to cookie let jar = CookieJar::from_headers(headers); jar.get(AUTH_COOKIE_NAME) .map(|cookie| cookie.value().to_string()) } +/// Parses a Bearer token from an Authorization header value. +/// +/// # Arguments +/// * `value` - The Authorization header value (e.g., "Bearer eyJhbGci...") +/// +/// # Returns +/// - `Some(String)` if the value is a valid Bearer token +/// - `None` if the format is invalid or not a Bearer token +/// +/// # Format +/// Expected format: "Bearer " +/// - Scheme must be "Bearer" (case-insensitive) +/// - Token must not be empty after trimming fn parse_bearer_token(value: &str) -> Option { - + // Split into scheme and token let trimmed = value.trim(); let (scheme, token) = trimmed.split_once(' ')?; - + // Verify Bearer scheme and non-empty token if scheme.eq_ignore_ascii_case("Bearer") && !token.trim().is_empty() { return Some(token.trim().to_string()); } None } +/// AXUM middleware for protecting routes with authentication. +/// +/// This middleware validates the JWT token and adds the claims to the +/// request extensions, making them available to downstream handlers. +/// +/// # Usage +/// ```rust,no_run +/// use axum::{Router, routing::get, middleware}; +/// use linux_tutorial_cms::auth; +/// +/// let app = Router::new() +/// .route("/protected", get(handler)) +/// .route_layer(middleware::from_fn(auth::auth_middleware)); +/// ``` +/// +/// # Authentication +/// Accepts tokens from: +/// - Authorization: Bearer header +/// - ltcms_session cookie +/// +/// # Errors +/// Returns 401 Unauthorized if: +/// - No token provided +/// - Token is invalid or expired +/// +/// # Request Extensions +/// On success, inserts Claims into request extensions for easy access +/// by downstream handlers. pub async fn auth_middleware( mut request: axum::extract::Request, next: axum::middleware::Next, ) -> Result { - + // Extract token from request let token = extract_token(request.headers()).ok_or_else(|| { ( StatusCode::UNAUTHORIZED, @@ -282,9 +575,11 @@ pub async fn auth_middleware( ) })?; + // Verify token and extract claims let claims = verify_jwt(&token) .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?; + // Add claims to request extensions for downstream handlers request.extensions_mut().insert(claims); Ok(next.run(request).await) diff --git a/backend/src/csrf.rs b/backend/src/csrf.rs index 11938ed8..4d71c81a 100644 --- a/backend/src/csrf.rs +++ b/backend/src/csrf.rs @@ -1,4 +1,39 @@ - +//! Cross-Site Request Forgery (CSRF) Protection Module +//! +//! This module provides CSRF protection for state-changing HTTP operations. +//! It implements a double-submit cookie pattern with additional security features. +//! +//! # Security Features +//! - HMAC-SHA256 signed tokens (prevents forgery) +//! - Per-user token binding (prevents token theft across accounts) +//! - Time-based expiration (6-hour TTL) +//! - Random nonce for uniqueness +//! - Version support for token format evolution +//! - Constant-time signature comparison (prevents timing attacks) +//! - Double-submit cookie pattern (cookie + header validation) +//! +//! # Token Format +//! `v1|base64url(username)|expiry|nonce|base64url(signature)` +//! +//! # Usage +//! Tokens are automatically validated by the CsrfGuard extractor for +//! state-changing HTTP methods (POST, PUT, DELETE, PATCH). +//! +//! ## Initialization +//! ```rust,no_run +//! use linux_tutorial_cms::csrf; +//! csrf::init_csrf_secret().expect("Failed to initialize CSRF secret"); +//! ``` +//! +//! ## Protection +//! ```rust,no_run +//! use axum::{Router, routing::post, middleware}; +//! use linux_tutorial_cms::csrf::CsrfGuard; +//! +//! let app = Router::new() +//! .route("/api/resource", post(handler)) +//! .route_layer(middleware::from_extractor::()); +//! ``` use axum::{ extract::FromRequestParts, @@ -20,22 +55,57 @@ use uuid::Uuid; use crate::{auth, models::ErrorResponse}; +/// HMAC-SHA256 type alias for token signing type HmacSha256 = Hmac; +/// Environment variable name for the CSRF secret const CSRF_SECRET_ENV: &str = "CSRF_SECRET"; +/// Name of the CSRF cookie const CSRF_COOKIE_NAME: &str = "ltcms_csrf"; +/// Name of the CSRF HTTP header const CSRF_HEADER_NAME: &str = "x-csrf-token"; +/// CSRF token time-to-live in seconds (6 hours) const CSRF_TOKEN_TTL_SECONDS: i64 = 6 * 60 * 60; +/// Minimum length for CSRF secret (256 bits recommended) const CSRF_MIN_SECRET_LENGTH: usize = 32; +/// Current CSRF token format version const CSRF_VERSION: &str = "v1"; +/// Global storage for the CSRF secret key static CSRF_SECRET: OnceLock> = OnceLock::new(); +/// Initializes the CSRF secret from the environment variable. +/// +/// This function must be called once at application startup before any +/// CSRF operations. It validates the secret for security and stores it +/// in global state. +/// +/// # Security Validation +/// The secret is checked for: +/// - Presence (not missing) +/// - Minimum length (32 bytes for adequate entropy) +/// - Character diversity (at least 10 unique characters) +/// +/// # Returns +/// - `Ok(())` if the secret was successfully initialized +/// - `Err(String)` with a descriptive error message if validation fails +/// +/// # Errors +/// - CSRF_SECRET environment variable not set +/// - Secret is too short (< 32 characters) +/// - Secret has insufficient entropy (< 10 unique characters) +/// - Secret was already initialized (can only be called once) +/// +/// # Example +/// ```rust,no_run +/// use linux_tutorial_cms::csrf; +/// csrf::init_csrf_secret().expect("Failed to initialize CSRF secret"); +/// ``` pub fn init_csrf_secret() -> Result<(), String> { // Load secret from environment variable let secret = env::var(CSRF_SECRET_ENV) @@ -65,6 +135,13 @@ pub fn init_csrf_secret() -> Result<(), String> { Ok(()) } +/// Retrieves the CSRF secret from global state. +/// +/// # Panics +/// Panics if init_csrf_secret() has not been called yet. +/// +/// # Returns +/// A reference to the CSRF secret bytes. fn get_secret() -> &'static [u8] { CSRF_SECRET .get() @@ -72,6 +149,38 @@ fn get_secret() -> &'static [u8] { .as_slice() } +/// Issues a new CSRF token for a user. +/// +/// Creates a cryptographically signed token bound to the user's identity. +/// The token is valid for 6 hours and includes a random nonce for uniqueness. +/// +/// # Arguments +/// * `username` - The username to bind the token to +/// +/// # Returns +/// - `Ok(String)` - The complete CSRF token (v1 format) +/// - `Err(String)` - If token generation fails +/// +/// # Token Structure +/// The token consists of: +/// 1. Version identifier ("v1") +/// 2. Base64URL-encoded username +/// 3. Unix timestamp expiration +/// 4. Random UUID nonce +/// 5. Base64URL-encoded HMAC-SHA256 signature +/// +/// All components are pipe-separated. +/// +/// # Security +/// - HMAC signature prevents token forgery +/// - Username binding prevents token theft across accounts +/// - Nonce prevents token reuse +/// - Expiration limits token lifetime +/// +/// # Errors +/// - Username is empty +/// - Failed to compute expiration timestamp +/// - HMAC initialization fails pub fn issue_csrf_token(username: &str) -> Result { // Validate input if username.is_empty() { @@ -104,6 +213,36 @@ pub fn issue_csrf_token(username: &str) -> Result { Ok(format!("{versioned_payload}|{signature}")) } +/// Validates a CSRF token against an expected username. +/// +/// This performs comprehensive validation including: +/// - Token format and structure +/// - Version compatibility +/// - Username binding +/// - Expiration check +/// - Signature verification (constant-time) +/// +/// # Arguments +/// * `token` - The CSRF token to validate +/// * `expected_username` - The username the token should be bound to +/// +/// # Returns +/// - `Ok(())` if the token is valid for the user +/// - `Err(String)` with a descriptive error message if validation fails +/// +/// # Security +/// - Constant-time signature comparison (prevents timing attacks) +/// - Strict format validation (prevents malformed tokens) +/// - Username binding check (prevents cross-account token use) +/// - Expiration enforcement (limits token lifetime) +/// +/// # Errors +/// - Malformed token structure +/// - Unsupported version +/// - Username mismatch +/// - Token expired +/// - Invalid signature +/// - Nonce too short fn validate_csrf_token(token: &str, expected_username: &str) -> Result<(), String> { // Parse token into components let mut parts = token.split('|'); @@ -180,16 +319,39 @@ fn validate_csrf_token(token: &str, expected_username: &str) -> Result<(), Strin Ok(()) } +/// Performs constant-time equality comparison on byte slices. +/// +/// This prevents timing side-channel attacks by ensuring the comparison +/// takes the same time regardless of where differences occur. +/// +/// # Arguments +/// * `a` - First byte slice +/// * `b` - Second byte slice +/// +/// # Returns +/// `true` if the slices are equal, `false` otherwise +/// +/// # Security +/// Uses the `subtle` crate for constant-time comparison, preventing +/// attackers from learning about signature bytes through timing analysis. fn subtle_equals(a: &[u8], b: &[u8]) -> bool { use subtle::ConstantTimeEq; a.ct_eq(b).into() } +/// Appends a CSRF token cookie to the response headers. +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// * `token` - The CSRF token to include in the cookie +/// +/// # Error Handling +/// Logs an error if cookie serialization fails (should never happen) pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) { - + // Build cookie with security flags let cookie = build_csrf_cookie(token); - + // Append to Set-Cookie header if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { headers.append(SET_COOKIE, value); } else { @@ -197,11 +359,18 @@ pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) { } } +/// Appends a cookie that removes the CSRF cookie (for logout). +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// +/// # Error Handling +/// Logs an error if cookie serialization fails (should never happen) pub fn append_csrf_removal(headers: &mut HeaderMap) { - + // Build removal cookie (expired) let cookie = build_csrf_removal(); - + // Append to Set-Cookie header if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { headers.append(SET_COOKIE, value); } else { @@ -209,14 +378,29 @@ pub fn append_csrf_removal(headers: &mut HeaderMap) { } } +/// Builds a CSRF cookie with appropriate security flags. +/// +/// # Arguments +/// * `token` - The CSRF token to store in the cookie +/// +/// # Returns +/// A Cookie configured for CSRF protection +/// +/// # Security Flags +/// - SameSite=Strict: Prevents cross-site cookie sending (strict CSRF protection) +/// - HttpOnly=false: Allows JavaScript read access (needed for header submission) +/// - Secure: HTTPS-only (when AUTH_COOKIE_SECURE is not false) +/// - Path=/: Available to all routes +/// - Max-Age: 6 hours (matches token expiration) fn build_csrf_cookie(token: &str) -> Cookie<'static> { - + // Build cookie with security settings let mut builder = Cookie::build((CSRF_COOKIE_NAME, token.to_owned())) .path("/") .same_site(SameSite::Strict) .max_age(TimeDuration::seconds(CSRF_TOKEN_TTL_SECONDS)) - .http_only(false); + .http_only(false); // Must be false for JavaScript to read and submit in header + // Add Secure flag in production (HTTPS only) if auth::cookies_should_be_secure() { builder = builder.secure(true); } @@ -224,8 +408,18 @@ fn build_csrf_cookie(token: &str) -> Cookie<'static> { builder.build() } +/// Builds a cookie that removes the CSRF cookie. +/// +/// # Returns +/// A Cookie configured to remove the CSRF cookie +/// +/// # Mechanism +/// - Empty value +/// - Expiration set to Unix epoch (Jan 1, 1970) +/// - Max-age of 0 +/// - Same path and security flags as the CSRF cookie fn build_csrf_removal() -> Cookie<'static> { - + // Build cookie with expiration in the past to trigger removal let mut builder = Cookie::build((CSRF_COOKIE_NAME, "")) .path("/") .same_site(SameSite::Strict) @@ -233,6 +427,7 @@ fn build_csrf_removal() -> Cookie<'static> { .max_age(TimeDuration::seconds(0)) .http_only(false); + // Match security settings of CSRF cookie if auth::cookies_should_be_secure() { builder = builder.secure(true); } @@ -240,6 +435,41 @@ fn build_csrf_removal() -> Cookie<'static> { builder.build() } +/// AXUM extractor for CSRF protection. +/// +/// This extractor validates CSRF tokens for state-changing HTTP methods. +/// Safe methods (GET, HEAD, OPTIONS, TRACE) are automatically allowed. +/// +/// # Validation Process +/// 1. Skip validation for safe HTTP methods +/// 2. Ensure user is authenticated (extract Claims) +/// 3. Extract token from x-csrf-token header +/// 4. Extract token from cookie +/// 5. Verify header and cookie tokens match (double-submit pattern) +/// 6. Validate token signature and binding to user +/// +/// # Usage +/// ```rust,no_run +/// use axum::{Router, routing::post, middleware}; +/// use linux_tutorial_cms::csrf::CsrfGuard; +/// +/// let app = Router::new() +/// .route("/api/resource", post(handler)) +/// .route_layer(middleware::from_extractor::()); +/// ``` +/// +/// # Security +/// - Double-submit cookie pattern (cookie + header) +/// - Per-user token binding +/// - HMAC signature verification +/// - Expiration enforcement +/// +/// # Errors +/// Returns 403 Forbidden if: +/// - CSRF token header is missing +/// - CSRF cookie is missing +/// - Header and cookie tokens don't match +/// - Token validation fails (expired, wrong user, invalid signature) pub struct CsrfGuard; impl FromRequestParts for CsrfGuard @@ -318,10 +548,18 @@ where } } +/// Returns the name of the CSRF cookie. +/// +/// # Returns +/// The constant CSRF cookie name: "ltcms_csrf" pub fn csrf_cookie_name() -> &'static str { CSRF_COOKIE_NAME } +/// Returns the name of the CSRF HTTP header. +/// +/// # Returns +/// The constant CSRF header name: "x-csrf-token" pub fn csrf_header_name() -> &'static str { CSRF_HEADER_NAME } diff --git a/backend/src/db.rs b/backend/src/db.rs index 9024c141..8a58302a 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,4 +1,45 @@ - +//! Database Module +//! +//! This module handles all database operations for the Linux Tutorial CMS. +//! It provides a SQLite-based persistence layer with connection pooling, +//! migrations, and full-text search capabilities. +//! +//! # Architecture +//! - SQLite database with WAL mode for better concurrent access +//! - Connection pooling (1-5 connections) for efficient resource usage +//! - Automatic schema migrations on startup +//! - FTS5 virtual table for fast full-text search +//! - Foreign key enforcement and cascading deletes +//! +//! # Database Schema +//! ## Core Tables +//! - `users`: User accounts with bcrypt-hashed passwords +//! - `login_attempts`: Rate limiting for failed login attempts +//! - `tutorials`: Tutorial content with versioning +//! - `tutorial_topics`: Many-to-many relationship for tutorial topics +//! - `comments`: User comments on tutorials +//! - `app_metadata`: Application-level key-value store +//! +//! ## Site Content Tables +//! - `site_pages`: Custom pages with flexible JSON layouts +//! - `site_posts`: Blog posts associated with pages +//! - `site_content`: Configurable site content sections +//! +//! ## Search Infrastructure +//! - `tutorials_fts`: FTS5 virtual table for full-text search +//! - Automatic triggers to keep FTS5 index in sync +//! +//! # Security Features +//! - Foreign key constraints prevent orphaned records +//! - Transaction-based operations for data integrity +//! - Slug validation to prevent injection attacks +//! - bcrypt password hashing for admin accounts +//! +//! # Performance Features +//! - WAL mode enables concurrent reads during writes +//! - Indexes on frequently queried columns +//! - Connection pooling reduces connection overhead +//! - FTS5 index for sub-second search performance use regex::Regex; use serde_json::{json, Value}; @@ -10,17 +51,56 @@ use std::env; use std::path::{Path, PathBuf}; use std::str::FromStr; +/// Type alias for the SQLite connection pool. +/// Used throughout the application for database access. pub type DbPool = SqlitePool; +/// Creates and initializes the database connection pool. +/// +/// This is the main entry point for database initialization. It: +/// 1. Loads database URL from environment (defaults to ./database.db) +/// 2. Ensures the database directory exists +/// 3. Configures SQLite connection options +/// 4. Creates connection pool (1-5 connections) +/// 5. Runs all migrations +/// +/// # Database Configuration +/// - **WAL Mode**: Write-Ahead Logging for better concurrency +/// - **Foreign Keys**: Enabled for referential integrity +/// - **Synchronous**: Normal mode (balanced safety/performance) +/// - **Busy Timeout**: 60 seconds to handle lock contention +/// - **Auto-create**: Database file created if missing +/// +/// # Connection Pool +/// - Min connections: 1 (always ready) +/// - Max connections: 5 (prevents resource exhaustion) +/// - Acquire timeout: 30 seconds +/// - No idle timeout (connections persist) +/// - No max lifetime (connections don't expire) +/// +/// # Returns +/// - `Ok(DbPool)` on success +/// - `Err(sqlx::Error)` if initialization fails +/// +/// # Errors +/// - Invalid DATABASE_URL format +/// - Database directory creation failure +/// - Connection establishment failure +/// - Migration failure +/// +/// # Environment Variables +/// - `DATABASE_URL`: SQLite database path (default: "sqlite:./database.db") pub async fn create_pool() -> Result { - + // Load database URL from environment or use default let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { tracing::warn!("DATABASE_URL not set, defaulting to sqlite:./database.db"); "sqlite:./database.db".to_string() }); + // Ensure parent directory exists ensure_sqlite_directory(&database_url)?; + // Configure SQLite connection options let connect_options = SqliteConnectOptions::from_str(&database_url)? .create_if_missing(true) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) @@ -28,6 +108,7 @@ pub async fn create_pool() -> Result { .foreign_keys(true) .busy_timeout(std::time::Duration::from_secs(60)); + // Create connection pool let pool = SqlitePoolOptions::new() .max_connections(5) .min_connections(1) @@ -37,12 +118,24 @@ pub async fn create_pool() -> Result { .connect_with(connect_options) .await?; + // Run all database migrations run_migrations(&pool).await?; tracing::info!("Database pool created successfully"); Ok(pool) } +/// Returns the compiled slug validation regex pattern. +/// +/// The regex enforces URL-safe slug format: +/// - Lowercase letters (a-z) +/// - Numbers (0-9) +/// - Hyphens (-) as separators (not leading/trailing/consecutive) +/// +/// Pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*$` +/// +/// # Returns +/// A static reference to the compiled Regex fn slug_regex() -> &'static Regex { use std::sync::OnceLock; @@ -50,6 +143,40 @@ fn slug_regex() -> &'static Regex { SLUG_RE.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").expect("valid slug regex")) } +/// Validates a slug for use in URLs. +/// +/// Ensures slugs are safe, readable, and SEO-friendly. +/// +/// # Validation Rules +/// - Length ≤ 100 characters +/// - Only lowercase letters (a-z) +/// - Only numbers (0-9) +/// - Hyphens (-) as word separators +/// - No leading, trailing, or consecutive hyphens +/// +/// # Valid Examples +/// - "hello-world" +/// - "tutorial-1" +/// - "linux-basics-2024" +/// +/// # Invalid Examples +/// - "Hello-World" (uppercase) +/// - "-hello" (leading hyphen) +/// - "hello--world" (consecutive hyphens) +/// - "hello_world" (underscore) +/// +/// # Arguments +/// * `slug` - The slug string to validate +/// +/// # Returns +/// - `Ok(())` if slug is valid +/// - `Err(sqlx::Error)` with descriptive message if invalid +/// +/// # Security +/// Slug validation prevents: +/// - Path traversal attacks (no slashes or dots) +/// - Special character injection +/// - Unicode confusion attacks pub fn validate_slug(slug: &str) -> Result<(), sqlx::Error> { const MAX_SLUG_LENGTH: usize = 100; @@ -949,9 +1076,53 @@ fn sqlite_file_path(database_url: &str) -> Option { Some(PathBuf::from(normalized)) } +/// Runs all database migrations and initial data seeding. +/// +/// This function is automatically called during database pool creation. +/// It ensures the database schema is up-to-date and populates initial data. +/// +/// # Migration Steps +/// 1. **Core Schema**: Create core tables (users, tutorials, comments, login_attempts) +/// 2. **Site Schema**: Create site-related tables (pages, posts, content) +/// 3. **FTS Index**: Create and populate full-text search index +/// 4. **Default Content**: Seed default site content (hero, footer, etc.) +/// 5. **Admin User**: Create admin account from environment variables +/// 6. **Default Tutorials**: Optionally seed sample tutorials +/// +/// # Admin User Creation +/// If `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set: +/// - Password must be ≥ 12 characters (NIST recommendation) +/// - User created with role "admin" +/// - Existing users are not overwritten (preserves runtime changes) +/// - Password hash created with bcrypt +/// +/// # Default Tutorials +/// If `ENABLE_DEFAULT_TUTORIALS` is not "false": +/// - Inserts 8 sample tutorials on first run +/// - Skipped if tutorials already exist +/// - Marked as seeded in app_metadata +/// +/// # Arguments +/// * `pool` - The database connection pool +/// +/// # Returns +/// - `Ok(())` if all migrations succeed +/// - `Err(sqlx::Error)` if any migration fails +/// +/// # Errors +/// - Schema creation failure +/// - Admin password too weak (< 12 characters) +/// - bcrypt hashing failure +/// - Transaction rollback on any error +/// +/// # Environment Variables +/// - `ADMIN_USERNAME`: Admin account username (optional) +/// - `ADMIN_PASSWORD`: Admin account password (optional, min 12 chars) +/// - `ENABLE_DEFAULT_TUTORIALS`: "false" to disable tutorial seeding (default: true) pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { let mut tx = pool.begin().await?; + // Apply core schema migrations (users, tutorials, comments, etc.) if let Err(err) = apply_core_migrations(&mut tx).await { tx.rollback().await?; return Err(err); @@ -959,14 +1130,17 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { tx.commit().await?; + // Create site-related schema (pages, posts, content) ensure_site_page_schema(pool).await?; + // Seed default site content (hero, footer, etc.) { let mut tx = pool.begin().await?; seed_site_content_tx(&mut tx).await?; tx.commit().await?; } + // Create admin user from environment variables let admin_username = env::var("ADMIN_USERNAME").ok(); let admin_password = env::var("ADMIN_PASSWORD").ok(); diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index e5cec2c9..1cc69fc7 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -1,4 +1,26 @@ - +//! Authentication HTTP Handlers +//! +//! This module contains HTTP handlers for authentication-related endpoints. +//! It implements secure login, logout, and user identity verification. +//! +//! # Security Features +//! - Login rate limiting with progressive lockout +//! - Timing-attack resistant password verification +//! - Salted hash-based login attempt tracking +//! - Constant-time dummy hash verification +//! - Input validation +//! - CSRF token issuance on login +//! - Secure cookie management +//! +//! # Endpoints +//! - POST /api/auth/login: Authenticate user and issue tokens +//! - GET /api/auth/me: Get current user information +//! - POST /api/auth/logout: Invalidate session +//! +//! # Rate Limiting +//! Failed login attempts trigger progressive lockout: +//! - 3 failures: 10-second lockout +//! - 5+ failures: 60-second lockout use crate::{auth, csrf, db::DbPool, models::*}; use axum::{ @@ -11,15 +33,36 @@ use sha2::{Digest, Sha256}; use sqlx::{self, FromRow}; use std::{env, sync::OnceLock, time::Duration}; +/// Database record for tracking login attempts. +/// +/// Stores per-user failure count and lockout expiration. #[derive(Debug, FromRow, Clone)] struct LoginAttempt { + /// Number of consecutive failed login attempts fail_count: i64, + /// RFC3339 timestamp when the lockout expires (NULL if not blocked) blocked_until: Option, } +/// Global salt for hashing login attempt identifiers. +/// Initialized once at startup via init_login_attempt_salt(). static LOGIN_ATTEMPT_SALT: OnceLock = OnceLock::new(); +/// Initializes the login attempt salt from environment. +/// +/// This salt is used to hash usernames before storing them in the +/// login_attempts table, preventing username enumeration attacks. +/// +/// # Returns +/// - `Ok(())` if initialization succeeds +/// - `Err(String)` with error message if validation fails +/// +/// # Errors +/// - LOGIN_ATTEMPT_SALT environment variable not set +/// - Salt is too short (< 32 characters) +/// - Salt has insufficient entropy (< 10 unique characters) +/// - Salt was already initialized pub fn init_login_attempt_salt() -> Result<(), String> { let raw = env::var("LOGIN_ATTEMPT_SALT") .map_err(|_| "LOGIN_ATTEMPT_SALT environment variable not set".to_string())?; @@ -41,6 +84,10 @@ pub fn init_login_attempt_salt() -> Result<(), String> { Ok(()) } +/// Retrieves the initialized login attempt salt. +/// +/// # Panics +/// Panics if init_login_attempt_salt() has not been called yet. fn login_attempt_salt() -> &'static str { LOGIN_ATTEMPT_SALT .get() @@ -48,6 +95,21 @@ fn login_attempt_salt() -> &'static str { .as_str() } +/// Hashes a username for login attempt tracking. +/// +/// Creates a salted SHA-256 hash of the normalized username. +/// This prevents username enumeration by obscuring which accounts exist. +/// +/// # Arguments +/// * `username` - The username to hash +/// +/// # Returns +/// Hex-encoded SHA-256 hash +/// +/// # Security +/// - Username is trimmed and lowercased for normalization +/// - Salt prevents rainbow table attacks +/// - Hash prevents direct username storage fn hash_login_identifier(username: &str) -> String { let mut hasher = Sha256::new(); hasher.update(login_attempt_salt().as_bytes()); @@ -55,6 +117,14 @@ fn hash_login_identifier(username: &str) -> String { format!("{:x}", hasher.finalize()) } +/// Parses an optional RFC3339 timestamp string into a UTC DateTime. +/// +/// # Arguments +/// * `value` - Optional RFC3339 timestamp string +/// +/// # Returns +/// - `Some(DateTime)` if parsing succeeds +/// - `None` if value is None or parsing fails fn parse_rfc3339_opt(value: &Option) -> Option> { value .as_ref() @@ -62,6 +132,17 @@ fn parse_rfc3339_opt(value: &Option) -> Option> { .map(|dt| dt.with_timezone(&Utc)) } +/// Returns a precomputed dummy bcrypt hash for timing-attack resistance. +/// +/// This hash is used during failed login attempts to ensure password +/// verification takes constant time regardless of whether the user exists. +/// +/// # Returns +/// A static bcrypt hash string +/// +/// # Security +/// Using a dummy hash when the user doesn't exist prevents timing attacks +/// that could enumerate valid usernames by measuring response times. fn dummy_bcrypt_hash() -> &'static str { static DUMMY_HASH: OnceLock = OnceLock::new(); @@ -74,6 +155,19 @@ fn dummy_bcrypt_hash() -> &'static str { }) } +/// Validates a username meets security and format requirements. +/// +/// # Arguments +/// * `username` - The username to validate +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(String)` with error message if invalid +/// +/// # Validation Rules +/// - Not empty +/// - Length ≤ 50 characters +/// - Only alphanumeric, underscore, hyphen, and period allowed fn validate_username(username: &str) -> Result<(), String> { if username.is_empty() { return Err("Username cannot be empty".to_string()); @@ -91,6 +185,18 @@ fn validate_username(username: &str) -> Result<(), String> { Ok(()) } +/// Validates a password meets security and format requirements. +/// +/// # Arguments +/// * `password` - The password to validate +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(String)` with error message if invalid +/// +/// # Validation Rules +/// - Not empty +/// - Length ≤ 128 characters (prevents DoS via bcrypt) fn validate_password(password: &str) -> Result<(), String> { if password.is_empty() { return Err("Password cannot be empty".to_string()); @@ -101,6 +207,48 @@ fn validate_password(password: &str) -> Result<(), String> { Ok(()) } +/// HTTP handler for user login. +/// +/// Authenticates a user and issues JWT and CSRF tokens. +/// Implements progressive rate limiting and timing-attack resistance. +/// +/// # Endpoint +/// POST /api/auth/login +/// +/// # Request +/// JSON body with LoginRequest: +/// ```json +/// { +/// "username": "admin", +/// "password": "secret" +/// } +/// ``` +/// +/// # Response +/// On success (200 OK): +/// - Sets auth cookie (ltcms_session) +/// - Sets CSRF cookie (ltcms_csrf) +/// - Returns LoginResponse with JWT token and user info +/// +/// # Errors +/// - 400 Bad Request: Invalid username/password format +/// - 401 Unauthorized: Invalid credentials +/// - 429 Too Many Requests: Account temporarily locked +/// - 500 Internal Server Error: Database or token generation failure +/// +/// # Security Features +/// - Input validation (length, character set) +/// - Progressive lockout (3 failures → 10s, 5+ failures → 60s) +/// - Timing-attack resistance (constant-time verification) +/// - Random jitter (100-300ms) to prevent timing analysis +/// - Username enumeration protection (hashed login tracking) +/// - Automatic lockout reset on successful login +/// +/// # Rate Limiting +/// After failed attempts: +/// - 3 failures: 10-second lockout +/// - 5+ failures: 60-second lockout +/// - Lockout countdown shown to user pub async fn login( State(pool): State, Json(payload): Json, @@ -279,6 +427,34 @@ pub async fn login( )) } +/// HTTP handler for retrieving current user information. +/// +/// Returns the authenticated user's identity from their JWT token. +/// Requires a valid authentication token (cookie or Authorization header). +/// +/// # Endpoint +/// GET /api/auth/me +/// +/// # Authentication +/// Requires valid JWT token via: +/// - Cookie: ltcms_session +/// - Header: Authorization: Bearer +/// +/// # Response +/// On success (200 OK): +/// ```json +/// { +/// "username": "admin", +/// "role": "admin" +/// } +/// ``` +/// +/// # Errors +/// - 401 Unauthorized: Missing or invalid token +/// +/// # Security +/// User identity is extracted from the validated JWT token, +/// not from request parameters, preventing impersonation. pub async fn me( claims: auth::Claims, ) -> Result, (StatusCode, Json)> { @@ -288,6 +464,33 @@ pub async fn me( })) } +/// HTTP handler for user logout. +/// +/// Invalidates the user's session by removing auth and CSRF cookies. +/// Requires CSRF token validation to prevent logout CSRF attacks. +/// +/// # Endpoint +/// POST /api/auth/logout +/// +/// # Authentication +/// Requires: +/// - Valid JWT token (cookie or header) +/// - Valid CSRF token (header and cookie must match) +/// +/// # Response +/// On success (204 No Content): +/// - Sets auth cookie expiration to past (removes session) +/// - Sets CSRF cookie expiration to past (removes token) +/// - Empty response body +/// +/// # Errors +/// - 401 Unauthorized: Missing or invalid JWT token +/// - 403 Forbidden: Missing or invalid CSRF token +/// +/// # Security +/// - CSRF protection prevents attackers from forcing logout +/// - Logs logout event for audit trail +/// - Client must clear local storage/state separately pub async fn logout(_csrf: csrf::CsrfGuard, claims: auth::Claims) -> (StatusCode, HeaderMap) { let mut headers = HeaderMap::new(); auth::append_auth_cookie(&mut headers, auth::build_cookie_removal()); diff --git a/backend/src/handlers/comments.rs b/backend/src/handlers/comments.rs index 52c7c3af..ebb48adb 100644 --- a/backend/src/handlers/comments.rs +++ b/backend/src/handlers/comments.rs @@ -1,4 +1,24 @@ - +//! Comment Management HTTP Handlers +//! +//! This module handles comment operations on tutorials. +//! Comments allow users (when authenticated) to provide feedback and discussion. +//! +//! # Endpoints +//! - GET /api/tutorials/{id}/comments: List comments for a tutorial (public, paginated) +//! - POST /api/tutorials/{id}/comments: Create comment (admin only, CSRF protected) +//! - DELETE /api/comments/{id}: Delete comment (admin only, CSRF protected) +//! +//! # Features +//! - Pagination support (default 50 comments, configurable via query params) +//! - Author attribution from JWT claims +//! - Content length validation (1-2000 characters) +//! - Foreign key cascade deletion (comments deleted with tutorial) +//! +//! # Security +//! - Comments require authentication and CSRF protection +//! - Author name extracted from JWT token (prevents impersonation) +//! - Content length limits prevent abuse +//! - Tutorial ID validation prevents injection use crate::{auth, db::DbPool, handlers::tutorials::validate_tutorial_id, models::*}; use axum::{ diff --git a/backend/src/handlers/search.rs b/backend/src/handlers/search.rs index 32bc603d..e8de007e 100644 --- a/backend/src/handlers/search.rs +++ b/backend/src/handlers/search.rs @@ -1,4 +1,30 @@ - +//! Search HTTP Handlers +//! +//! This module provides full-text search capabilities for tutorials. +//! It uses SQLite's FTS5 (Full-Text Search 5) for fast and efficient searching. +//! +//! # Endpoints +//! - GET /api/search/tutorials: Search tutorials by keyword (public) +//! - GET /api/search/topics: Get all unique topics (public) +//! +//! # Search Features +//! - Full-text search across title, description, content, and topics +//! - Topic-based filtering (optional) +//! - Pagination support (default 20 results, configurable) +//! - Ranked results (FTS5 BM25 ranking algorithm) +//! - Query sanitization to prevent FTS5 syntax errors +//! +//! # Query Processing +//! - Splits query into tokens +//! - Removes FTS5 special characters (* " :) +//! - Validates minimum word length (3 characters) +//! - Limits maximum tokens (20) to prevent DoS +//! - Applies FTS5 prefix matching for better UX +//! +//! # Performance +//! - FTS5 index provides sub-second search on large datasets +//! - Automatic index updates via triggers on tutorial changes +//! - Result limit prevents excessive data transfer use crate::{db::DbPool, models::*}; use axum::{ diff --git a/backend/src/handlers/tutorials.rs b/backend/src/handlers/tutorials.rs index 53dcc6cb..55875267 100644 --- a/backend/src/handlers/tutorials.rs +++ b/backend/src/handlers/tutorials.rs @@ -1,4 +1,29 @@ - +//! Tutorial Management HTTP Handlers +//! +//! This module contains HTTP handlers for CRUD operations on tutorials. +//! Tutorials are the main content type of the application, representing +//! Linux learning modules with topics, icons, and markdown content. +//! +//! # Endpoints +//! - GET /api/tutorials: List all tutorials +//! - GET /api/tutorials/{id}: Get specific tutorial by ID +//! - POST /api/tutorials: Create new tutorial (admin only, CSRF protected) +//! - PUT /api/tutorials/{id}: Update tutorial (admin only, CSRF protected) +//! - DELETE /api/tutorials/{id}: Delete tutorial (admin only, CSRF protected) +//! +//! # Data Validation +//! - Tutorial IDs: Alphanumeric and hyphens only, max 100 characters +//! - Title: 1-200 characters +//! - Description: 1-1000 characters +//! - Content: Max 100,000 characters (markdown) +//! - Icons: Whitelist of allowed Lucide icon names +//! - Colors: Tailwind gradient classes only +//! +//! # Features +//! - Full-text search integration (automatic FTS5 indexing) +//! - Topic-based organization +//! - Version tracking for content updates +//! - Soft validation to preserve data integrity use crate::{auth, db::DbPool, models::*}; use axum::{ diff --git a/backend/src/main.rs b/backend/src/main.rs index 16d7f7db..49140692 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -41,12 +41,29 @@ const REFERRER_POLICY: HeaderName = HeaderName::from_static("referrer-policy"); const X_XSS_PROTECTION: HeaderName = HeaderName::from_static("x-xss-protection"); // Forwarded header constants for proxy handling +// Standard forwarded header used by RFC 7239 const FORWARDED_HEADER: HeaderName = HeaderName::from_static("forwarded"); +// Header containing the originating client IP address const X_FORWARDED_FOR_HEADER: HeaderName = HeaderName::from_static("x-forwarded-for"); +// Header indicating the protocol used by the client (http/https) const X_FORWARDED_PROTO_HEADER: HeaderName = HeaderName::from_static("x-forwarded-proto"); +// Header containing the original host requested by the client const X_FORWARDED_HOST_HEADER: HeaderName = HeaderName::from_static("x-forwarded-host"); +// Header containing the real IP address of the client const X_REAL_IP_HEADER: HeaderName = HeaderName::from_static("x-real-ip"); +/// Parses a boolean value from an environment variable with support for various formats. +/// +/// # Arguments +/// * `key` - The environment variable name to parse +/// * `default` - The default value to return if the variable is not set or invalid +/// +/// # Returns +/// The parsed boolean value, or the default if parsing fails +/// +/// # Supported formats +/// - true: "1", "true", "yes", "on" (case-insensitive) +/// - false: "0", "false", "no", "off" (case-insensitive) fn parse_env_bool(key: &str, default: bool) -> bool { env::var(key) .ok() @@ -63,6 +80,21 @@ fn parse_env_bool(key: &str, default: bool) -> bool { .unwrap_or(default) } +/// Middleware to strip potentially spoofable forwarded headers from incoming requests. +/// +/// This middleware removes all proxy-related headers that could be spoofed by clients +/// to bypass security measures like rate limiting or IP-based access control. +/// +/// # Security considerations +/// When TRUST_PROXY_IP_HEADERS is disabled, this middleware ensures that only the +/// direct connection IP is trusted, preventing clients from faking their IP address. +/// +/// # Headers removed +/// - Forwarded (RFC 7239) +/// - X-Forwarded-For +/// - X-Forwarded-Proto +/// - X-Forwarded-Host +/// - X-Real-IP async fn strip_untrusted_forwarded_headers(mut request: Request, next: Next) -> Response { { let headers = request.headers_mut(); @@ -78,6 +110,25 @@ async fn strip_untrusted_forwarded_headers(mut request: Request, next: Next) -> next.run(request).await } +/// Middleware to add security headers to all HTTP responses. +/// +/// This middleware implements defense-in-depth security by adding multiple +/// protective headers to every response. It handles: +/// - Cache control based on endpoint sensitivity +/// - Content Security Policy (CSP) +/// - HSTS for HTTPS connections +/// - Anti-clickjacking protections +/// - Privacy and permissions policies +/// +/// # Security headers added +/// - Cache-Control: Prevents caching of sensitive data +/// - Content-Security-Policy: Mitigates XSS attacks +/// - Strict-Transport-Security: Enforces HTTPS (when applicable) +/// - X-Content-Type-Options: Prevents MIME sniffing +/// - X-Frame-Options: Prevents clickjacking +/// - Referrer-Policy: Protects user privacy +/// - Permissions-Policy: Disables dangerous browser features +/// - X-XSS-Protection: Disabled in favor of CSP async fn security_headers(request: Request, next: Next) -> Response { use axum::http::Method; @@ -170,6 +221,25 @@ const ADMIN_BODY_LIMIT: usize = 8 * 1024 * 1024; // 8MB for admin content uploa // In production, FRONTEND_ORIGINS environment variable must be set const DEV_DEFAULT_FRONTEND_ORIGINS: &[&str] = &["http://localhost:5173", "http://localhost:3000"]; +/// Parses and validates a list of allowed CORS origins. +/// +/// This function takes an iterator of origin strings and converts them into +/// validated HeaderValue objects suitable for use in CORS configuration. +/// +/// # Arguments +/// * `origins` - An iterator of origin strings to parse and validate +/// +/// # Returns +/// A vector of validated HeaderValue objects representing allowed origins +/// +/// # Validation rules +/// - Empty strings are ignored +/// - Origins must start with http:// or https:// +/// - Origins must be valid URLs according to the url crate +/// - Origins must be convertible to valid HTTP header values +/// +/// # Warnings +/// Invalid origins are logged as warnings and excluded from the result. fn parse_allowed_origins<'a, I>(origins: I) -> Vec where I: IntoIterator, @@ -210,11 +280,38 @@ where .collect() } +/// Main application entry point. +/// +/// This function initializes and starts the web server with all necessary +/// middleware, routes, and security configurations. +/// +/// # Initialization steps +/// 1. Load environment variables from .env file +/// 2. Initialize logging with tracing +/// 3. Initialize JWT secret for authentication +/// 4. Initialize CSRF token secret +/// 5. Initialize login attempt salt for rate limiting +/// 6. Create database connection pool +/// 7. Configure CORS with allowed origins +/// 8. Set up rate limiting for different endpoint types +/// 9. Configure routes and middleware layers +/// 10. Start the HTTP server +/// +/// # Panics +/// The function will panic if: +/// - JWT secret initialization fails +/// - CSRF secret initialization fails +/// - Login attempt salt initialization fails +/// - Database pool creation fails +/// - FRONTEND_ORIGINS is invalid or missing in production +/// - PORT environment variable is invalid +/// - Server fails to bind to the specified port #[tokio::main] async fn main() { - + // Load environment variables from .env file (if present) dotenv().ok(); + // Initialize structured logging tracing_subscriber::fmt::init(); auth::init_jwt_secret().expect("Failed to initialize JWT secret"); @@ -463,14 +560,32 @@ async fn main() { tracing::info!("Server shutdown complete"); } +/// Waits for a shutdown signal and initiates graceful shutdown. +/// +/// This function listens for termination signals to allow the server to shut down +/// gracefully, completing in-flight requests before stopping. +/// +/// # Signals handled +/// - Ctrl+C (SIGINT): Handles user interruption from terminal +/// - SIGTERM: Handles termination signal from process managers (Unix only) +/// +/// On non-Unix systems, only Ctrl+C is handled as SIGTERM is Unix-specific. +/// +/// # Graceful shutdown +/// When a signal is received, the server: +/// 1. Stops accepting new connections +/// 2. Waits for existing requests to complete +/// 3. Closes database connections +/// 4. Exits cleanly async fn shutdown_signal() { - + // Handle Ctrl+C signal (works on all platforms) let ctrl_c = async { signal::ctrl_c() .await .expect("Failed to install Ctrl+C handler"); }; + // Handle SIGTERM on Unix systems (used by Docker, systemd, etc.) #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) @@ -479,9 +594,11 @@ async fn shutdown_signal() { .await; }; + // On non-Unix systems, SIGTERM doesn't exist, so use a pending future #[cfg(not(unix))] let terminate = std::future::pending::<()>(); + // Wait for either Ctrl+C or SIGTERM tokio::select! { _ = ctrl_c => { tracing::info!("Received Ctrl+C signal");