diff --git a/src/server/web/mod.rs b/src/server/web/mod.rs index f3a8691..44be6d8 100644 --- a/src/server/web/mod.rs +++ b/src/server/web/mod.rs @@ -4,14 +4,19 @@ use axum::{ middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, routing::get, - RequestExt, Router, + Form, RequestExt, Router, }; -use axum_extra::extract::CookieJar; -use cookie::Cookie; +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, Context}; +use super::{ + error::{AppError, AppResult}, + Context, +}; const SESSION_ID_COOKIE: &str = "sessionid"; @@ -22,23 +27,77 @@ pub fn router(ctx: Context) -> Router { 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) } -/// 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 mut jar: CookieJar = req.extract_parts().await.unwrap(); - let redirect = Redirect::to("/_/login"); +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()) @@ -52,22 +111,33 @@ pub async fn auth_web_middleware( .await .unwrap() { - Ok(session) => { - req.extensions_mut().insert(session.user); - - (jar, next.run(req).await).into_response() - } - Err(gpodder::AuthErr::UnknownSession) => { - jar = jar.add( - Cookie::build((SESSION_ID_COOKIE, String::new())) - .max_age(cookie::time::Duration::ZERO), - ); - - (jar, redirect).into_response() - } - Err(err) => AppError::from(err).into_response(), + Ok(session) => Ok(Some(session)), + Err(gpodder::AuthErr::UnknownSession) => Ok(None), + Err(err) => Err(AppError::from(err)), } } else { - redirect.into_response() + 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(), } } diff --git a/src/web/mod.rs b/src/web/mod.rs index 8b33c27..683732e 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -75,6 +75,10 @@ pub fn initialize_tera() -> tera::Result { View::Index.template(), include_str!("templates/views/index.html"), ), + ( + View::Login.template(), + include_str!("templates/views/login.html"), + ), ])?; Ok(tera) diff --git a/src/web/templates/base.html b/src/web/templates/base.html index 1894e72..3afd3e4 100644 --- a/src/web/templates/base.html +++ b/src/web/templates/base.html @@ -16,7 +16,7 @@ a:hover {
-
+
{{ inner | safe }}
diff --git a/src/web/templates/views/index.html b/src/web/templates/views/index.html index 6fc5b55..7116f34 100644 --- a/src/web/templates/views/index.html +++ b/src/web/templates/views/index.html @@ -1,3 +1,5 @@

Otter

Otter is a self-hostable Gpodder implementation. + +If you're seeing this, you're logged in. diff --git a/src/web/templates/views/login.html b/src/web/templates/views/login.html new file mode 100644 index 0000000..fa6df57 --- /dev/null +++ b/src/web/templates/views/login.html @@ -0,0 +1,9 @@ +
+
+ + + + + +
+
diff --git a/src/web/view.rs b/src/web/view.rs index 55ca3ce..236d8a5 100644 --- a/src/web/view.rs +++ b/src/web/view.rs @@ -2,12 +2,14 @@ use super::Template; pub enum View { Index, + Login, } impl Template for View { fn template(&self) -> &'static str { match self { Self::Index => "views/index.html", + Self::Login => "views/login.html", } }