This repository has been archived by the owner on Aug 21, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add login form and verify credentials
- Loading branch information
Showing
16 changed files
with
344 additions
and
99 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.