You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

161 lines
5.4 KiB
Rust

//! 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<String>,
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<String> {
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<AppState> for AuthUser {
type Rejection = AppError;
fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + 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<AppState> for AdminUser {
type Rejection = AppError;
fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + 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);
}
}