refactor: split web auth routes
							parent
							
								
									5cd1f4f736
								
							
						
					
					
						commit
						97b30b1840
					
				|  | @ -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(), | ||||
|     } | ||||
| } | ||||
|  | @ -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(), | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|         )) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|         )) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue