diff --git a/Cargo.lock b/Cargo.lock index bd10531..efe035e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.1" @@ -512,7 +518,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "ldap3_lber", "nom", @@ -537,6 +543,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "base64 0.23.1", "camino", "dn_escape", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 07c7928..827b2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ argh = "0.1.19" async-trait = "0.1.92" axum = { version = "0.8.9", optional = true, features = ["macros"] } axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } +base64 = "0.23.1" camino = { version = "1.2.5", features = ["serde1"] } dn_escape = { path = "vendor/dn_escape" } futures-util = { version = "0.3.34", features = ["sink"] } diff --git a/src/http/login.rs b/src/http/login.rs index 578b5ff..7a65998 100644 --- a/src/http/login.rs +++ b/src/http/login.rs @@ -1,12 +1,12 @@ use axum::extract::{Form, State}; -use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::response::{Html, IntoResponse, Response}; use axum_extra::extract::cookie::CookieJar; use http::StatusCode; use minijinja::context; use serde::{Deserialize, Serialize}; use crate::db::{DatabaseInterface, UserRef}; -use crate::http::{HttpState, OptionalHttpSession}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; #[derive(Debug, Deserialize)] pub struct LoginForm { @@ -23,12 +23,13 @@ pub enum LoginError { pub async fn login_page( State(state): State, login_error: Option, + redirect: InternalRedirect, ) -> Response { let page = state .templates .get_template("login.html") .unwrap() - .render(context! {login_error => login_error}) + .render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())}) .unwrap(); (StatusCode::OK, Html(page)).into_response() } @@ -36,23 +37,25 @@ pub async fn login_page( pub async fn get_login( State(state): State, maybe_session: Option, + redirect: InternalRedirect, ) -> Response { if maybe_session.is_some() { - return Redirect::to("/").into_response(); + return redirect.to_redirect().into_response(); } - login_page(State(state), None).await + login_page(State(state), None, redirect).await } pub async fn post_login( State(state): State, session: Option, cookies: CookieJar, + redirect: InternalRedirect, Form(form): Form, ) -> Response { if session.is_some() { // Already logged in - return (cookies, Redirect::to("/")).into_response(); + return (cookies, redirect.to_redirect()).into_response(); } let req_user = match UserRef::from_user_maybe_domain(&form.username) { @@ -83,9 +86,9 @@ pub async fn post_login( }; let cookies = state.sessions.add_session(user, cookies); - (cookies, Redirect::to("/")).into_response() + (cookies, redirect.to_redirect()).into_response() } else { - login_page(State(state), Some(LoginError::InvalidCredentials)) + login_page(State(state), Some(LoginError::InvalidCredentials), redirect) .await .into_response() } diff --git a/src/http/logout.rs b/src/http/logout.rs index 3dc5151..844633d 100644 --- a/src/http/logout.rs +++ b/src/http/logout.rs @@ -3,7 +3,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use crate::http::login::{LoginError, login_page}; -use crate::http::{HttpState, OptionalHttpSession}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; pub async fn logout( State(state): State, @@ -17,7 +17,12 @@ pub async fn logout( let cookies = state.sessions.remove_session(&session, cookies); ( cookies, - login_page(State(state), Some(LoginError::SessionInvalidated)).await, + login_page( + State(state), + Some(LoginError::SessionInvalidated), + InternalRedirect::new(), + ) + .await, ) .into_response() } diff --git a/src/http/mod.rs b/src/http/mod.rs index daf1507..e5b8ae1 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -17,6 +17,8 @@ mod domain; mod home; mod login; mod logout; +mod redirect; +use redirect::InternalRedirect; mod session; use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; mod user; diff --git a/src/http/redirect.rs b/src/http/redirect.rs new file mode 100644 index 0000000..120f9c8 --- /dev/null +++ b/src/http/redirect.rs @@ -0,0 +1,96 @@ +use axum::extract::{FromRequestParts, Query}; +use axum::response::Redirect; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use http::request::Parts; +use serde::Deserialize; + +use crate::http::HttpState; + +/// Internal redirect to a different page. +/// +/// Is usually constructed from a base64url-encoded `redirect` +/// query string, for example in the login page. +pub struct InternalRedirect(Option); + +impl InternalRedirect { + /// Creates a new redirection from a base64url-encoded string. + /// + /// An invalid redirect is silently discarded and treated as no redirect. + pub fn from_base64url(s: &str) -> Self { + let s = match URL_SAFE.decode(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL, treating as empty: {e}"); + return Self::new(); + } + }; + + let s = match String::from_utf8(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL bytes, treating as empty: {e}"); + return Self::new(); + } + }; + + Self::from_string(s) + } + + /// Turns into a base64url string that can be added to a URL. + pub fn to_base64url(&self) -> String { + let s = self + .0 + .as_ref() + .expect("Cannot call InternalRedirect::to_base64url on an empty redirect"); + URL_SAFE.encode(s) + } + + /// Creates a new redirection from a raw string. + /// + /// If the string is empty or seems to redirect outside of our website (absolute URL), + /// the redirect is silently discarded and treated as no redirect. + pub fn from_string(s: String) -> Self { + if s.trim().is_empty() { + return Self(None); + } + + if s.starts_with("http://") || s.starts_with("https://") { + tracing::debug!("Invalid login redirect URL, treating as empty: {s}"); + return Self(None); + } + + Self(Some(s)) + } + + pub fn new() -> Self { + Self(None) + } + + pub fn to_redirect(&self) -> Redirect { + if let Some(url) = &self.0 { + Redirect::to(url) + } else { + Redirect::to("/") + } + } +} + +impl FromRequestParts for InternalRedirect { + type Rejection = !; + + async fn from_request_parts( + parts: &mut Parts, + state: &HttpState, + ) -> Result { + let Ok(f) = Query::::from_request_parts(parts, state).await else { + return Ok(Self::new()); + }; + + Ok(Self::from_base64url(&f.redirect)) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RedirectForm { + redirect: String, +} diff --git a/src/http/session.rs b/src/http/session.rs index 1f88874..fd73975 100644 --- a/src/http/session.rs +++ b/src/http/session.rs @@ -2,12 +2,13 @@ use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use axum::response::Redirect; use axum_extra::extract::cookie::{Cookie, CookieJar}; use http::request::Parts; +use http::uri::Uri; use uuid::Uuid; use std::sync::{Arc, RwLock}; use crate::db::User; -use crate::http::HttpState; +use crate::http::{HttpState, InternalRedirect}; pub const COOKIE_NAME: &str = "lldap_session"; @@ -91,10 +92,22 @@ impl FromRequestParts for HttpSession { state: &HttpState, ) -> Result { let cookies = CookieJar::from_request_parts(parts, state).await.unwrap(); - state - .sessions - .get_session(&cookies) - .ok_or(Redirect::to("/login")) + if let Some(session) = state.sessions.get_session(&cookies) { + return Ok(session); + } + + // Extract the requested URL, turn it into base64, to let the login page + // know where to redirect us. + // But first, remove the scheme/host/port from URL. + let s = Uri::builder() + .path_and_query(parts.uri.path_and_query().unwrap().clone()) + .build() + .unwrap(); + let r = InternalRedirect::from_string(s.to_string()); + Err(Redirect::to(&format!( + "/login?redirect={}", + r.to_base64url() + ))) } } diff --git a/templates/login.html b/templates/login.html index 11ae59a..82ef600 100644 --- a/templates/login.html +++ b/templates/login.html @@ -11,7 +11,7 @@

Logout successful

{% endif %} -
+