Skip to content

Commit

Permalink
Auth module, login flow
Browse files Browse the repository at this point in the history
  • Loading branch information
samituga committed Oct 24, 2024
1 parent 21deb5c commit 973ff01
Show file tree
Hide file tree
Showing 11 changed files with 229 additions and 84 deletions.
89 changes: 89 additions & 0 deletions src/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use secrecy::{ExposeSecret, Secret};
use sqlx::PgPool;
use uuid::Uuid;

#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("Invalid credentials.")]
InvalidCredentials(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}

#[derive(Debug)]
pub struct Credentials {
pub username: String,
pub password: Secret<String>,
}

pub async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<Uuid, AuthError> {
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?
{
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.")??;

user_id.ok_or_else(|| AuthError::InvalidCredentials(anyhow::anyhow!("Unknown username.")))
}

#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
username: &str,
pool: &PgPool,
) -> Result<Option<(Uuid, Secret<String>)>, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT id, password_hash
FROM users
WHERE username = $1
"#,
username,
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve user credentials.")?
.map(|row| (row.id, Secret::new(row.password_hash)));

Ok(row)
}

#[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<(), AuthError> {
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
.context("Failed to parse hash in PHC string format.")?;

Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Invalid password")
.map_err(AuthError::InvalidCredentials)
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod authentication;
pub mod bootstrap;
pub mod configuration;
pub mod domain;
Expand Down
11 changes: 11 additions & 0 deletions src/routes/home/home.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html" charset="UTF-8">
<title>Home</title>
</head>
<p>Welcome to our newsletter</p>
<body>

</body>
</html>
8 changes: 8 additions & 0 deletions src/routes/home/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;

pub async fn home() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("home.html"))
}
8 changes: 8 additions & 0 deletions src/routes/login/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;

pub async fn login_form() -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::html())
.body(include_str!("login.html"))
}
26 changes: 26 additions & 0 deletions src/routes/login/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html" charset="UTF-8">
<title>Login</title>
</head>
<body>
<form action="/login" method="post">
<label>Username
<input
type="text"
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
5 changes: 5 additions & 0 deletions src/routes/login/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub use get::login_form;
pub use post::login;

mod get;
mod post;
63 changes: 63 additions & 0 deletions src/routes/login/post.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use crate::authentication::{validate_credentials, AuthError, Credentials};
use crate::routes::error_chain_fmt;
use actix_web::http::StatusCode;
use actix_web::{web, HttpResponse, ResponseError};
use secrecy::Secret;
use sqlx::PgPool;
use std::fmt::Debug;

#[derive(serde::Deserialize)]
pub struct FormData {
pub username: String,
pub password: Secret<String>,
}

#[tracing::instrument(
skip(form, pool),
fields(username = %form.username, user_id = tracing::field::Empty)
)]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, LoginError> {
let credentials = Credentials {
username: form.0.username,
password: form.0.password,
};

let user_id = validate_credentials(credentials, &pool)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
})?;

tracing::Span::current().record("user_id", tracing::field::display(&user_id));

Ok(HttpResponse::SeeOther()
.insert_header(("location", "/"))
.finish())
}

#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Invalid credentials.")]
AuthError(#[source] anyhow::Error),
#[error("Something went wrong.")]
UnexpectedError(#[from] anyhow::Error),
}

impl Debug for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}

impl ResponseError for LoginError {
fn status_code(&self) -> StatusCode {
match self {
LoginError::AuthError(_) => StatusCode::UNAUTHORIZED,
LoginError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
4 changes: 4 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
pub use health_check::*;
pub use home::*;
pub use login::*;
pub use newsletters::*;
pub use subscriptions::*;
pub use subscriptions_confirm::*;

mod health_check;
mod home;
mod login;
mod newsletters;
mod subscriptions;
mod subscriptions_confirm;
91 changes: 8 additions & 83 deletions src/routes/newsletters.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
use crate::authentication::{validate_credentials, AuthError, Credentials};
use crate::domain::SubscriberEmail;
use crate::email::email_client::{EmailClient, EmailService, SendEmailRequest};
use crate::routes::error_chain_fmt;
use crate::telemetry::spawn_blocking_with_tracing;
use actix_web::body::BoxBody;
use actix_web::http::header::{HeaderMap, HeaderValue};
use actix_web::http::{header, StatusCode};
use actix_web::{web, HttpRequest, HttpResponse, ResponseError};
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use base64::Engine;
use secrecy::{ExposeSecret, Secret};
use secrecy::Secret;
use sqlx::PgPool;
use std::fmt::{Debug, Formatter};
use uuid::Uuid;

#[derive(thiserror::Error)]
pub enum PublishError {
Expand Down Expand Up @@ -81,7 +79,12 @@ pub async fn publish_newsletter(

tracing::Span::current().record("username", tracing::field::display(&credentials.username));

let user_id = validate_credentials(credentials, &pool).await?;
let user_id = validate_credentials(credentials, &pool)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()),
AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()),
})?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id));

let confirmed_subscribers = get_confirmed_subscribers(&pool).await?;
Expand Down Expand Up @@ -146,11 +149,6 @@ async fn get_confirmed_subscribers(
Ok(confirmed_subscribers)
}

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

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
let header_value = headers
.get("Authorization")
Expand Down Expand Up @@ -184,76 +182,3 @@ fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Erro
password: Secret::new(password),
})
}

async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<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::AuthError)??;

user_id.ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))
}

#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
username: &str,
pool: &PgPool,
) -> Result<Option<(Uuid, Secret<String>)>, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT id, password_hash
FROM users
WHERE username = $1
"#,
username,
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve user credentials.")?
.map(|row| (row.id, Secret::new(row.password_hash)));

Ok(row)
}

#[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)?;

Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Invalid password")
.map_err(PublishError::AuthError)
}
7 changes: 6 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use crate::bootstrap::Dependencies;
use crate::configuration::{DatabaseSettings, Settings};
use crate::email::email_client::{EmailClient, EmailService};
use crate::routes::{confirm, health_check, publish_newsletter, subscribe};
use crate::routes::{
confirm, health_check, home, login, login_form, publish_newsletter, subscribe,
};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use http::Uri;
Expand Down Expand Up @@ -77,6 +79,9 @@ pub fn run(
let server = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.route("/", web::get().to(home))
.route("/login", web::get().to(login_form))
.route("/login", web::post().to(login))
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.route("/subscriptions/confirm", web::get().to(confirm))
Expand Down

0 comments on commit 973ff01

Please sign in to comment.