From 2b77a6b0647ed38fac11b72fa01aba0b5886d2e9 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 8 Mar 2024 13:24:59 +0100 Subject: [PATCH] feat: require token as http basic auth --- Cargo.toml | 6 +++++ README.md | 19 +++++++++++++-- src/auth.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 42 ++++++++++++++++++++++++--------- 4 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 src/auth.rs diff --git a/Cargo.toml b/Cargo.toml index 3d15138..9f07025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,12 @@ features = [ "cmake-build", "libz-static" ] version = "1.7" features = [ "v4" ] +[dependencies.base64] +version = "0.22" + +[dependencies.bcrypt] +version = "0.15" + [dependencies.bwhc-dto] git = "https://github.com/ccc-mf/bwhc-dto-rs" branch = "master" \ No newline at end of file diff --git a/README.md b/README.md index a0d8eae..4a04268 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Die Anwendung lässt sich mit Umgebungsvariablen konfigurieren. * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste * `APP_KAFKA_TOPIC`: Zu verwendendes Topic zum Warten auf neue Anfragen. Standardwert: `etl-processor_input` +* `APP_SECURITY_TOKEN`: Verpflichtende Angabe es Tokens als *bcrypt*-Hash ## HTTP-Requests @@ -30,5 +31,19 @@ Die folgenden Endpunkte sind verfügbar: Zum Löschen von Patienteninformationen wird intern ein MTB-File mit Consent-Status `REJECTED` erzeugt und weiter geleitet. Hier ist kein Request-Body erforderlich. -Bei Erfolg enthält die Antwort enthält im HTTP-Header `X-Request-Id` die Anfrage-ID, die auch im ETL-Prozessor verwendet -wird. \ No newline at end of file +Bei Erfolg enthält die Antwort enthält im HTTP-Header `x-request-id` die Anfrage-ID, die auch im ETL-Prozessor verwendet +wird. + +### Authentifizierung + +Requests müssen einen HTTP-Header `authorization` für HTTP-Basic enthalten. Hier ist es erforderlich, dass der +Benutzername `token` gewählt wird. + +Es ist hierzu erforderlich, die erforderliche Umgebungsvariable `APP_SECURITY_TOKEN` zu setzen. Dies kann z.B. mit +*htpasswd* erzeugt werden: + +``` +htpasswd -Bn token +``` + +Der hintere Teil (hinter `token:`) entspricht dem *bcrypt*-Hash des Tokens. \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6a254db --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,67 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD; + +pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool { + let split = auth_header.split(' ').collect::>(); + if split.len() == 2 && split.first().map(|first| first.to_lowercase()) == Some("basic".into()) { + if let Ok(auth) = BASE64_STANDARD.decode(split.last().unwrap_or(&"")) { + if let Ok(auth) = String::from_utf8(auth) { + let split = auth.split(':').collect::>(); + if split.len() == 2 && split.first() == Some(&"token") { + match split.last() { + None => {} + Some(&token) => { + if let Ok(true) = bcrypt::verify(token, expected_token) { + return true; + } + } + } + } + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use crate::auth::check_basic_auth; + + // plain text value 'very-secret' + const EXPECTED_TOKEN: &str = "$2y$05$LIIFF4Rbi3iRVA4UIqxzPeTJ0NOn/cV2hDnSKFftAMzbEZRa42xSG"; + + #[test] + fn should_reject_non_basic_header_content() { + assert_eq!(check_basic_auth("token 123456789", EXPECTED_TOKEN), false) + } + + #[test] + fn should_reject_invalid_basic_auth() { + assert_eq!(check_basic_auth("Basic 123456789", EXPECTED_TOKEN), false) + } + + #[test] + fn should_reject_basic_auth_without_token_username() { + assert_eq!( + check_basic_auth("Basic dXNlcjoxMjM0NTY3ODk=", EXPECTED_TOKEN), + false + ) + } + + #[test] + fn should_reject_basic_auth_without_wrong_token() { + assert_eq!( + check_basic_auth("Basic dG9rZW46MTIzNDU2Nzg5", EXPECTED_TOKEN), + false + ) + } + + #[test] + fn should_accept_basic_auth_without_correct_token() { + assert!(check_basic_auth( + "Basic dG9rZW46dmVyeS1zZWNyZXQ=", + EXPECTED_TOKEN + )) + } +} diff --git a/src/main.rs b/src/main.rs index a27cad5..d2c0104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,9 @@ use std::time::Duration; use axum::{Extension, Json, Router}; use axum::body::Body; use axum::extract::Path; -use axum::http::StatusCode; +use axum::http::{Request, StatusCode}; +use axum::http::header::AUTHORIZATION; +use axum::middleware::{from_fn, Next}; use axum::response::Response; use axum::routing::{delete, post}; use bwhc_dto::MtbFile; @@ -14,10 +16,7 @@ use rdkafka::producer::{FutureProducer, FutureRecord}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Clone)] -struct KafkaConfig { - dst_topic: String, -} +mod auth; #[derive(Serialize, Deserialize)] struct RecordKey { @@ -29,8 +28,9 @@ struct RecordKey { #[tokio::main] async fn main() { + let _ = bcrypt_hashed_token(); + let boostrap_servers = env::var("KAFKA_BOOTSTRAP_SERVERS").unwrap_or("kafka:9094".into()); - let dst_topic = env::var("APP_KAFKA_TOPIC").unwrap_or("etl-processor_input".into()); let producer: FutureProducer = ClientConfig::new() .set("bootstrap.servers", boostrap_servers.as_str()) @@ -42,20 +42,31 @@ async fn main() { .route("/mtbfile", post(handle_post)) .route("/mtbfile/:patient_id", delete(handle_delete)) .layer(Extension(producer)) - .layer(Extension(KafkaConfig { dst_topic })); + .layer(from_fn(check_basic_auth)); let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); axum::serve(listener, app).await.unwrap(); } +async fn check_basic_auth(request: Request, next: Next) -> Response { + if let Some(Ok(auth_header)) = request.headers().get(AUTHORIZATION).map(|x| x.to_str()) { + if auth::check_basic_auth(auth_header, &bcrypt_hashed_token()) { + return next.run(request).await; + } + } + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::empty()) + .expect("response built") +} + async fn handle_delete( Path(patient_id): Path, Extension(producer): Extension, - Extension(kafka_config): Extension, ) -> Response { let delete_mtb_file = MtbFile::new_with_consent_rejected(&patient_id); - match send_mtb_file(producer, &kafka_config.dst_topic, delete_mtb_file).await { + match send_mtb_file(producer, &dst_topic(), delete_mtb_file).await { Ok(request_id) => success_response(&request_id), _ => error_response(), } @@ -63,10 +74,9 @@ async fn handle_delete( async fn handle_post( Extension(producer): Extension, - Extension(kafka_config): Extension, Json(mtb_file): Json, ) -> Response { - match send_mtb_file(producer, &kafka_config.dst_topic, mtb_file).await { + match send_mtb_file(producer, &dst_topic(), mtb_file).await { Ok(request_id) => success_response(&request_id), _ => error_response(), } @@ -128,3 +138,13 @@ fn error_response() -> Response { .body(Body::empty()) .expect("response built") } + +fn dst_topic() -> String { + env::var("APP_KAFKA_TOPIC").unwrap_or("etl-processor_input".into()) +} + +fn bcrypt_hashed_token() -> String { + env::var("APP_SECURITY_TOKEN").unwrap_or_else(|_| { + panic!("Missing configuration 'APP_SECURITY_TOKEN'. Provide bcrypt hashed token value.") + }) +}