Skip to content

Commit

Permalink
Ch10: Extract credentials from the header
Browse files Browse the repository at this point in the history
  • Loading branch information
tahaafzal5 committed Aug 11, 2024
1 parent 3fbf56d commit 9796239
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 5 deletions.
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 @@ -29,6 +29,7 @@ reqwest = { version = "0.11", features = ["json"] }
rand = { version = "0.8", features = ["std_rng"] }
thiserror = "1"
anyhow = "1"
base64 = "0.21"

# We need the optional `derive` feature to use `serde`'s procedural macros:
# `#[derive(Serialize)]` and `#[derive(Deserialize)]`.
Expand Down
17 changes: 17 additions & 0 deletions Notes/Ch6-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@
- [Something They Have](#something-they-have)
- [Something They Are](#something-they-are)
- [Multi-factor Authentication](#multi-factor-authentication)
- [Password-based Authentication](#password-based-authentication)
- [Extracting Credentials](#extracting-credentials)

# Ch 6 - Reject Invalid Subscribers #1
* Our input validation for `/POST` is limited: we just ensure that both the `name` and the `email` fields are provided, even if they are empty.
Expand Down Expand Up @@ -973,3 +975,18 @@ curl "https://api.postmarkapp.com/email" \
### Multi-factor Authentication
* We combine at least 2 forms of authentication to overcome most of the weaknesses individual techniques have.

## Password-based Authentication
* We can use the ‘BasicAuthentication Scheme, a standard defined by the Internet Engineering Task Force (IETF) in RFC 2617.
* The API must look for the Authorization header in the incoming request, structured as `Authorization: Basic <encoded credentials>` where `<encoded credentials>` is the base64-encoding of `{username}:{password}`.
* According to the specification, we need to partition our API into protection spaces or **realms** - resources within the same realm are protected using the same authentication scheme and set of credentials.
* We only have a single endpoint to protect - `POST /newsletters` so we'll have a single realm, named `publish`.
* The API must reject all requests missing the header or using invalid credentials - the response must use the `401 Unauthorized` and include a special header, `WWW-Authenticate`,containing a **challenge**.
* The challenge is a string explaining to the API caller what type of authentication scheme we expect to see for the relevant realm.
* In our case, using basic authentication, the challenge should be:
```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="publish"
```

#### Extracting Credentials
* We extract the encoded credentials from the `Authorization` header in the request, make sure it is `Basic`, decode them, make sure they are valid UTF8, and that both username and password are provided.
78 changes: 74 additions & 4 deletions src/routes/newsletter.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
use crate::{domain::SubscriberEmail, email_client::EmailClient, routes::error_chain_fmt};
use actix_web::{web, HttpResponse, ResponseError};
use actix_web::{
http::header::HeaderMap,
web::{self},
HttpRequest, HttpResponse, ResponseError,
};
use anyhow::Context;
use reqwest::StatusCode;
use base64::Engine;
use reqwest::{
header::{self, HeaderValue},
StatusCode,
};
use secrecy::Secret;
use sqlx::PgPool;
use std::fmt::Debug;

Expand All @@ -25,6 +34,8 @@ struct ConfirmedSubscriber {
pub enum PublishError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
}

impl Debug for PublishError {
Expand All @@ -34,18 +45,77 @@ impl Debug for PublishError {
}

impl ResponseError for PublishError {
fn status_code(&self) -> reqwest::StatusCode {
// `status_code` is invoked by the default `error_response` implementation.
// We are providing a bespoke `error_response` implementation
// therefore there is no need to maintain a `status_code` implementation anymore.
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
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(header::WWW_AUTHENTICATE, header_value);

response
}
}
}
}

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

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
// 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_segment = header_value
.strip_prefix("Basic ")
.context("The authorization scheme was not 'Basic'")?;

let decoded_bytes = base64::engine::general_purpose::STANDARD
.decode(base64encoded_segment)
.context("Failed to base64-encode 'Basic' credentials")?;

let decoded_credentials = String::from_utf8(decoded_bytes)
.context("The decoded credential string is not 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: Secret::new(password),
})
}

pub async fn publish_newsletter(
connection_pool: web::Data<PgPool>,
body: web::Json<BodyData>,
email_client: web::Data<EmailClient>,
request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
let _credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?;

let confirmed_subscribers = get_confirmed_subscribers(&connection_pool).await?;

for subscriber in confirmed_subscribers {
Expand Down
29 changes: 28 additions & 1 deletion tests/api/newsletter.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use wiremock::{matchers::method, matchers::path, Mock, ResponseTemplate};
use zero2prod::email_client::email_route;
use zero2prod::{email_client::email_route, routes::publish_newsletter_route};

use crate::helpers::{spawn_app, ConfirmationLinks, TestApp};

Expand Down Expand Up @@ -95,6 +95,33 @@ async fn newsletters_returns_400_for_invalid_data() {
}
}

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

// Act
let response = reqwest::Client::new()
.post(&format!("{}{}", app.address, publish_newsletter_route()))
.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 publish newsletter request");

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

/// Use the public API of the application under test to create
/// and unconfirmed subscriber
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
Expand Down

0 comments on commit 9796239

Please sign in to comment.