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

Commit

Permalink
Add admin password and logout endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
0rzech committed Apr 11, 2024
1 parent af2d109 commit 8c8f9fd
Show file tree
Hide file tree
Showing 28 changed files with 774 additions and 123 deletions.

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

23 changes: 8 additions & 15 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ argon2 = { version = "0.5.3", features = ["std"] }
askama = { version = "0.12.1", features = ["with-axum"], default-features = false }
askama_axum = { version = "0.4.0", default-features = false }
axum = "0.7.4"
axum-extra = { version = "0.9.3", features = ["cookie-signed"] }
axum-messages = "0.6.0"
base64 = "0.22.0"
config = "0.14.0"
once_cell = "1.19.0"
Expand Down
7 changes: 7 additions & 0 deletions migrations/20240407064230_seed_user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
INSERT INTO USERS (user_id, username, password_hash)
VALUES (
'77b7e761-d41a-447d-bcf9-00211196a577',
'admin',
'$argon2id$v=19$m=15000,t=2,p=1'
'$7WOA4CkjTTaLhVrzBVrFTQ$x+dO/frQlVo0CNxRso+ds0bZyrP5TPm+8b68jYn/eWY'
);
2 changes: 1 addition & 1 deletion src/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::email_client::EmailClient;
use axum::{extract::FromRef, http::Uri};
use axum_extra::extract::cookie::Key;
use sqlx::PgPool;
use tower_sessions::cookie::Key;

#[derive(Clone)]
pub struct AppState {
Expand Down
44 changes: 43 additions & 1 deletion src/authentication.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use argon2::{
password_hash::SaltString, Algorithm, Argon2, Params, PasswordHash, PasswordHasher,
PasswordVerifier, Version,
};
use secrecy::{ExposeSecret, Secret};
use sqlx::PgPool;
use uuid::Uuid;
Expand Down Expand Up @@ -77,6 +80,45 @@ fn verify_password_hash(
.map_err(AuthError::InvalidCredentials)
}

#[tracing::instrument(skip(db_pool, user_id, password))]
pub async fn change_password(
db_pool: &PgPool,
user_id: Uuid,
password: Secret<String>,
) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(move || compute_password_hash(password))
.await?
.context("Failed to hash password")?;

sqlx::query!(
r#"
UPDATE users
SET password_hash = $1
WHERE user_id = $2
"#,
password_hash.expose_secret(),
user_id
)
.execute(db_pool)
.await
.context("Failed to change user's password in the database")?;

Ok(())
}

fn compute_password_hash(password: Secret<String>) -> Result<Secret<String>, anyhow::Error> {
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(password.expose_secret().as_bytes(), &salt)?
.to_string();

Ok(Secret::new(password_hash))
}

pub struct Credentials {
pub username: String,
pub password: Secret<String>,
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pub mod domain;
pub mod email_client;
pub mod request_id;
pub mod routes;
pub mod session_state;
pub mod session;
pub mod startup;
pub mod telemetry;
pub mod utils;
45 changes: 24 additions & 21 deletions src/routes/admin/dashboard.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
use crate::{
app_state::AppState,
session_state::TypedSession,
utils::{e500, redirect_to, HttpError},
session::extract::SessionUserId,
utils::{e500, HttpError},
};
use anyhow::{Context, Error};
use askama::Template;
use askama_axum::IntoResponse;
use axum::{extract::State, response::Response};
use axum::extract::State;
use sqlx::PgPool;
use uuid::Uuid;

#[tracing::instrument(name = "Get admin dashboard", skip(app_state, session))]
#[tracing::instrument(name = "Get admin dashboard", skip(app_state, user_id))]
pub(super) async fn admin_dashboard(
State(app_state): State<AppState>,
session: TypedSession,
) -> Result<Response, HttpError<Error>> {
let response = match session.get_user_id().await.map_err(e500)? {
Some(user_id) => Dashboard {
title: "Admin Dashboard",
username: get_username(&app_state.db_pool, user_id)
.await
.map_err(e500)?,
}
.into_response(),
None => redirect_to("/login"),
};
SessionUserId(user_id): SessionUserId,
) -> Result<Dashboard<'static>, HttpError<Error>> {
let username = get_username(&app_state.db_pool, user_id)
.await
.map_err(e500)?;

Ok(response)
Ok(Dashboard {
title: "Admin Dashboard",
welcome: "Welcome",
available_actions: "Available actions",
change_password: "Change password",
logout: "Logout",
username,
})
}

