Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
764 changes: 327 additions & 437 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ quickcheck_macros = "1.0.0"
reqwest = { version = "^0.12", default-features = false, features = ["json", "rustls-tls"] }
thiserror = "2.0.12"
anyhow = "1.0.98"
base64 = "0.22.1"
argon2 = { version = "0.5.3", features = ["std"] }

[dependencies.sqlx]
version = "^0.8.5"
Expand Down
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
| Branch | Status |
|--------|--------|
| main | [![Rust (main)](https://github.com/rs333/zero2prod/actions/workflows/general.yml/badge.svg?branch=main)](https://github.com/rs333/zero2prod/actions/workflows/general.yml) [![Security audit (main)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml/badge.svg?branch=main)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml) |
| dev | [![Rust (dev)](https://github.com/rs333/zero2prod/actions/workflows/general.yml/badge.svg?branch=dev)](https://github.com/rs333/zero2prod/actions/workflows/general.yml) [![Security audit (dev)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml/badge.svg?branch=dev)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml) |
| Branch | Status |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| main | [![Rust (main)](https://github.com/rs333/zero2prod/actions/workflows/general.yml/badge.svg?branch=main)](https://github.com/rs333/zero2prod/actions/workflows/general.yml) [![Security audit (main)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml/badge.svg?branch=main)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml) |
| dev | [![Rust (dev)](https://github.com/rs333/zero2prod/actions/workflows/general.yml/badge.svg?branch=dev)](https://github.com/rs333/zero2prod/actions/workflows/general.yml) [![Security audit (dev)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml/badge.svg?branch=dev)](https://github.com/rs333/zero2prod/actions/workflows/audit.yml) |

# Zero to Production in Rust

## How to build

```bash
cargo build
```

## How to test

Veriy the postgres database is running, then run the tests.

```bash
scripts/init_db.sh
cargo test
```

## Notes
How to remove the test databases using psql
```bash
for dbname in $(psql -U postgres -c "copy (select datname from pg_database where datname like '%-%-%-%-%') to stdout"); do echo "$dbname"; psql -U postgres -c "drop database \"$dbname\""; done
```

- How to remove the test databases using psql

```bash
for dbname in $(psql -U postgres -c "copy (select datname from pg_database where datname like '%-%-%-%-%') to stdout"); do echo "$dbname"; psql -U postgres -c "drop database \"$dbname\""; done
```

- Verify the following prior to running tests locally.

1. The password is updated in the `DATABASE_URL` for the `.env` file.
2. The database password is updated in the `base.yml` file.
3. The `sender_email:` field is updated for the `email_client:` portion of the `production.yml` file.
4. These changes are not commited.
6 changes: 6 additions & 0 deletions migrations/20250528210419_create_users_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Add migration script here
create table users(
user_id uuid primary key,
username text not null unique,
password text not null
);
2 changes: 2 additions & 0 deletions migrations/20250529201954_rename_password_column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add migration script here
alter table users rename password to password_hash;
2 changes: 2 additions & 0 deletions migrations/20250530012608_add_salt_to_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add migration script here
alter table users add column salt text not null;
2 changes: 2 additions & 0 deletions migrations/20250530024646_remove_salt_from_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add migration script here
alter table users drop column salt;
15 changes: 7 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
//! src/main.rs

use telemetry::init_subscriber;
use zero2prod::{configuration::get_configuration, startup::Application};
mod telemetry;
use zero2prod::{
configuration::get_configuration,
startup::Application,
telemetry::{get_subscriber, init_subscriber},
};

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = telemetry::get_subscriber(
"zero2prod".into(),
"info".into(),
std::io::stdout,
);
let subscriber =
get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);

let configuration =
Expand Down
148 changes: 144 additions & 4 deletions src/routes/newsletter.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
use crate::{
domain::SubscriberEmail, email_client::EmailClient, routes::error_chain_fmt,
domain::SubscriberEmail, email_client::EmailClient,
routes::error_chain_fmt, telemetry::spawn_blocking_with_tracing,
};
use actix_web::{HttpResponse, ResponseError, http::StatusCode, web};

use actix_web::{
HttpRequest, HttpResponse, ResponseError,
http::header::{HeaderMap, HeaderValue},
http::{StatusCode, header},
web,
};

use argon2::{Argon2, PasswordHash, PasswordVerifier};

use anyhow::Context;
use base64::Engine;
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool;

#[derive(serde::Deserialize)]
Expand All @@ -23,6 +35,8 @@ struct ConfirmedSubscriber {

#[derive(thiserror::Error)]
pub enum PublishError {
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
Expand All @@ -34,20 +48,43 @@ impl std::fmt::Debug for PublishError {
}

impl ResponseError for PublishError {
fn status_code(&self) -> actix_web::http::StatusCode {
fn error_response(&self) -> HttpResponse {
match self {
PublishError::UnexpectedError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
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
}
}
}
}

#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body,pool,email_client,request),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
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_authentication(request.headers())
.map_err(PublishError::AuthError)?;

tracing::Span::current()
.record("username", tracing::field::display(&credentials.username));
let user_id = validate_credentials(credentials, &pool).await?;
tracing::Span::current()
.record("user_id", tracing::field::display(&user_id));
let subscribers = get_confirmed_subscribers(&pool).await?;
for subscriber in subscribers {
match subscriber {
Expand Down Expand Up @@ -93,3 +130,106 @@ async fn get_confirmed_subscribers(

Ok(confirmed_subscribers)
}

struct Credentials {
username: String,
password: SecretString,
}

fn basic_authentication(
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_credentials = header_value
.strip_prefix("Basic ")
.context("The authorization scheme was not 'Basic'.")?;

let decoded_bytes = base64::engine::general_purpose::STANDARD
.decode(base64encoded_credentials)
.context("Failed to base64-decode '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: SecretString::from(password),
})
}

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let row: Option<_> = sqlx::query!(
r#"
select user_id, password_hash from users where username = $1"#,
credentials.username
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve stored credentials")
.map_err(PublishError::UnexpectedError)?;

let (expected_password_hash, user_id) = match row {
Some(row) => (row.password_hash, row.user_id),
None => {
return Err(PublishError::AuthError(anyhow::anyhow!(
"Unknown username"
)));
}
};
spawn_blocking_with_tracing(move || {
verify_password_hash(
expected_password_hash.into(),
credentials.password,
)
})
.await
.context("Failed to spawn blocking task.")
.map_err(PublishError::UnexpectedError)??;

Ok(user_id)
}

#[tracing::instrument(
name = "Verify password hash",
skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
expected_password_hash: SecretString,
password_candidate: SecretString,
) -> Result<(), PublishError> {
let expected_password_hash =
PasswordHash::new(expected_password_hash.expose_secret())
.context("Failed to parse hash in PHC string format.")
.map_err(PublishError::UnexpectedError)?;

Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Invalid Password")
.map_err(PublishError::AuthError)
}
10 changes: 10 additions & 0 deletions src/telemetry.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use tokio::task::JoinHandle;
use tracing::Subscriber;
use tracing::dispatcher::set_global_default;
use tracing_bunyan_formatter::BunyanFormattingLayer;
Expand Down Expand Up @@ -29,3 +30,12 @@ pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber.into()).expect("Failed to set subscriber.");
}

pub fn spawn_blocking_with_tracing<F, R>(f: F) -> JoinHandle<R>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
let current_span = tracing::Span::current();
tokio::task::spawn_blocking(move || current_span.in_scope(f))
}
49 changes: 47 additions & 2 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHasher};
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
Expand Down Expand Up @@ -81,19 +84,57 @@ pub(crate) async fn spawn_app() -> TestApp {
let _ = tokio::spawn(application.run_until_stopped());
let db_pool = get_connection_pool(&configuration.database);

TestApp {
let test_app = TestApp {
address,
db_pool,
email_server,
port,
}
test_user: TestUser::generate(),
};

test_app.test_user.store(&test_app.db_pool).await;
test_app
}

pub struct TestApp {
pub address: String,
pub db_pool: PgPool,
pub email_server: MockServer,
pub port: u16,
test_user: TestUser,
}
pub struct TestUser {
pub user_id: Uuid,
pub username: String,
pub password: String,
}

impl TestUser {
pub fn generate() -> Self {
Self {
user_id: Uuid::new_v4(),
username: Uuid::new_v4().to_string(),
password: Uuid::new_v4().to_string(),
}
}

async fn store(&self, pool: &PgPool) {
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(self.password.as_bytes(), &salt)
.unwrap()
.to_string();
sqlx::query!(
"insert into users (user_id, username, password_hash)
values ($1, $2, $3)",
self.user_id,
self.username,
password_hash,
)
.execute(pool)
.await
.expect("Failed to store test users.");
}
}

impl TestApp {
Expand All @@ -103,6 +144,10 @@ impl TestApp {
) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
.basic_auth(
&self.test_user.username,
Some(&self.test_user.password),
)
.json(&body)
.send()
.await
Expand Down
Loading