diff --git a/Cargo.lock b/Cargo.lock index 74e0f2f..2d2fa89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,9 +701,9 @@ checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" [[package]] name = "futures" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +checksum = "1adc00f486adfc9ce99f77d717836f0c5aa84965eb0b4f051f4e83f7cab53f8b" dependencies = [ "futures-channel", "futures-core", @@ -716,9 +716,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +checksum = "74ed2411805f6e4e3d9bc904c95d5d423b89b3b25dc0250aa74729de20629ff9" dependencies = [ "futures-core", "futures-sink", @@ -726,15 +726,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" +checksum = "af51b1b4a7fdff033703db39de8802c673eb91855f2e0d47dcf3bf2c0ef01f99" [[package]] name = "futures-executor" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "badaa6a909fac9e7236d0620a2f57f7664640c56575b71a7552fbd68deafab79" +checksum = "4d0d535a57b87e1ae31437b892713aee90cd2d7b0ee48727cd11fc72ef54761c" dependencies = [ "futures-core", "futures-task", @@ -743,9 +743,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" +checksum = "0b0e06c393068f3a6ef246c75cdca793d6a46347e75286933e5e75fd2fd11582" [[package]] name = "futures-lite" @@ -764,9 +764,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +checksum = "c54913bae956fb8df7f4dc6fc90362aa72e69148e3f39041fbe8742d21e0ac57" dependencies = [ "autocfg", "proc-macro-hack", @@ -777,15 +777,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" +checksum = "c0f30aaa67363d119812743aa5f33c201a7a66329f97d1a887022971feea4b53" [[package]] name = "futures-task" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" +checksum = "bbe54a98670017f3be909561f6ad13e810d9a51f3f061b902062ca3da80799f2" [[package]] name = "futures-timer" @@ -795,9 +795,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +checksum = "67eb846bfd58e44a8481a00049e82c43e0ccb5d61f8dc071057cb19249dd4d78" dependencies = [ "autocfg", "futures-channel", @@ -2676,6 +2676,7 @@ dependencies = [ "actix-rt", "actix-web", "anyhow", + "base64", "chrono", "claim", "config", diff --git a/Cargo.toml b/Cargo.toml index 209bb81..59fa5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ rand = { version = "0.8", features=["std_rng"] } sha2 = { version = "0.9" } tracing-actix-web = "0.4.0-beta.8" anyhow = "1.0.40" +base64 = "0.13.0" [dev-dependencies] once_cell = "1.7.2" diff --git a/src/routes/newsletters.rs b/src/routes/newsletters.rs index 2a3be0c..56bffa9 100644 --- a/src/routes/newsletters.rs +++ b/src/routes/newsletters.rs @@ -1,7 +1,7 @@ use crate::domain::SubscriberEmail; use crate::email_client::EmailClient; use crate::routes::error_chain_fmt; -use actix_web::http::StatusCode; +use actix_web::http::{HeaderMap, HeaderValue, StatusCode}; use actix_web::{web, HttpResponse, ResponseError}; use anyhow::Context; use sqlx::PgPool; @@ -20,6 +20,8 @@ pub struct Content { #[derive(thiserror::Error)] pub enum PublishError { + #[error("Authentication failed.")] + AuthError(#[source] anyhow::Error), #[error(transparent)] UnexpectedError(#[from] anyhow::Error), } @@ -31,18 +33,63 @@ impl std::fmt::Debug for PublishError { } impl ResponseError for PublishError { - fn status_code(&self) -> StatusCode { + fn error_response(&self) -> HttpResponse { match self { - PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, + PublishError::UnexpectedError(_) => { + HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR) + } + PublishError::AuthError(_) => { + let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED); + let header_value = HeaderValue::from_str(r#"Basic realm="publish""#).unwrap(); + response + .headers_mut() + .insert(actix_web::http::header::WWW_AUTHENTICATE, header_value); + response + } } } } +struct Credentials { + username: String, + password: String, +} + +fn basic_authentication(headers: &HeaderMap) -> Result { + // The header value, if present, must be a valid UTF8 string + let header_value = headers + .get("Authorization") + .context("The 'Authorization' header was missing")? + .to_str() + .context("The 'Authorization' header was not a valid UTF8 string.")?; + let base64encoded_credentials = header_value + .strip_prefix("Basic ") + .context("The authorization scheme was not 'Basic'.")?; + let decoded_credentials = base64::decode_config(base64encoded_credentials, base64::STANDARD) + .context("Failed to base64-decode 'Basic' credentials.")?; + let decoded_credentials = String::from_utf8(decoded_credentials) + .context("The decoded credential string is valid UTF8.")?; + + let mut credentials = decoded_credentials.splitn(2, ':'); + let username = credentials + .next() + .ok_or_else(|| anyhow::anyhow!("A username must be provided in 'Basic' auth."))? + .to_string(); + let password = credentials + .next() + .ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))? + .to_string(); + + Ok(Credentials { username, password }) +} + pub async fn publish_newsletter( body: web::Json, pool: web::Data, email_client: web::Data, + request: web::HttpRequest, ) -> Result { + let credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?; let subscribers = get_confirmed_subscribers(&pool).await?; for subscriber in subscribers { match subscriber { diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index bf3de0b..0f03d29 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -134,14 +134,21 @@ async fn requests_missing_authorization_are_rejected() { let response = reqwest::Client::new() .post(&format!("{}/newsletters", &app.address)) - // The body should not matter - authentication must be performed - // BEFORE any further processing takes place. - .json(&serde_json::json!({})) + .json(&serde_json::json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as HTML

", + } + })) .send() .await .expect("Failed to execute request."); // Assert assert_eq!(401, response.status().as_u16()); - assert_eq!(r#"Basic realm="publish""#, response.headers()["WWW-Authenticate"]); -} \ No newline at end of file + assert_eq!( + r#"Basic realm="publish""#, + response.headers()["WWW-Authenticate"] + ); +}