1
0
mirror of https://github.com/CCC-MF/bwhc-kafka-rest-proxy.git synced 2025-04-19 19:16:51 +00:00

feat: require token as http basic auth

This commit is contained in:
Paul-Christian Volkmer 2024-03-08 13:24:59 +01:00
parent 92c2286174
commit 2b77a6b064
4 changed files with 121 additions and 13 deletions

View File

@ -36,6 +36,12 @@ features = [ "cmake-build", "libz-static" ]
version = "1.7" version = "1.7"
features = [ "v4" ] features = [ "v4" ]
[dependencies.base64]
version = "0.22"
[dependencies.bcrypt]
version = "0.15"
[dependencies.bwhc-dto] [dependencies.bwhc-dto]
git = "https://github.com/ccc-mf/bwhc-dto-rs" git = "https://github.com/ccc-mf/bwhc-dto-rs"
branch = "master" branch = "master"

View File

@ -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_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_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 ## 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 Zum Löschen von Patienteninformationen wird intern ein MTB-File mit Consent-Status `REJECTED` erzeugt und weiter
geleitet. Hier ist kein Request-Body erforderlich. 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 Bei Erfolg enthält die Antwort enthält im HTTP-Header `x-request-id` die Anfrage-ID, die auch im ETL-Prozessor verwendet
wird. 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.

67
src/auth.rs Normal file
View File

@ -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::<Vec<_>>();
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::<Vec<_>>();
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
))
}
}

View File

@ -4,7 +4,9 @@ use std::time::Duration;
use axum::{Extension, Json, Router}; use axum::{Extension, Json, Router};
use axum::body::Body; use axum::body::Body;
use axum::extract::Path; 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::response::Response;
use axum::routing::{delete, post}; use axum::routing::{delete, post};
use bwhc_dto::MtbFile; use bwhc_dto::MtbFile;
@ -14,10 +16,7 @@ use rdkafka::producer::{FutureProducer, FutureRecord};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] mod auth;
struct KafkaConfig {
dst_topic: String,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct RecordKey { struct RecordKey {
@ -29,8 +28,9 @@ struct RecordKey {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let _ = bcrypt_hashed_token();
let boostrap_servers = env::var("KAFKA_BOOTSTRAP_SERVERS").unwrap_or("kafka:9094".into()); 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() let producer: FutureProducer = ClientConfig::new()
.set("bootstrap.servers", boostrap_servers.as_str()) .set("bootstrap.servers", boostrap_servers.as_str())
@ -42,20 +42,31 @@ async fn main() {
.route("/mtbfile", post(handle_post)) .route("/mtbfile", post(handle_post))
.route("/mtbfile/:patient_id", delete(handle_delete)) .route("/mtbfile/:patient_id", delete(handle_delete))
.layer(Extension(producer)) .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(); let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
async fn check_basic_auth(request: Request<Body>, 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( async fn handle_delete(
Path(patient_id): Path<String>, Path(patient_id): Path<String>,
Extension(producer): Extension<FutureProducer>, Extension(producer): Extension<FutureProducer>,
Extension(kafka_config): Extension<KafkaConfig>,
) -> Response { ) -> Response {
let delete_mtb_file = MtbFile::new_with_consent_rejected(&patient_id); 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), Ok(request_id) => success_response(&request_id),
_ => error_response(), _ => error_response(),
} }
@ -63,10 +74,9 @@ async fn handle_delete(
async fn handle_post( async fn handle_post(
Extension(producer): Extension<FutureProducer>, Extension(producer): Extension<FutureProducer>,
Extension(kafka_config): Extension<KafkaConfig>,
Json(mtb_file): Json<MtbFile>, Json(mtb_file): Json<MtbFile>,
) -> Response { ) -> 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), Ok(request_id) => success_response(&request_id),
_ => error_response(), _ => error_response(),
} }
@ -128,3 +138,13 @@ fn error_response() -> Response {
.body(Body::empty()) .body(Body::empty())
.expect("response built") .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.")
})
}