feat: Redirect to requested URL after login
This commit is contained in:
parent
c28b25d8d9
commit
63e55f814f
8 changed files with 144 additions and 17 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<HttpState>,
|
||||
login_error: Option<LoginError>,
|
||||
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<HttpState>,
|
||||
maybe_session: Option<OptionalHttpSession>,
|
||||
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<HttpState>,
|
||||
session: Option<OptionalHttpSession>,
|
||||
cookies: CookieJar,
|
||||
redirect: InternalRedirect,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HttpState>,
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
96
src/http/redirect.rs
Normal file
96
src/http/redirect.rs
Normal file
|
|
@ -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<String>);
|
||||
|
||||
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<HttpState> for InternalRedirect {
|
||||
type Rejection = !;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &HttpState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Ok(f) = Query::<RedirectForm>::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,
|
||||
}
|
||||
|
|
@ -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<HttpState> for HttpSession {
|
|||
state: &HttpState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
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()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<p>Logout successful</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="/login">
|
||||
<form method="POST" action="{{ login_url }}">
|
||||
<div id="login-line">
|
||||
<img src="/assets/img/user.svg">
|
||||
<input type="text" name="username" id="username" placeholder="Username">
|
||||
|
|
|
|||
Loading…
Reference in a new issue