//! Session-based authentication middleware. //! //! Provides the `AuthUser` extractor that reads the session cookie, //! looks up the session in the database, checks expiration, and loads //! the associated user. Handlers that require authentication simply //! take `AuthUser` as a parameter. use axum::extract::FromRequestParts; use axum::http::header; use axum::http::request::Parts; use crate::app_state::AppState; use crate::db; use crate::errors::AppError; use crate::models::user::UserRole; use crate::services::auth::SESSION_COOKIE_NAME; use crate::util::token; /// Authenticated user extracted from the session cookie. /// /// Any handler that includes `AuthUser` in its parameters will automatically /// reject unauthenticated requests with a 401 Unauthorized response. #[derive(Clone, Debug)] pub struct AuthUser { pub id: uuid::Uuid, pub email: String, pub display_name: Option, pub role: UserRole, } /// Extract session token from a Cookie header value. /// /// Splits the header on `;`, trims whitespace, and returns the value of the /// first cookie whose name matches `cookie_name`. Returns `None` when no /// matching cookie is present. fn extract_session_token(cookie_header: &str, cookie_name: &str) -> Option { cookie_header .split(';') .map(|s| s.trim()) .find(|s| s.starts_with(&format!("{}=", cookie_name))) .and_then(|s| s.strip_prefix(&format!("{}=", cookie_name))) .map(|s| s.to_string()) } impl FromRequestParts for AuthUser { type Rejection = AppError; fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> impl std::future::Future> + Send { let pool = state.pool.clone(); // Extract the session cookie value from the Cookie header let session_token = parts .headers .get_all(header::COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find_map(|header_value| extract_session_token(header_value, SESSION_COOKIE_NAME)); async move { let session_token = session_token .ok_or_else(|| AppError::Unauthorized("No session cookie".into()))?; // Hash the raw token and look up the session let token_hash = token::hash_token(&session_token); let session = db::sessions::find_by_hash(&pool, &token_hash) .await? .ok_or_else(|| AppError::Unauthorized("Invalid session".into()))?; // Check expiration if session.expires_at < chrono::Utc::now() { db::sessions::delete_by_hash(&pool, &session.session_hash).await?; return Err(AppError::Unauthorized("Session expired".into())); } // Load the user let user = db::users::find_by_id(&pool, session.user_id) .await? .ok_or_else(|| AppError::Unauthorized("User not found".into()))?; Ok(AuthUser { id: user.id, email: user.email, display_name: user.display_name, role: user.role, }) } } } /// Admin user extractor. Wraps `AuthUser` and additionally checks that /// the user has the `Admin` role. #[derive(Clone, Debug)] pub struct AdminUser(pub AuthUser); impl FromRequestParts for AdminUser { type Rejection = AppError; fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> impl std::future::Future> + Send { async move { let auth_user = AuthUser::from_request_parts(parts, state).await?; if auth_user.role != UserRole::Admin { return Err(AppError::Forbidden("Admin access required".into())); } Ok(AdminUser(auth_user)) } } } #[cfg(test)] mod tests { use super::extract_session_token; use crate::services::auth::SESSION_COOKIE_NAME; #[test] fn test_valid_session_cookie_extracted() { let header = format!("{}=abc123def", SESSION_COOKIE_NAME); assert_eq!( extract_session_token(&header, SESSION_COOKIE_NAME), Some("abc123def".to_string()) ); } #[test] fn test_missing_session_cookie_returns_none() { let header = "other=value; another=thing"; assert_eq!(extract_session_token(header, SESSION_COOKIE_NAME), None); } #[test] fn test_multiple_cookies_correct_one_picked() { let header = format!("theme=dark; {}=mytoken123; lang=fr", SESSION_COOKIE_NAME); assert_eq!( extract_session_token(&header, SESSION_COOKIE_NAME), Some("mytoken123".to_string()) ); } #[test] fn test_cookie_with_whitespace_trimmed() { // The whole segment is trimmed before matching, so leading/trailing // whitespace around name=value pairs is stripped. The value itself // also has its trailing spaces removed as part of that segment trim. let header = format!(" {}=abc123 ; other=val ", SESSION_COOKIE_NAME); assert_eq!( extract_session_token(&header, SESSION_COOKIE_NAME), Some("abc123".to_string()) ); } #[test] fn test_empty_cookie_header_returns_none() { assert_eq!(extract_session_token("", SESSION_COOKIE_NAME), None); } }