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
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);
|
|
}
|
|
}
|