Skip to content
This repository has been archived by the owner on Aug 21, 2024. It is now read-only.

Commit

Permalink
Switch to session-based auth for newsletters endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
0rzech committed Apr 12, 2024
1 parent c91defe commit 82c70f4
Show file tree
Hide file tree
Showing 5 changed files with 41 additions and 178 deletions.
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ askama = { version = "0.12.1", features = ["with-axum"], default-features = fals
askama_axum = { version = "0.4.0", default-features = false }
axum = "0.7.4"
axum-messages = "0.6.0"
base64 = "0.22.0"
config = "0.14.0"
once_cell = "1.19.0"
rand = "0.8.5"
Expand Down
96 changes: 13 additions & 83 deletions src/routes/newsletters.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,36 @@
use crate::{
app_state::AppState,
authentication::password::{validate_credentials, AuthError, Credentials},
authentication::{extract::SessionUserId, middleware::AuthorizedSessionLayer},
domain::{SubscriberEmail, SubscriptionStatus},
utils::{e500, HttpError},
};
use anyhow::Context;
use axum::{
extract::State,
http::{header::WWW_AUTHENTICATE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use base64::Engine;
use secrecy::Secret;
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use sqlx::PgPool;

pub fn router() -> Router<AppState> {
Router::new().route("/newsletters", post(publish_newsletter))
Router::new()
.route("/newsletters", post(publish_newsletter))
.layer(AuthorizedSessionLayer)
}

#[tracing::instrument(
name = "Publish newsletter",
skip(headers, app_state, body),
skip(app_state, user_id, body),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
async fn publish_newsletter(
headers: HeaderMap,
State(app_state): State<AppState>,
SessionUserId(user_id): SessionUserId,
Json(body): Json<BodyData>,
) -> Result<(), PublishError> {
let credentials = basic_authentication(&headers).map_err(PublishError::AuthError)?;
tracing::Span::current().record("username", &tracing::field::display(&credentials.username));

let user_id = validate_credentials(&app_state.db_pool, credentials)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()),
AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()),
})?;
) -> Result<(), HttpError<anyhow::Error>> {
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));

