use axum::{ extract::{Request, State}, http::HeaderMap, middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, routing::get, Form, RequestExt, Router, }; use axum_extra::{extract::CookieJar, headers::UserAgent, TypedHeader}; use cookie::{time::Duration, Cookie}; use gpodder::{AuthErr, Session}; use serde::Deserialize; use crate::web::{Page, TemplateExt, TemplateResponse, View}; use super::{ error::{AppError, AppResult}, Context, }; const SESSION_ID_COOKIE: &str = "sessionid"; pub fn router(ctx: Context) -> Router { Router::new() .route("/", get(get_index)) .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)) } async fn get_index(State(ctx): State, headers: HeaderMap) -> TemplateResponse> { View::Index.page(&headers).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>, _headers: HeaderMap, 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.create_session(&user, user_agent)?; Ok::<_, AuthErr>(session) }) .await .unwrap() { Ok(session) => Ok(( 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)), ), Redirect::to("/"), ) .into_response()), Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => { todo!("serve login form with error messages") } Err(err) => Err(AppError::from(err)), } } 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.user); next.run(req).await } Ok(None) => redirect.into_response(), Err(err) => err.into_response(), } }