Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

redirect to admin login page when forward fails #2886

Merged
merged 2 commits into from
Dec 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 44 additions & 51 deletions src/api/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -31,7 +31,6 @@ pub fn routes() -> Vec<Route> {
}

routes![
admin_login,
get_users_json,
get_user_json,
post_admin_login,
Expand All @@ -57,6 +56,14 @@ pub fn routes() -> Vec<Route> {
]
}

pub fn catchers() -> Vec<Catcher> {
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 {
Expand Down Expand Up @@ -85,17 +92,6 @@ fn admin_path() -> String {
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
}

struct Referer(Option<String>);

#[rocket::async_trait]
impl<'r> FromRequest<'r> for Referer {
type Error = ();

async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
Outcome::Success(Referer(request.headers().get_one("Referer").map(str::to_string)))
}
}

#[derive(Debug)]
struct IpHeader(Option<String>);

Expand All @@ -118,25 +114,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)]
Expand All @@ -149,18 +128,23 @@ enum AdminResponse {
TooManyRequests(ApiResult<Html<String>>),
}

#[get("/", rank = 2)]
fn admin_login() -> ApiResult<Html<String>> {
render_admin_login(None)
#[catch(401)]
fn admin_login(request: &Request<'_>) -> ApiResult<Html<String>> {
if request.format() == Some(&MediaType::JSON) {
err_code!("Authorization failed.", Status::Unauthorized.code);
}
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
render_admin_login(None, Some(redirect))
}

fn render_admin_login(msg: Option<&str>) -> ApiResult<Html<String>> {
fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> ApiResult<Html<String>> {
// 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()
});

Expand All @@ -172,20 +156,25 @@ fn render_admin_login(msg: Option<&str>) -> ApiResult<Html<String>> {
#[derive(FromForm)]
struct LoginForm {
token: String,
redirect: Option<String>,
}

#[post("/", data = "<data>")]
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> AdminResponse {
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> Result<Redirect, AdminResponse> {
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();
Expand All @@ -199,7 +188,11 @@ fn post_admin_login(data: Form<LoginForm>, 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()))
}
}
}

Expand Down Expand Up @@ -256,7 +249,7 @@ fn render_admin_page() -> ApiResult<Html<String>> {
Ok(Html(text))
}

#[get("/", rank = 1)]
#[get("/")]
fn admin_page(_token: AdminToken) -> ApiResult<Html<String>> {
render_admin_page()
}
Expand Down Expand Up @@ -312,9 +305,9 @@ async fn test_smtp(data: Json<InviteData>, _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")]
Expand Down Expand Up @@ -603,7 +596,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
Expand Down Expand Up @@ -645,15 +638,15 @@ pub struct AdminToken {}
impl<'r> FromRequest<'r> for AdminToken {
type Error = &'static str;

async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
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 {
Expand All @@ -665,10 +658,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 {})
}
}
}
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,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())
Expand Down
8 changes: 7 additions & 1 deletion src/static/templates/admin/diagnostics.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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<details><summary>Show Running Config</summary>\n"
supportString += "\n**Environment settings which are overridden:** {{page_data.overrides}}\n"
Expand Down
5 changes: 4 additions & 1 deletion src/static/templates/admin/login.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
<h6 class="mb-0 text-white">Authentication key needed to continue</h6>
<small>Please provide it below:</small>

<form class="form-inline" method="post">
<form class="form-inline" method="post" action="{{urlpath}}/admin">
<input type="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
{{#if redirect}}
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
{{/if}}
<button type="submit" class="btn btn-primary">Enter</button>
</form>
</div>
Expand Down