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 sessions; | ||||||
| mod users; | mod users; | ||||||
| 
 | 
 | ||||||
| use axum::{ | use axum::{Router, extract::State, http::HeaderMap, routing::get}; | ||||||
|     Form, RequestExt, Router, | use axum_extra::extract::CookieJar; | ||||||
|     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 serde::Deserialize; | use serde::Deserialize; | ||||||
| 
 | 
 | ||||||
| use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; | use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; | ||||||
| 
 | 
 | ||||||
| use super::{ | use super::{Context, error::AppResult}; | ||||||
|     Context, |  | ||||||
|     error::{AppError, AppResult}, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const SESSION_ID_COOKIE: &str = "sessionid"; | 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
 |         // Login route needs to be handled differently, as the middleware turns it into a redirect
 | ||||||
|         // loop
 |         // loop
 | ||||||
|         .route("/login", get(get_login).post(post_login)) |         .merge(auth::router(ctx.clone())) | ||||||
|         .route("/logout", post(post_logout)) |  | ||||||
|         .merge(sessions::router(ctx.clone())) |         .merge(sessions::router(ctx.clone())) | ||||||
|         .merge(users::router(ctx.clone())) |         .merge(users::router(ctx.clone())) | ||||||
| } | } | ||||||
|  | @ -85,126 +73,10 @@ async fn get_index( | ||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
|     jar: CookieJar, |     jar: CookieJar, | ||||||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ) -> 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 |     Ok(View::Index | ||||||
|         .page(&headers) |         .page(&headers) | ||||||
|         .authenticated(authenticated) |         .authenticated(authenticated) | ||||||
|         .response(&ctx.tera)) |         .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("/sessions/{id}", delete(delete_session)) | ||||||
|         .route_layer(axum::middleware::from_fn_with_state( |         .route_layer(axum::middleware::from_fn_with_state( | ||||||
|             ctx.clone(), |             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("/users", get(get_users)) | ||||||
|         .route_layer(axum::middleware::from_fn_with_state( |         .route_layer(axum::middleware::from_fn_with_state( | ||||||
|             ctx.clone(), |             ctx.clone(), | ||||||
|             super::auth_web_middleware, |             super::auth::auth_web_middleware, | ||||||
|         )) |         )) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue