Skip to content

Commit

Permalink
Add experimental {auth/login}-callback auth modes
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasKalbertodt committed Dec 14, 2023
1 parent 9d183f3 commit 79a0834
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 27 deletions.
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ time = "0.3"
tokio = { version = "=1.28", features = ["fs", "rt-multi-thread", "macros", "time"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1"] }
tokio-postgres-rustls = "0.10.0"
url = "2.4.1"

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.15.1"
Expand Down
51 changes: 40 additions & 11 deletions backend/src/auth/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::unreachable;

use base64::Engine;
use hyper::{Body, StatusCode};
use serde::Deserialize;
Expand Down Expand Up @@ -59,7 +61,12 @@ pub(crate) async fn handle_post_session(req: Request<Body>, ctx: &Context) -> Re
///
/// TODO: maybe notify the user about these failures?
pub(crate) async fn handle_delete_session(req: Request<Body>, ctx: &Context) -> Response {
if !matches!(ctx.config.auth.mode, AuthMode::LoginProxy | AuthMode::Opencast) {
let is_enabled = matches!(ctx.config.auth.mode,
| AuthMode::LoginProxy
| AuthMode::Opencast
| AuthMode::LoginCallback
);
if !is_enabled {
warn!("Got DELETE /~session request, but due to the authentication mode, this endpoint \
is disabled");

Expand Down Expand Up @@ -103,10 +110,9 @@ const PASSWORD_FIELD: &str = "password";

/// Handles `POST /~login` request.
pub(crate) async fn handle_post_login(req: Request<Body>, ctx: &Context) -> Response {
if ctx.config.auth.mode != AuthMode::Opencast {
warn!("Got POST /~login request, but 'auth.mode' is not 'opencast', \
so login requests have to be handled by your reverse proxy. \
Please see the documentation about auth.");
let is_enabled = matches!(ctx.config.auth.mode, AuthMode::Opencast | AuthMode::LoginCallback);
if !is_enabled {
warn!("Got POST /~login request, but due to 'auth.mode', this endpoint is disabled.");
return Response::builder().status(StatusCode::NOT_FOUND).body(Body::empty()).unwrap();
}

Expand Down Expand Up @@ -151,13 +157,36 @@ pub(crate) async fn handle_post_login(req: Request<Body>, ctx: &Context) -> Resp


// Check the login data.
match check_opencast_login(&userid, &password, &ctx.config.opencast).await {
Err(e) => {
error!("Error occured while checking Opencast login data: {e}");
internal_server_error()
let user = match ctx.config.auth.mode {
AuthMode::Opencast => {
match check_opencast_login(&userid, &password, &ctx.config.opencast).await {
Err(e) => {
error!("Error occured while checking Opencast login data: {e}");
return internal_server_error();
}
Ok(user) => user,
}
}
AuthMode::LoginCallback => {
let body = serde_json::json!({
"userid": userid,
"password": password,
});
let mut req = Request::new(body.to_string().into());
*req.method_mut() = hyper::Method::POST;
*req.uri_mut() = ctx.config.auth.callback_url.clone().unwrap();

match User::from_callback(req).await {
Err(e) => return e,
Ok(user) => user,
}
}
Ok(None) => Response::builder().status(StatusCode::FORBIDDEN).body(Body::empty()).unwrap(),
Ok(Some(user)) => create_session(user, ctx).await.unwrap_or_else(|e| e),
_ => unreachable!(),
};

match user {
None => Response::builder().status(StatusCode::FORBIDDEN).body(Body::empty()).unwrap(),
Some(user) => create_session(user, ctx).await.unwrap_or_else(|e| e),
}
}

Expand Down
139 changes: 130 additions & 9 deletions backend/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ use std::{borrow::Cow, time::Duration, collections::HashSet};

use base64::Engine;
use deadpool_postgres::Client;
use hyper::HeaderMap;
use hyper::{HeaderMap, Uri, StatusCode};
use once_cell::sync::Lazy;
use secrecy::{Secret, ExposeSecret};
use serde::{Deserialize, Deserializer, de::Error};
use tokio_postgres::Error as PgError;
use url::Url;

use crate::{config::TranslatedString, prelude::*, db::util::select};
use crate::{config::TranslatedString, prelude::*, db::util::select, http::{Response, response, Request}};


mod handlers;
Expand Down Expand Up @@ -45,6 +47,11 @@ pub(crate) struct AuthConfig {
/// send a `DELETE` request to `/~session`.
pub(crate) logout_link: Option<String>,

/// Only for `*-callback` modes: URL to HTTP API to resolve incoming request
/// to user information.
#[config(deserialize_with = AuthConfig::deserialize_callback_url)]
pub(crate) callback_url: Option<Uri>,

/// The header containing a unique and stable username of the current user.
#[config(default = "x-tobira-username")]
pub(crate) username_header: String,
Expand Down Expand Up @@ -121,6 +128,48 @@ pub(crate) struct AuthConfig {
pub(crate) pre_auth_external_links: bool,
}

impl AuthConfig {
pub(crate) fn validate(&self) -> Result<()> {
let cb_mode = matches!(self.mode, AuthMode::LoginCallback | AuthMode::AuthCallback);
if cb_mode && !self.callback_url.is_some() {
bail!(
"'auth.mode' is '{}', but 'auth.callback_url' is not specified",
self.mode.label(),
);
}
if !cb_mode && self.callback_url.is_some() {
bail!(
"'auth.mode' is '{}', but 'auth.callback_url' is specified, which makes no sense",
self.mode.label(),
);
}

Ok(())
}

fn deserialize_callback_url<'de, D>(deserializer: D) -> Result<Uri, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let url: Url = s.parse()
.map_err(|e| <D::Error>::custom(format!("invalid URL: {e}")))?;
if url.query().is_some() || url.fragment().is_some() {
return Err(
<D::Error>::custom("'auth.callback_url' must not contain a query or fragment part"),
);
}

Uri::builder()
.scheme(url.scheme())
.authority(url.authority())
.path_and_query(url.path())
.build()
.unwrap()
.pipe(Ok)
}
}

/// Authentification and authorization
#[derive(Debug, Clone, confique::Config)]
pub(crate) struct LoginPageConfig {
Expand All @@ -141,6 +190,8 @@ pub(crate) enum AuthMode {
None,
FullAuthProxy,
LoginProxy,
AuthCallback,
LoginCallback,
Opencast,
}

Expand All @@ -152,6 +203,8 @@ impl AuthMode {
AuthMode::None => "none",
AuthMode::FullAuthProxy => "full-auth-proxy",
AuthMode::LoginProxy => "login-proxy",
AuthMode::AuthCallback => "auth-callback",
AuthMode::LoginCallback => "login-callback",
AuthMode::Opencast => "opencast",
}
}
Expand Down Expand Up @@ -180,7 +233,7 @@ impl AuthContext {
headers: &HeaderMap,
auth_config: &AuthConfig,
db: &Client,
) -> Result<Self, PgError> {
) -> Result<Self, Response> {

if let Some(given_key) = headers.get("x-tobira-trusted-external-key") {
if let Some(trusted_key) = &auth_config.trusted_external_key {
Expand Down Expand Up @@ -220,11 +273,14 @@ impl User {
headers: &HeaderMap,
auth_config: &AuthConfig,
db: &Client,
) -> Result<Option<Self>, PgError> {
) -> Result<Option<Self>, Response> {
match auth_config.mode {
AuthMode::None => Ok(None),
AuthMode::FullAuthProxy => Ok(Self::from_auth_headers(headers, auth_config).into()),
AuthMode::LoginProxy | AuthMode::Opencast => {
AuthMode::AuthCallback => {
Self::from_callback_with_headers(headers, auth_config).await
}
AuthMode::LoginProxy | AuthMode::Opencast | AuthMode::LoginCallback => {
Self::from_session(headers, db, auth_config.session_duration)
.await
.map(Into::into)
Expand Down Expand Up @@ -268,7 +324,7 @@ impl User {
headers: &HeaderMap,
db: &Client,
session_duration: Duration,
) -> Result<Option<Self>, PgError> {
) -> Result<Option<Self>, Response> {
// Try to get a session ID from the cookie.
let session_id = match SessionId::from_headers(headers) {
None => return Ok(None),
Expand All @@ -282,9 +338,13 @@ impl User {
where id = $1 \
and extract(epoch from now() - created) < $2::double precision"
);
let row = match db.query_opt(&query, &[&session_id, &session_duration.as_secs_f64()]).await? {
None => return Ok(None),
Some(row) => row,
let row = match db.query_opt(&query, &[&session_id, &session_duration.as_secs_f64()]).await {
Ok(None) => return Ok(None),
Ok(Some(row)) => row,
Err(e) => {
error!("DB error when checking user session: {}", e);
return Err(response::internal_server_error());
}
};

Ok(Some(Self {
Expand All @@ -295,6 +355,67 @@ impl User {
}))
}

pub(crate) async fn from_callback_with_headers(
headers: &HeaderMap,
auth_config: &AuthConfig,
) -> Result<Option<Self>, Response> {
let mut req = Request::new(hyper::Body::empty());
*req.headers_mut() = headers.clone();
*req.uri_mut() = auth_config.callback_url.clone().unwrap();

Self::from_callback(req).await
}

pub(crate) async fn from_callback(req: Request) -> Result<Option<Self>, Response> {
// Send request and download response.
// TOOD: Only create client once!
let client = hyper::Client::new();
let response = client.request(req).await.map_err(|e| {
// TODO: maybe limit how quickly that can be logged?
error!("Error contacting auth callback: {e}");
response::bad_gateway()
})?;
let (parts, body) = response.into_parts();
let body = hyper::body::to_bytes(body).await.map_err(|e| {
error!("Error downloading body from auth callback: {e}");
response::bad_gateway()
})?;


if parts.status != StatusCode::OK {
error!("Auth callback replied with {} (which is unexpected)", parts.status);
return Err(response::bad_gateway())
}

#[derive(Deserialize)]
#[serde(tag = "outcome", rename_all = "kebab-case")]
enum CallbackResponse {
// Duplicating `User` fields here as this defines a public API, that
// has to stay stable.
#[serde(rename_all = "camelCase")]
User {
username: String,
display_name: String,
email: Option<String>,
roles: HashSet<String>,
},
NoUser,
// TODO: maybe add "redirect"?
}

// Note: this will also fail if `body` is not valid UTF-8.
match serde_json::from_slice::<CallbackResponse>(&body) {
Ok(CallbackResponse::User { username, display_name, email, roles }) => {
Ok(Some(Self { username, display_name, email, roles }))
},
Ok(CallbackResponse::NoUser) => Ok(None),
Err(e) => {
error!("Could not deserialize body from auth callback: {e}");
Err(response::bad_gateway())
},
}
}

/// Creates a new session for this user and persists it in the database.
/// Should only be called if the auth mode is `LoginProxy`.
pub(crate) async fn persist_new_session(&self, db: &Client) -> Result<SessionId, PgError> {
Expand Down
1 change: 1 addition & 0 deletions backend/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ impl Config {
/// illegal or conflicting values.
pub(crate) fn validate(&self) -> Result<()> {
debug!("Validating configuration...");
self.auth.validate()?;
self.opencast.validate()?;
self.db.validate()?;
self.theme.validate()?;
Expand Down
8 changes: 1 addition & 7 deletions backend/src/http/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,7 @@ async fn handle_api(req: Request<Body>, ctx: &Context) -> Result<Response, Respo
let mut connection = db::get_conn_or_service_unavailable(&ctx.db_pool).await?;

// Get auth session
let auth = match AuthContext::new(&parts.headers, &ctx.config.auth, &connection).await {
Ok(auth) => auth,
Err(e) => {
error!("DB error when checking user session: {}", e);
return Err(response::internal_server_error());
},
};
let auth = AuthContext::new(&parts.headers, &ctx.config.auth, &connection).await?;

let tx = match connection.transaction().await {
Ok(tx) => tx,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/http/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ pub(crate) fn not_found() -> Response {
.unwrap()
}

pub(crate) fn bad_gateway() -> Response {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body("Bad gateway: broken auth callback".into())
.unwrap()
}
4 changes: 4 additions & 0 deletions docs/docs/setup/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@
# send a `DELETE` request to `/~session`.
#logout_link =

# Only for `*-callback` modes: URL to HTTP API to resolve incoming request
# to user information.
#callback_url =

# The header containing a unique and stable username of the current user.
#
# Default value: "x-tobira-username"
Expand Down

0 comments on commit 79a0834

Please sign in to comment.