#[tracing::instrument(skip(db_pool))]
async fn get_username(db_pool: &PgPool, user_id: Uuid) -> Result<String, Error> {
#[tracing::instrument(skip(db_pool, user_id))]
pub async fn get_username(db_pool: &PgPool, user_id: Uuid) -> Result<String, Error> {
let row = sqlx::query!(
r#"
SELECT username
Expand All @@ -48,7 +47,11 @@ async fn get_username(db_pool: &PgPool, user_id: Uuid) -> Result<String, Error>

#[derive(Template)]
#[template(path = "web/dashboard.html")]
struct Dashboard<'a> {
pub struct Dashboard<'a> {
title: &'a str,
welcome: &'a str,
available_actions: &'a str,
change_password: &'a str,
logout: &'a str,
username: String,
}
19 changes: 19 additions & 0 deletions src/routes/admin/logout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use crate::{
session::state::TypedSession,
utils::{e500, HttpError},
};
use axum::response::Redirect;
use axum_messages::Messages;

#[tracing::instrument(skip(session, messages))]
pub(super) async fn log_out(
session: TypedSession,
messages: Messages,
) -> Result<Redirect, HttpError<anyhow::Error>> {
if session.get_user_id().await.map_err(e500)?.is_some() {
session.flush().await.map_err(e500)?;
messages.info("You have successfully logged out.");
}

Ok(Redirect::to("/login"))
}
15 changes: 13 additions & 2 deletions src/routes/admin/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
use crate::app_state::AppState;
use axum::{routing::get, Router};
use axum::{
routing::{get, post},
Router,
};
use dashboard::admin_dashboard;
use logout::log_out;
use password::{change_password, change_password_form};

mod dashboard;
mod logout;
mod password;

pub fn router() -> Router<AppState> {
Router::new().route("/admin/dashboard", get(admin_dashboard))
Router::new()
.route("/admin/dashboard", get(admin_dashboard))
.route("/admin/password", get(change_password_form))
.route("/admin/password", post(change_password))
.route("/admin/logout", post(log_out))
}
81 changes: 81 additions & 0 deletions src/routes/admin/password/action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use crate::{
app_state::AppState,
authentication::{
change_password as auth_change_password, validate_credentials, AuthError, Credentials,
},
routes::admin::dashboard::get_username,
session::extract::SessionUserId,
utils::{e500, HttpError},
};
use axum::{extract::State, response::Redirect, Form};
use axum_messages::Messages;
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;

#[tracing::instrument(skip(app_state, user_id, form))]
pub(in crate::routes::admin) async fn change_password(
State(app_state): State<AppState>,
SessionUserId(user_id): SessionUserId,
messages: Messages,
Form(form): Form<FormData>,
) -> Result<Redirect, HttpError<anyhow::Error>> {
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
messages.error(
"You have entered two different new passwords - \
the field values must match.",
);
return Ok(Redirect::to("/admin/password"));
}

let credentials = get_username(&app_state.db_pool, user_id)
.await
.map(|username| Credentials {
username,
password: form.current_password,
})
.map_err(e500)?;

if let Err(e) = validate_credentials(&app_state.db_pool, credentials).await {
return match e {
AuthError::InvalidCredentials(_) => {
messages.error("The current password is incorrect.");
Ok(Redirect::to("/admin/password"))
}
AuthError::UnexpectedError(_) => Err(e500(e.into())),
};
}

if let Err(e) = validate_password(&form.new_password) {
messages.error(e);
return Ok(Redirect::to("/admin/password"));
}

auth_change_password(&app_state.db_pool, user_id, form.new_password)
.await
.map_err(e500)?;

messages.info("Your password has been changed.");

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

#[derive(Deserialize)]
pub(in crate::routes::admin) struct FormData {
current_password: Secret<String>,
new_password: Secret<String>,
new_password_check: Secret<String>,
}

fn validate_password(password: &Secret<String>) -> Result<(), &'static str> {
let password = password.expose_secret();

if password.len() < 12 {
return Err("Password must be at least 12 characters long.");
}

if password.len() > 128 {
return Err("Passwords must be at most 128 characters long.");
}

Ok(())
}
36 changes: 36 additions & 0 deletions src/routes/admin/password/form.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use askama_axum::Template;
use axum_messages::Messages;

pub(in crate::routes::admin) async fn change_password_form(
messages: Messages,
) -> ChangePasswordForm<'static> {
let flashes = messages.map(|m| m.message).collect();

ChangePasswordForm {
title: "Change Password",
current_password_label: "Current Password",
current_password_placeholder: "Enter current password",
new_password_label: "New Password",
new_password_placeholder: "Enter new password",
new_password_check_label: "Confirm New Password",
new_password_check_placeholder: "Type the new password again",
change_password_button: "Change password",
back_link: "Back",
flashes,
}
}

#[derive(Template)]
#[template(path = "web/change_password_form.html")]
pub(in crate::routes::admin) struct ChangePasswordForm<'a> {
title: &'a str,
current_password_label: &'a str,
current_password_placeholder: &'a str,
new_password_label: &'a str,
new_password_placeholder: &'a str,
new_password_check_label: &'a str,
new_password_check_placeholder: &'a str,
change_password_button: &'a str,
back_link: &'a str,
flashes: Vec<String>,
}
5 changes: 5 additions & 0 deletions src/routes/admin/password/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod action;
mod form;

pub(super) use action::change_password;
pub(super) use form::change_password_form;
Loading

0 comments on commit 8c8f9fd

Please sign in to comment.