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

Commit

Permalink
Add login form and verify credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
0rzech committed Mar 26, 2024
1 parent 42fa09d commit cfc255d
Show file tree
Hide file tree
Showing 16 changed files with 344 additions and 99 deletions.
11 changes: 11 additions & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ askama_axum = { version = "0.4.0", default-features = false }
axum = "0.7.4"
base64 = "0.22.0"
config = "0.14.0"
hex = "0.4.3"
hmac = { version = "0.12.1", features = ["std"] }
htmlescape = "0.3.1"
once_cell = "1.19.0"
rand = "0.8.5"
regex = "1.10.3"
reqwest = { version = "0.11.24", features = ["json"], default-features = false }
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0.196", features = ["derive"] }
serde-aux = { version = "4.4.0", default-features = false }
sha2 = "0.10.8"
sqlx = { version = "0.7.3", features = ["macros", "migrate", "postgres", "time", "runtime-tokio", "tls-native-tls", "uuid"], default-features = false }
thiserror = "1.0.58"
time = { version = "0.3.34", features = ["macros", "serde"] }
Expand All @@ -38,6 +42,7 @@ tracing-bunyan-formatter = "0.3.9"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
unicode-segmentation = "1.11.0"
urlencoding = "2.1.3"
uuid = { version = "1.7.0", features = ["v4"] }
validator = "0.16.1"

Expand Down
1 change: 1 addition & 0 deletions configuration/base.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
application:
port: 8000
hmac_secret: long-and-very-secret-random-key-needed-to-verify-message-integrity
database:
host: localhost
port: 5432
Expand Down
2 changes: 2 additions & 0 deletions src/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use crate::email_client::EmailClient;
use axum::http::Uri;
use secrecy::Secret;
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
pub db_pool: PgPool,
pub email_client: EmailClient,
pub base_url: Uri,
pub hmac_secret: Secret<String>,
}
91 changes: 91 additions & 0 deletions src/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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;

#[tracing::instrument(name = "Validate credentials", skip(db_pool, credentials))]
pub async fn validate_credentials(
db_pool: &PgPool,
credentials: Credentials,
) -> Result<Uuid, AuthError> {
let mut user_id = None;
let mut expected_password_hash = Secret::new(
"$argon2id$v=19$m=15000,t=2,p=1$\
5sbY1nJpEqWJ9gQP0SvDbw$\
ZgUSqWDG8XJozXYqOTrah9Ori8FmepJwhTHZMLradFU"
.to_string(),
);

if let Some((stored_user_id, stored_password_hash)) =
get_stored_credentials(db_pool, credentials.username).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(|| anyhow::anyhow!("Unknown username"))
.map_err(AuthError::InvalidCredentials)
}

#[tracing::instrument(name = "Get stored credentials", skip(db_pool, username))]
async fn get_stored_credentials(
db_pool: &PgPool,
username: String,
) -> Result<Option<(Uuid, Secret<String>)>, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT user_id, password_hash
FROM users
WHERE username = $1
"#,
username,
)
.fetch_optional(db_pool)
.await
.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 = "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)
}

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

#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
1 change: 1 addition & 0 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub base_url: String,
pub hmac_secret: Secret<String>,
}

#[derive(Deserialize)]
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod app_state;
pub mod authentication;
pub mod configuration;
pub mod domain;
pub mod email_client;
Expand Down
77 changes: 77 additions & 0 deletions src/routes/login/action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use crate::{
app_state::AppState,
authentication::{validate_credentials, AuthError, Credentials},
};
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Redirect, Response},
Form,
};
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;
use urlencoding::Encoded;

#[tracing::instrument(
skip(app_state, form),
fields(username = tracing::field::Empty, user_id = tracing::field::Empty)
)]
pub(super) async fn login(
State(app_state): State<AppState>,
Form(form): Form<FormData>,
) -> Result<Redirect, LoginError> {
tracing::Span::current().record("username", &tracing::field::display(&form.username));

let user_id = validate_credentials(
&app_state.db_pool,
Credentials {
username: form.username,
password: form.password,
},
)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => {
let error = format!("error={}", Encoded::new(e.to_string()));
let tag = format!("tag={:x}", {
let secret: &[u8] = app_state.hmac_secret.expose_secret().as_bytes();
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret).unwrap();
mac.update(error.as_bytes());
mac.finalize().into_bytes()
});

LoginError::AuthError(e.into(), Redirect::to(&format!("/login?{error}&{tag}")))
}
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
})?;

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

Ok(Redirect::to("/"))
}

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

#[derive(Debug, thiserror::Error)]
pub(super) enum LoginError {
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error, Redirect),
#[error("Something went wrong")]
UnexpectedError(#[from] anyhow::Error),
}

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

match self {
Self::AuthError(_, redirect) => redirect.into_response(),
Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
74 changes: 74 additions & 0 deletions src/routes/login/form.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use crate::app_state::AppState;
use anyhow::Context;
use askama_axum::Template;
use axum::extract::{Query, State};
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;
use urlencoding::Encoded;

#[tracing::instrument(skip(app_state, parameters))]
pub(super) async fn login_form(
State(app_state): State<AppState>,
Query(parameters): Query<Parameters>,
) -> LoginForm<'static> {
let error_message = match parameters.error_message(&app_state.hmac_secret) {
Ok(raw_html) => raw_html,
Err(e) => {
tracing::warn!("Failed to get error message from query parameters: {:?}", e);
None
}
}
.map(|raw_html| htmlescape::encode_minimal(&raw_html));

LoginForm {
title: "Login",
username_label: "Username",
username_placeholder: "Enter username",
password_label: "Password",
password_placeholder: "Enter password",
submit_label: "Login",
error_message,
action: "/login",
}
}

#[derive(Deserialize)]
pub(super) struct Parameters {
error: Option<String>,
tag: Option<String>,
}

impl Parameters {
fn error_message(self, hmac_secret: &Secret<String>) -> Result<Option<String>, anyhow::Error> {
match (&self.error, self.tag) {
(Some(e), Some(t)) => {
let tag = hex::decode(t).context("Failed to decode hex hmac tag")?;
let error = format!("error={}", Encoded::new(e));

let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(hmac_secret.expose_secret().as_bytes())?;
mac.update(error.as_bytes());
mac.verify_slice(&tag)?;

Ok(self.error)
}
(None, None) => Ok(None),
(Some(_), None) => Err(anyhow::anyhow!("Error message is missing hmac tag")),
(None, Some(_)) => Err(anyhow::anyhow!("Hmac tag is missing error message")),
}
}
}

#[derive(Template)]
#[template(path = "web/login_form.html")]
pub(super) struct LoginForm<'a> {
title: &'a str,
username_label: &'a str,
username_placeholder: &'a str,
password_label: &'a str,
password_placeholder: &'a str,
submit_label: &'a str,
error_message: Option<String>,
action: &'a str,
}
16 changes: 16 additions & 0 deletions src/routes/login/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use crate::app_state::AppState;
use action::login;
use axum::{
routing::{get, post},
Router,
};
use form::login_form;

mod action;
mod form;

pub fn router() -> Router<AppState> {
Router::new()
.route("/login", get(login_form))
.route("/login", post(login))
}
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod health_check;
pub mod home;
pub mod login;
pub mod newsletters;
pub mod subscriptions;
pub mod subscriptions_confirm;
Loading

0 comments on commit cfc255d

Please sign in to comment.