From 97b30b1840ba25035aeacb3d8ce2d457cac0c6cf Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Thu, 17 Jul 2025 10:55:47 +0200 Subject: [PATCH] refactor: split web auth routes --- otter/src/server/web/auth.rs | 149 +++++++++++++++++++++++++++++++ otter/src/server/web/mod.rs | 140 ++--------------------------- otter/src/server/web/sessions.rs | 2 +- otter/src/server/web/users.rs | 2 +- 4 files changed, 157 insertions(+), 136 deletions(-) create mode 100644 otter/src/server/web/auth.rs diff --git a/otter/src/server/web/auth.rs b/otter/src/server/web/auth.rs new file mode 100644 index 0000000..0aeeec6 --- /dev/null +++ b/otter/src/server/web/auth.rs @@ -0,0 +1,149 @@ +use axum::{ + Form, RequestExt, Router, + extract::{Request, State}, + http::HeaderMap, + middleware::Next, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent}; +use cookie::{Cookie, time::Duration}; +use serde::Deserialize; + +use gpodder::{AuthErr, Session}; + +use crate::{ + server::{ + Context, + error::{AppError, AppResult}, + }, + web::{TemplateExt, View}, +}; + +pub fn router(ctx: Context) -> Router { + Router::new() + // .layer(middleware::from_fn_with_state( + // ctx.clone(), + // auth_web_middleware, + // )) + // Login route needs to be handled differently, as the middleware turns it into a redirect + // loop + .route("/login", get(get_login).post(post_login)) + .route("/logout", post(post_logout)) +} + +async fn get_login(State(ctx): State, headers: HeaderMap, jar: CookieJar) -> Response { + if extract_session(ctx.clone(), &jar) + .await + .ok() + .flatten() + .is_some() + { + Redirect::to("/").into_response() + } else { + View::Login + .page(&headers) + .response(&ctx.tera) + .into_response() + } +} + +#[derive(Deserialize)] +struct LoginForm { + username: String, + password: String, +} + +async fn post_login( + State(ctx): State, + user_agent: Option>, + jar: CookieJar, + Form(login): Form, +) -> AppResult { + match tokio::task::spawn_blocking(move || { + let user = ctx + .store + .validate_credentials(&login.username, &login.password)?; + + let user_agent = user_agent.map(|header| header.to_string()); + let session = ctx.store.user(&user).create_session(user_agent)?; + + Ok::<_, AuthErr>(session) + }) + .await + .unwrap() + { + Ok(session) => Ok(( + // Redirect forces htmx to reload the full page, refreshing the navbar + [("HX-Redirect", "/")], + (jar.add( + Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string())) + .secure(true) + .same_site(cookie::SameSite::Lax) + .http_only(true) + .path("/") + .max_age(Duration::days(365)), + )), + ) + .into_response()), + Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => { + todo!("serve login form with error messages") + } + Err(err) => Err(AppError::from(err)), + } +} + +/// Log out the user by simply removing the session +async fn post_logout(State(ctx): State, jar: CookieJar) -> AppResult { + if let Some(session) = extract_session(ctx.clone(), &jar).await? { + ctx.store.remove_session(session.id)?; + } + + // Redirect forces htmx to reload the full page, refreshing the navbar + Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE))) +} + +pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult> { + if let Some(session_id) = jar + .get(super::SESSION_ID_COOKIE) + .and_then(|c| c.value().parse::().ok()) + { + match tokio::task::spawn_blocking(move || { + let session = ctx.store.get_session(session_id)?; + ctx.store.refresh_session(&session)?; + + Ok(session) + }) + .await + .unwrap() + { + Ok(session) => Ok(Some(session)), + Err(gpodder::AuthErr::UnknownSession) => Ok(None), + Err(err) => Err(AppError::from(err)), + } + } else { + Ok(None) + } +} + +/// Middleware that authenticates the current user via the session token. If the credentials are +/// invalid, the user is redirected to the login page. +pub async fn auth_web_middleware( + State(ctx): State, + mut req: Request, + next: Next, +) -> Response { + // SAFETY: this extractor's error type is Infallible + let jar: CookieJar = req.extract_parts().await.unwrap(); + let redirect = Redirect::to("/login"); + + match extract_session(ctx, &jar).await { + Ok(Some(session)) => { + req.extensions_mut().insert(session); + + next.run(req).await + } + Ok(None) => redirect.into_response(), + Err(err) => err.into_response(), + } +} diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index e9c5cb2..ab67b9a 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -1,25 +1,14 @@ +mod auth; mod sessions; mod users; -use axum::{ - Form, RequestExt, Router, - extract::{Request, State}, - http::HeaderMap, - middleware::Next, - response::{IntoResponse, Redirect, Response}, - routing::{get, post}, -}; -use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent}; -use cookie::{Cookie, time::Duration}; -use gpodder::{AuthErr, Session}; +use axum::{Router, extract::State, http::HeaderMap, routing::get}; +use axum_extra::extract::CookieJar; use serde::Deserialize; use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; -use super::{ - Context, - error::{AppError, AppResult}, -}; +use super::{Context, error::AppResult}; const SESSION_ID_COOKIE: &str = "sessionid"; @@ -74,8 +63,7 @@ pub fn router(ctx: Context) -> Router { // )) // Login route needs to be handled differently, as the middleware turns it into a redirect // loop - .route("/login", get(get_login).post(post_login)) - .route("/logout", post(post_logout)) + .merge(auth::router(ctx.clone())) .merge(sessions::router(ctx.clone())) .merge(users::router(ctx.clone())) } @@ -85,126 +73,10 @@ async fn get_index( headers: HeaderMap, jar: CookieJar, ) -> AppResult>> { - let authenticated = extract_session(ctx.clone(), &jar).await?.is_some(); + let authenticated = auth::extract_session(ctx.clone(), &jar).await?.is_some(); Ok(View::Index .page(&headers) .authenticated(authenticated) .response(&ctx.tera)) } - -async fn get_login(State(ctx): State, headers: HeaderMap, jar: CookieJar) -> Response { - if extract_session(ctx.clone(), &jar) - .await - .ok() - .flatten() - .is_some() - { - Redirect::to("/").into_response() - } else { - View::Login - .page(&headers) - .response(&ctx.tera) - .into_response() - } -} - -#[derive(Deserialize)] -struct LoginForm { - username: String, - password: String, -} - -async fn post_login( - State(ctx): State, - user_agent: Option>, - jar: CookieJar, - Form(login): Form, -) -> AppResult { - match tokio::task::spawn_blocking(move || { - let user = ctx - .store - .validate_credentials(&login.username, &login.password)?; - - let user_agent = user_agent.map(|header| header.to_string()); - let session = ctx.store.user(&user).create_session(user_agent)?; - - Ok::<_, AuthErr>(session) - }) - .await - .unwrap() - { - Ok(session) => Ok(( - // Redirect forces htmx to reload the full page, refreshing the navbar - [("HX-Redirect", "/")], - (jar.add( - Cookie::build((SESSION_ID_COOKIE, session.id.to_string())) - .secure(true) - .same_site(cookie::SameSite::Lax) - .http_only(true) - .path("/") - .max_age(Duration::days(365)), - )), - ) - .into_response()), - Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => { - todo!("serve login form with error messages") - } - Err(err) => Err(AppError::from(err)), - } -} - -/// Log out the user by simply removing the session -async fn post_logout(State(ctx): State, jar: CookieJar) -> AppResult { - if let Some(session) = extract_session(ctx.clone(), &jar).await? { - ctx.store.remove_session(session.id)?; - } - - // Redirect forces htmx to reload the full page, refreshing the navbar - Ok(([("HX-Redirect", "/")], jar.remove(SESSION_ID_COOKIE))) -} - -async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult> { - if let Some(session_id) = jar - .get(SESSION_ID_COOKIE) - .and_then(|c| c.value().parse::().ok()) - { - match tokio::task::spawn_blocking(move || { - let session = ctx.store.get_session(session_id)?; - ctx.store.refresh_session(&session)?; - - Ok(session) - }) - .await - .unwrap() - { - Ok(session) => Ok(Some(session)), - Err(gpodder::AuthErr::UnknownSession) => Ok(None), - Err(err) => Err(AppError::from(err)), - } - } else { - Ok(None) - } -} - -/// Middleware that authenticates the current user via the session token. If the credentials are -/// invalid, the user is redirected to the login page. -pub async fn auth_web_middleware( - State(ctx): State, - mut req: Request, - next: Next, -) -> Response { - // SAFETY: this extractor's error type is Infallible - let jar: CookieJar = req.extract_parts().await.unwrap(); - let redirect = Redirect::to("/login"); - - match extract_session(ctx, &jar).await { - Ok(Some(session)) => { - req.extensions_mut().insert(session); - - next.run(req).await - } - Ok(None) => redirect.into_response(), - Err(err) => err.into_response(), - } -} diff --git a/otter/src/server/web/sessions.rs b/otter/src/server/web/sessions.rs index 8c25f30..4c7aa4c 100644 --- a/otter/src/server/web/sessions.rs +++ b/otter/src/server/web/sessions.rs @@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router { .route("/sessions/{id}", delete(delete_session)) .route_layer(axum::middleware::from_fn_with_state( ctx.clone(), - super::auth_web_middleware, + super::auth::auth_web_middleware, )) } diff --git a/otter/src/server/web/users.rs b/otter/src/server/web/users.rs index dd54c0e..9d412e1 100644 --- a/otter/src/server/web/users.rs +++ b/otter/src/server/web/users.rs @@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router { .route("/users", get(get_users)) .route_layer(axum::middleware::from_fn_with_state( ctx.clone(), - super::auth_web_middleware, + super::auth::auth_web_middleware, )) }