refactor: split web auth routes

signup-links
Jef Roosens 2025-07-17 10:55:47 +02:00
parent 5cd1f4f736
commit 97b30b1840
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
4 changed files with 157 additions and 136 deletions

View File

@ -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<Context> {
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<Context>, 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<Context>,
user_agent: Option<TypedHeader<UserAgent>>,
jar: CookieJar,
Form(login): Form<LoginForm>,
) -> AppResult<Response> {
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<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
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<Option<Session>> {
if let Some(session_id) = jar
.get(super::SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().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<Context>,
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(),
}
}

View File

@ -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<Context> {
// ))
// 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<TemplateResponse<Page<View>>> {
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<Context>, 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<Context>,
user_agent: Option<TypedHeader<UserAgent>>,
jar: CookieJar,
Form(login): Form<LoginForm>,
) -> AppResult<Response> {
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<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
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<Option<Session>> {
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().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<Context>,
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(),
}
}

View File

@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router<Context> {
.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,
))
}

View File

@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router<Context> {
.route("/users", get(get_users))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
super::auth_web_middleware,
super::auth::auth_web_middleware,
))
}