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