for subscriber in get_confirmed_subscribers(&app_state.db_pool).await? {
for subscriber in get_confirmed_subscribers(&app_state.db_pool)
.await
.map_err(e500)?
{
match subscriber {
Ok(subscriber) => app_state
.email_client
Expand All @@ -66,41 +55,6 @@ async fn publish_newsletter(
Ok(())
}

#[tracing::instrument(name = "Extract basic auth credentials", skip(headers))]
fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
let base64encoded_segment = headers
.get("Authorization")
.context("The 'Authorization' header was missing")?
.to_str()
.context("The 'Authorization' header was not a valid UTF-8 string")?
.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-decode 'Basic' credentials")?;

let decoded_credentials = String::from_utf8(decoded_bytes)
.context("The decoded credential string is not a valid UTF-8")?;

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 username must be provided in 'Basic' auth"))?
.to_string();

Ok(Credentials {
username,
password: Secret::new(password),
})
}

#[tracing::instrument(name = "Get confirmed subscribers", skip(db_pool))]
async fn get_confirmed_subscribers(
db_pool: &PgPool,
Expand Down Expand Up @@ -142,27 +96,3 @@ struct Content {
struct ConfirmedSubscriber {
email: SubscriberEmail,
}

#[derive(Debug, thiserror::Error)]
enum PublishError {
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}

impl IntoResponse for PublishError {
fn into_response(self) -> Response {
tracing::error!("{:#?}", self);

match self {
Self::AuthError(_) => {
let mut headers = HeaderMap::new();
let header_value = HeaderValue::from_static(r#"Basic realm="publish""#);
headers.insert(WWW_AUTHENTICATE, header_value);
(StatusCode::UNAUTHORIZED, headers).into_response()
}
Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
24 changes: 0 additions & 24 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,31 +109,7 @@ impl TestApp {
.expect(Self::FAILED_TO_EXECUTE_REQUEST)
}

pub async fn post_newsletters_with_credentials(
&self,
body: &serde_json::Value,
username: &str,
password: &str,
) -> reqwest::Response {
self.client
.post(self.url("/newsletters"))
.basic_auth(username, Some(password))
.json(body)
.send()
.await
.expect(Self::FAILED_TO_EXECUTE_REQUEST)
}

pub async fn post_newsletters(&self, body: &serde_json::Value) -> reqwest::Response {
self.post_newsletters_with_credentials(
body,
&self.test_user.username,
&self.test_user.password,
)
.await
}

pub async fn post_newsletters_no_auth(&self, body: &serde_json::Value) -> reqwest::Response {
self.client
.post(self.url("/newsletters"))
.json(body)
Expand Down
97 changes: 28 additions & 69 deletions tests/api/newsletters.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use crate::helpers::{ConfirmationLinks, TestApp};
use crate::helpers::{assert_redirect_to, ConfirmationLinks, TestApp};
use serde_json::json;
use uuid::Uuid;
use wiremock::{
matchers::{any, method, path},
Mock, ResponseTemplate,
Expand All @@ -26,6 +25,14 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() {
.mount(&app.email_server)
.await;

let response = app
.post_login(&json!({
"username": &app.test_user.username,
"password": &app.test_user.password,
}))
.await;
assert_redirect_to(&response, "/admin/dashboard");

// when
let response = app.post_newsletters(&newsletter_request_body).await;

Expand All @@ -52,6 +59,14 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
.mount(&app.email_server)
.await;

let response = app
.post_login(&json!({
"username": &app.test_user.username,
"password": &app.test_user.password,
}))
.await;
assert_redirect_to(&response, "/admin/dashboard");

// when
let response = app.post_newsletters(&newsletter_request_body).await;

Expand Down Expand Up @@ -81,6 +96,14 @@ async fn newsletters_returns_400_for_invalid_data() {
),
];

let response = app
.post_login(&json!({
"username": &app.test_user.username,
"password": &app.test_user.password,
}))
.await;
assert_redirect_to(&response, "/admin/dashboard");

for (body, error_message) in test_cases {
// when
let response = app.post_newsletters(&body).await;
Expand All @@ -96,30 +119,7 @@ async fn newsletters_returns_400_for_invalid_data() {
}

#[tokio::test]
async fn requests_missing_authorization_are_rejected() {
// given
let app = TestApp::spawn().await;
let newsletter_request_body = json!({
"title": "Newsletter Title",
"content": {
"text": "Newsletter body as plain text.",
"html": "<p>Newsletter body as html.</p>",
}
});

// when
let response = app.post_newsletters_no_auth(&newsletter_request_body).await;

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

#[tokio::test]
async fn non_existing_user_is_rejected() {
async fn requests_from_anonymous_users_are_redirected_to_login() {
// given
let app = TestApp::spawn().await;
let newsletter_request_body = json!({
Expand All @@ -129,53 +129,12 @@ async fn non_existing_user_is_rejected() {
"html": "<p>Newsletter body as html.</p>",
}
});
let username = Uuid::new_v4().to_string();

// when
let response = app
.post_newsletters_with_credentials(
&newsletter_request_body,
&username,
&app.test_user.password,
)
.await;

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

#[tokio::test]
async fn invalid_password_is_rejected() {
// given
let app = TestApp::spawn().await;
let newsletter_request_body = json!({
"title": "Newsletter Title",
"content": {
"text": "Newsletter body as plain text.",
"html": "<p>Newsletter body as html.</p>",
}
});
let password = Uuid::new_v4().to_string();

// when
let response = app
.post_newsletters_with_credentials(
&newsletter_request_body,
&app.test_user.username,
&password,
)
.await;
let response = app.post_newsletters(&newsletter_request_body).await;

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

async fn create_unfonfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
Expand Down

0 comments on commit 82c70f4

Please sign in to comment.