diff --git a/src/api/admin.rs b/src/api/admin.rs index 6e10f0b6..656f63bf 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -6,10 +6,10 @@ use std::env; use rocket::serde::json::Json; use rocket::{ form::Form, - http::{Cookie, CookieJar, SameSite, Status}, - request::{self, FromRequest, Outcome, Request}, + http::{Cookie, CookieJar, MediaType, SameSite, Status}, + request::{FromRequest, Outcome, Request}, response::{content::RawHtml as Html, Redirect}, - Route, + Catcher, Route, }; use crate::{ @@ -31,7 +31,6 @@ pub fn routes() -> Vec { } routes![ - admin_login, get_users_json, get_user_json, post_admin_login, @@ -57,6 +56,14 @@ pub fn routes() -> Vec { ] } +pub fn catchers() -> Vec { + if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() { + catchers![] + } else { + catchers![admin_login] + } +} + static DB_TYPE: Lazy<&str> = Lazy::new(|| { DbConnType::from_url(&CONFIG.database_url()) .map(|t| match t { @@ -87,17 +94,6 @@ fn admin_path() -> String { format!("{}{}", CONFIG.domain_path(), ADMIN_PATH) } -struct Referer(Option); - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for Referer { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> request::Outcome { - Outcome::Success(Referer(request.headers().get_one("Referer").map(str::to_string))) - } -} - #[derive(Debug)] struct IpHeader(Option); @@ -120,25 +116,8 @@ impl<'r> FromRequest<'r> for IpHeader { } } -/// Used for `Location` response headers, which must specify an absolute URI -/// (see https://tools.ietf.org/html/rfc2616#section-14.30). -fn admin_url(referer: Referer) -> String { - // If we get a referer use that to make it work when, DOMAIN is not set - if let Some(mut referer) = referer.0 { - if let Some(start_index) = referer.find(ADMIN_PATH) { - referer.truncate(start_index + ADMIN_PATH.len()); - return referer; - } - } - - if CONFIG.domain_set() { - // Don't use CONFIG.domain() directly, since the user may want to keep a - // trailing slash there, particularly when running under a subpath. - format!("{}{}{}", CONFIG.domain_origin(), CONFIG.domain_path(), ADMIN_PATH) - } else { - // Last case, when no referer or domain set, technically invalid but better than nothing - ADMIN_PATH.to_string() - } +fn admin_url() -> String { + format!("{}{}", CONFIG.domain_origin(), admin_path()) } #[derive(Responder)] @@ -151,18 +130,23 @@ enum AdminResponse { TooManyRequests(ApiResult>), } -#[get("/", rank = 2)] -fn admin_login() -> ApiResult> { - render_admin_login(None) +#[catch(401)] +fn admin_login(request: &Request<'_>) -> ApiResult> { + if request.format() == Some(&MediaType::JSON) { + err_code!("Authorization failed.", Status::Unauthorized.code); + } + let redirect = request.segments::(0..).unwrap_or_default().display().to_string(); + render_admin_login(None, Some(redirect)) } -fn render_admin_login(msg: Option<&str>) -> ApiResult> { +fn render_admin_login(msg: Option<&str>, redirect: Option) -> ApiResult> { // If there is an error, show it let msg = msg.map(|msg| format!("Error: {msg}")); let json = json!({ "page_content": "admin/login", "version": VERSION, "error": msg, + "redirect": redirect, "urlpath": CONFIG.domain_path() }); @@ -174,20 +158,25 @@ fn render_admin_login(msg: Option<&str>) -> ApiResult> { #[derive(FromForm)] struct LoginForm { token: String, + redirect: Option, } #[post("/", data = "")] -fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp) -> AdminResponse { +fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp) -> Result { let data = data.into_inner(); + let redirect = data.redirect; if crate::ratelimit::check_limit_admin(&ip.ip).is_err() { - return AdminResponse::TooManyRequests(render_admin_login(Some("Too many requests, try again later."))); + return Err(AdminResponse::TooManyRequests(render_admin_login( + Some("Too many requests, try again later."), + redirect, + ))); } // If the token is invalid, redirect to login page if !_validate_token(&data.token) { error!("Invalid admin token. IP: {}", ip.ip); - AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."))) + Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect))) } else { // If the token received is valid, generate JWT and save it as a cookie let claims = generate_admin_claims(); @@ -201,7 +190,11 @@ fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp .finish(); cookies.add(cookie); - AdminResponse::Ok(render_admin_page()) + if let Some(redirect) = redirect { + Ok(Redirect::to(format!("{}{}", admin_path(), redirect))) + } else { + Err(AdminResponse::Ok(render_admin_page())) + } } } @@ -258,7 +251,7 @@ fn render_admin_page() -> ApiResult> { Ok(Html(text)) } -#[get("/", rank = 1)] +#[get("/")] fn admin_page(_token: AdminToken) -> ApiResult> { render_admin_page() } @@ -314,9 +307,9 @@ async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { } #[get("/logout")] -fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect { +fn logout(cookies: &CookieJar<'_>) -> Redirect { cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); - Redirect::temporary(admin_url(referer)) + Redirect::to(admin_path()) } #[get("/users")] @@ -639,7 +632,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) "uses_proxy": uses_proxy, "db_type": *DB_TYPE, "db_version": get_sql_server_version(&mut conn).await, - "admin_url": format!("{}/diagnostics", admin_url(Referer(None))), + "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the date/time check as the last item to minimize the difference @@ -681,15 +674,15 @@ pub struct AdminToken {} impl<'r> FromRequest<'r> for AdminToken { type Error = &'static str; - async fn from_request(request: &'r Request<'_>) -> request::Outcome { + async fn from_request(request: &'r Request<'_>) -> Outcome { if CONFIG.disable_admin_token() { - Outcome::Success(AdminToken {}) + Outcome::Success(Self {}) } else { let cookies = request.cookies(); let access_token = match cookies.get(COOKIE_NAME) { Some(cookie) => cookie.value(), - None => return Outcome::Forward(()), // If there is no cookie, redirect to login + None => return Outcome::Failure((Status::Unauthorized, "Unauthorized")), }; let ip = match ClientIp::from_request(request).await { @@ -701,10 +694,10 @@ impl<'r> FromRequest<'r> for AdminToken { // Remove admin cookie cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); error!("Invalid or expired admin JWT. IP: {}.", ip); - return Outcome::Forward(()); + return Outcome::Failure((Status::Unauthorized, "Session expired")); } - Outcome::Success(AdminToken {}) + Outcome::Success(Self {}) } } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 0861ea2d..06db310b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ use rocket::serde::json::Json; use serde_json::Value; pub use crate::api::{ + admin::catchers as admin_catchers, admin::routes as admin_routes, core::catchers as core_catchers, core::purge_sends, diff --git a/src/main.rs b/src/main.rs index 70cd5d9f..86bcc36c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -436,6 +436,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .mount([basepath, "/notifications"].concat(), api::notifications_routes()) .register([basepath, "/"].concat(), api::web_catchers()) .register([basepath, "/api"].concat(), api::core_catchers()) + .register([basepath, "/admin"].concat(), api::admin_catchers()) .manage(pool) .manage(api::start_notification_server()) .attach(util::AppHeaders()) diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs index 20d6f371..cb63eb4c 100644 --- a/src/static/templates/admin/diagnostics.hbs +++ b/src/static/templates/admin/diagnostics.hbs @@ -352,7 +352,13 @@ supportString += "* Reverse proxy and version: \n"; supportString += "* Other relevant information: \n"; - let jsonResponse = await fetch('{{urlpath}}/admin/diagnostics/config'); + let jsonResponse = await fetch('{{urlpath}}/admin/diagnostics/config', { + 'headers': { 'Accept': 'application/json' } + }); + if (!jsonResponse.ok) { + alert("Generation failed: " + jsonResponse.statusText); + throw new Error(jsonResponse); + } const configJson = await jsonResponse.json(); supportString += "\n### Config (Generated via diagnostics page)\n
Show Running Config\n" supportString += "\n**Environment settings which are overridden:** {{page_data.overrides}}\n" diff --git a/src/static/templates/admin/login.hbs b/src/static/templates/admin/login.hbs index 0492fcab..20b1998e 100644 --- a/src/static/templates/admin/login.hbs +++ b/src/static/templates/admin/login.hbs @@ -12,8 +12,11 @@
Authentication key needed to continue
Please provide it below: -
+ + {{#if redirect}} + + {{/if}}