Skip to content

Commit

Permalink
feat: implement password hashing
Browse files Browse the repository at this point in the history
  • Loading branch information
sciencefidelity committed Aug 18, 2024
1 parent a5fd572 commit 075f97f
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 48 deletions.
33 changes: 33 additions & 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 @@ -40,6 +40,7 @@ features = ["json", "rustls-tls"]
[dependencies]
actix-web = "4"
anyhow = "1"
argon2 = { version = "0.5", features = ["std"] }
base64 = "0.22"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
config = { version = "0.14.0", default-features = false, features = ["yaml"] }
Expand Down
1 change: 1 addition & 0 deletions migrations/20240816193545_rename_password_column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users RENAME password TO password_hash
1 change: 1 addition & 0 deletions migrations/20240817145345_add_salt_to_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;
1 change: 1 addition & 0 deletions migrations/20240818185308_remove_salt_from_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN salt;
84 changes: 69 additions & 15 deletions src/routes/newsletters.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use super::error_chain_fmt;
use crate::telemetry::spawn_blocking_with_tracing;
use crate::{EmailClient, SubscriberEmail};
use actix_web::http::header::{self, HeaderMap, HeaderValue};
use actix_web::HttpRequest;
use actix_web::{http::StatusCode, web, HttpResponse, ResponseError};
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use base64::Engine;
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;
Expand Down Expand Up @@ -91,36 +93,88 @@ fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Erro
})
}

async fn validate_credentials(
credentials: Credentials,
#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
username: &str,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let user_id: Option<_> = sqlx::query!(
) -> Result<Option<(uuid::Uuid, Secret<String>)>, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT user_id
SELECT user_id, password_hash
FROM users
WHERE username = $1 AND password = $2
WHERE username = $1
"#,
credentials.username,
credentials.password.expose_secret()
username,
)
.fetch_optional(pool)
.await
.context("failed to perform a query to validate auth credentials.")
.map_err(PublishError::UnexpectedError)?;
.context("failed to perform a query to retrieve stored credentials.")?
.map(|row| (row.user_id, Secret::new(row.password_hash)));
Ok(row)
}

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let mut user_id = None;
let mut expected_password_hash = Secret::new(
"$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.to_string(),
);
if let Some((stored_user_id, stored_password_hash)) =
get_stored_credentials(&credentials.username, &pool)
.await
.map_err(PublishError::UnexpectedError)?
{
user_id = Some(stored_user_id);
expected_password_hash = stored_password_hash;
}

spawn_blocking_with_tracing(move || {
verify_password_hash(expected_password_hash, credentials.password)
})
.await
.context("Failed to spawn blocking task.")
.map_err(PublishError::UnexpectedError)??;

// This is only set to `Some` if we found credentials in the store.
// So, even if the default password ends up matching (somehow)
// with the provided password, we never authenticate a non-existing user.
// TODO: add unit test for this.
user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
}

#[tracing::instrument(
name = "Verify password hash",
skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
expected_password_hash: Secret<String>,
password_candidate: Secret<String>,
) -> 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)?;

user_id
.map(|row| row.user_id)
.ok_or_else(|| anyhow::anyhow!("invalid username or password."))
Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Invalid password.")
.map_err(PublishError::AuthError)
}

/// # Errors
///
/// Will return `Err` if the stored email address in invalid.
#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body, pool, email_client, request),
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(
Expand Down
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::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
Expand Down Expand Up @@ -41,3 +42,12 @@ pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).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))
}
88 changes: 55 additions & 33 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use argon2::{Algorithm, Params, Version};
use newsletter::{get_configuration, get_connection_pool, get_subscriber, init_subscriber};
use newsletter::{Application, DatabaseSettings};
use once_cell::sync::Lazy;
Expand All @@ -23,6 +25,7 @@ pub struct TestApp {
pub port: u16,
pub db_pool: PgPool,
pub email_server: MockServer,
pub test_user: TestUser,
}

pub struct ConfirmationLinks {
Expand All @@ -41,6 +44,16 @@ impl TestApp {
.expect("Failed to execute request.")
}

pub async fn post_newsletters(&self, body: serde_json::Value) -> 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
.expect("failed to execute request")
}

pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();

Expand All @@ -62,25 +75,6 @@ impl TestApp {
let plain_text = get_link(&body["TextBody"].as_str().unwrap());
ConfirmationLinks { html, plain_text }
}

pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
let (username, password) = self.test_user().await;
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
.basic_auth(username, Some(password))
.json(&body)
.send()
.await
.expect("failed to execute request")
}

pub async fn test_user(&self) -> (String, String) {
let row = sqlx::query!("SELECT username, password FROM users LIMIT 1",)
.fetch_one(&self.db_pool)
.await
.expect("failed to create test users.");
(row.username, row.password)
}
}

pub async fn spawn_app() -> TestApp {
Expand Down Expand Up @@ -108,22 +102,12 @@ pub async fn spawn_app() -> TestApp {
port: application_port,
db_pool: get_connection_pool(&configuration.database),
email_server,
test_user: TestUser::generate(),
};
add_test_user(&test_app.db_pool).await;
test_app
}

async fn add_test_user(pool: &PgPool) {
sqlx::query!(
"INSERT INTO users (user_id, username, password)
VALUES ($1, $2, $3)",
Uuid::new_v4(),
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
)
.execute(pool)
.await
.expect("failed to create test user");
test_app.test_user.store(&test_app.db_pool).await;

test_app
}

async fn configure_database(config: &DatabaseSettings) -> PgPool {
Expand All @@ -145,3 +129,41 @@ async fn configure_database(config: &DatabaseSettings) -> PgPool {

connection_pool
}

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 rand::thread_rng());
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(15000, 2, 1, None).unwrap(),
)
.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 user.");
}
}
Loading

0 comments on commit 075f97f

Please sign in to comment.