Skip to content

Commit

Permalink
feat: basic auth without validation
Browse files Browse the repository at this point in the history
  • Loading branch information
rejdeboer committed Feb 22, 2024
1 parent 57fb15c commit 56a25fb
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target
terraform.tfvars
.terraform
.idea
1 change: 1 addition & 0 deletions 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "rus
rand = { version = "0.8", features=["std_rng"] }
thiserror = "1"
anyhow = "1"
base64 = "0.13"

[dependencies.sqlx]
version = "0.6.2"
Expand Down
59 changes: 56 additions & 3 deletions src/routes/newsletters.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
use crate::domain::SubscriberEmail;
use crate::email_client::EmailClient;
use crate::routes::error_chain_fmt;
use actix_web::http::header::HeaderMap;
use actix_web::http::StatusCode;
use actix_web::{web, HttpResponse, ResponseError};
use actix_web::{web, HttpRequest, HttpResponse, ResponseError};
use anyhow::Context;
use reqwest::header::{self, HeaderValue};
use secrecy::Secret;
use sqlx::PgPool;

#[derive(serde::Deserialize)]
Expand All @@ -22,8 +25,15 @@ struct ConfirmedSubscriber {
email: SubscriberEmail,
}

struct Credentials {
username: String,
password: Secret<String>,
}

#[derive(thiserror::Error)]
pub enum PublishError {
#[error("Authentication failed.")]
AuthError(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
Expand All @@ -35,9 +45,19 @@ 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::AuthError(_) => {
let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED);
let header_value = HeaderValue::from_str(r#"Basic realm="publish""#).unwrap();
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, header_value);
response
}
PublishError::UnexpectedError(_) => {
HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
Expand All @@ -46,7 +66,9 @@ pub async fn publish_newsletter(
body: web::Json<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
let _credentials = basic_authenticaton(request.headers()).map_err(PublishError::AuthError)?;
let subscribers = get_confirmed_subscribers(&pool).await?;
for subscriber in subscribers {
email_client
Expand Down Expand Up @@ -97,3 +119,34 @@ async fn get_confirmed_subscribers(

Ok(confirmed_subscribers)
}

fn basic_authenticaton(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
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_segment = header_value
.strip_prefix("Basic ")
.context("The authorization scheme was not 'Basic'.")?;
let decoded_bytes = base64::decode_config(base64encoded_segment, base64::STANDARD)
.context("Failed to decode base64-decode 'Basic' credentials.")?;
let decoded_credentials = String::from_utf8(decoded_bytes)
.context("The decoded credential string is not valid UTF8.")?;

// Split into 2 segments, using ':' as delimitator
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: Secret::new(password),
})
}
1 change: 1 addition & 0 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ impl TestApp {
pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
.basic_auth(Uuid::new_v4().to_string(), Some(Uuid::new_v4().to_string()))
.json(&body)
.send()
.await
Expand Down
24 changes: 24 additions & 0 deletions tests/api/newsletter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ async fn newsletter_returns_400_for_invalid_data() {
}
}

#[tokio::test]
async fn requests_missing_authorization_are_rejected() {
let app = spawn_app().await;

let response = reqwest::Client::new()
.put(&format!("{}/newsletters", &app.address))
.json(&serde_json::json!({
"title": "Newsletter title",
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as HTML</p>",
}
}))
.send()
.await
.expect("Failed to execute request.");

assert_eq!(301, response.status().as_u16());
assert_eq!(
r#"Basic realm="publish""#,
response.headers()["WWW-Authenticate"]
);
}

async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
let body = "name=rick%20de%20boer&email=rick.deboer%40live.nl";

Expand Down

0 comments on commit 56a25fb

Please sign in to comment.