From d96a401d992c4947d90f19701ab511a93539baab Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Mon, 1 Sep 2025 03:08:43 +0200 Subject: [PATCH] Admin dashboard and sessions --- .cargo/config.toml | 3 + Cargo.lock | 166 +++++++++++++++++++++ Cargo.toml | 24 ++- configuration/base.yaml | 1 + migrations/20250831121659_seed_user.sql | 6 + scripts/init_redis.sh | 18 +++ src/authentication.rs | 40 ++++- src/configuration.rs | 1 + src/lib.rs | 1 + src/routes.rs | 2 + src/routes/admin.rs | 159 ++++++++++++++++++++ src/routes/admin/change_password_form.html | 26 ++++ src/routes/admin/dashboard.html | 20 +++ src/routes/home/home.html | 11 +- src/routes/login.rs | 49 +++--- src/routes/login/login.html | 20 +-- src/routes/subscriptions.rs | 8 +- src/session_state.rs | 53 +++++++ src/startup.rs | 31 +++- tests/api/admin_dashboard.rs | 34 +++++ tests/api/change_password.rs | 115 ++++++++++++++ tests/api/helpers.rs | 60 +++++++- tests/api/login.rs | 16 ++ tests/api/main.rs | 2 + 24 files changed, 810 insertions(+), 56 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 migrations/20250831121659_seed_user.sql create mode 100755 scripts/init_redis.sh create mode 100644 src/routes/admin.rs create mode 100644 src/routes/admin/change_password_form.html create mode 100644 src/routes/admin/dashboard.html create mode 100644 src/session_state.rs create mode 100644 tests/api/admin_dashboard.rs create mode 100644 tests/api/change_password.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0c38d57 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"] diff --git a/Cargo.lock b/Cargo.lock index 798b4ac..f9ec8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,12 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "argon2" version = "0.5.3" @@ -133,6 +139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -206,6 +213,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "axum-messages" version = "0.8.0" @@ -300,6 +318,16 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.33" @@ -414,6 +442,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + [[package]] name = "cookie_store" version = "0.21.1" @@ -462,6 +496,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -697,6 +737,15 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.11.1" @@ -729,6 +778,43 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.5", + "redis-protocol", + "semver", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures" version = "0.3.31" @@ -1421,6 +1507,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1441,6 +1533,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1594,6 +1696,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1920,6 +2028,20 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -2027,6 +2149,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "ron" version = "0.8.1" @@ -2145,6 +2289,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "serde" version = "1.0.219" @@ -2925,6 +3075,20 @@ dependencies = [ "tower-sessions-core", ] +[[package]] +name = "tower-sessions-redis-store" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e15b774f3d46625a27a8ac1238ecd73c8bd50013244e2de004026e161aad728" +dependencies = [ + "async-trait", + "fred", + "rmp-serde", + "thiserror", + "time", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.41" @@ -3116,6 +3280,7 @@ checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", + "serde", "wasm-bindgen", ] @@ -3659,6 +3824,7 @@ dependencies = [ "tokio", "tower-http", "tower-sessions", + "tower-sessions-redis-store", "tracing", "tracing-bunyan-formatter", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index ce483ab..5cded43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,10 @@ path = "src/lib.rs" path = "src/main.rs" name = "zero2prod" -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"] - [dependencies] anyhow = "1.0.99" argon2 = { version = "0.5.3", features = ["std"] } -axum = "0.8.4" +axum = { version = "0.8.4", features = ["macros"] } axum-extra = { version = "0.10.1", features = ["query", "cookie"] } axum-messages = "0.8.0" base64 = "0.22.1" @@ -26,22 +22,34 @@ chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" htmlescape = "0.3.1" rand = { version = "0.9.2", features = ["std_rng"] } -reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "json", "cookies"] } +reqwest = { version = "0.12.23", default-features = false, features = [ + "rustls-tls", + "json", + "cookies", +] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } serde-aux = "4.7.0" sha3 = "0.10.8" -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] } +sqlx = { version = "0.8.6", features = [ + "runtime-tokio-rustls", + "macros", + "postgres", + "uuid", + "chrono", + "migrate", +] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tower-http = { version = "0.6.6", features = ["trace"] } tower-sessions = "0.14.0" +tower-sessions-redis-store = "0.16.0" tracing = "0.1.41" tracing-bunyan-formatter = "0.3.10" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } unicode-segmentation = "1.12.0" urlencoding = "2.1.3" -uuid = { version = "1.18.0", features = ["v4"] } +uuid = { version = "1.18.0", features = ["v4", "serde"] } validator = { version = "0.20.0", features = ["derive"] } [dev-dependencies] diff --git a/configuration/base.yaml b/configuration/base.yaml index eb88940..7ec5134 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -11,3 +11,4 @@ email_client: sender_email: "sender@example.com" authorization_token: "my-secret-token" timeout_milliseconds: 10000 +redis_uri: "redis://127.0.0.1:6379" diff --git a/migrations/20250831121659_seed_user.sql b/migrations/20250831121659_seed_user.sql new file mode 100644 index 0000000..3a2587b --- /dev/null +++ b/migrations/20250831121659_seed_user.sql @@ -0,0 +1,6 @@ +INSERT INTO users (user_id, username, password_hash) +VALUES ( + 'd2492680-6e45-4179-b369-1439b8f22051', + 'admin', + '$argon2id$v=19$m=19456,t=2,p=1$oWy180x7KxJYiTHzoN3sVw$vTgzvEqACiXjGalYUJHgb329Eb+s6wu5r+Cw8dHR5YE' +); diff --git a/scripts/init_redis.sh b/scripts/init_redis.sh new file mode 100755 index 0000000..80afcda --- /dev/null +++ b/scripts/init_redis.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -x +set -eo pipefail + +RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}') +if [[ -n $RUNNING_CONTAINER ]]; then + echo >&2 "A redis container is already running (${RUNNING_CONTAINER})." + exit 1 +fi + +docker run \ + -p "6379:6379" \ + -d \ + --name "redis_$(date '+%s')" \ + redis + +>&2 echo "Redis is ready to go!" diff --git a/src/authentication.rs b/src/authentication.rs index 05c3a52..d1054c1 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,11 +1,13 @@ +use crate::telemetry::spawn_blocking_with_tracing; use anyhow::Context; -use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use argon2::{ + Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, + password_hash::{SaltString, rand_core::OsRng}, +}; use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; use uuid::Uuid; -use crate::telemetry::spawn_blocking_with_tracing; - pub struct Credentials { pub username: String, pub password: SecretString, @@ -19,6 +21,38 @@ pub enum AuthError { InvalidCredentials(#[source] anyhow::Error), } +#[tracing::instrument(name = "Change password", skip(password, connection_pool))] +pub async fn change_password( + user_id: Uuid, + password: SecretString, + connection_pool: &PgPool, +) -> Result<(), anyhow::Error> { + let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password)) + .await? + .context("Failed to hash password")?; + sqlx::query!( + "UPDATE users SET password_hash = $1 WHERE user_id = $2", + password_hash.expose_secret(), + user_id + ) + .execute(connection_pool) + .await + .context("Failed to update user password in the database.")?; + Ok(()) +} + +fn compute_pasword_hash(password: SecretString) -> Result { + let salt = SaltString::generate(&mut OsRng); + let password_hash = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(1500, 2, 1, None).unwrap(), + ) + .hash_password(password.expose_secret().as_bytes(), &salt)? + .to_string(); + Ok(SecretString::from(password_hash)) +} + #[tracing::instrument( name = "Validate credentials", skip(username, password, connection_pool) diff --git a/src/configuration.rs b/src/configuration.rs index 37b472a..0b897a0 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -60,6 +60,7 @@ pub struct Settings { pub application: ApplicationSettings, pub database: DatabaseSettings, pub email_client: EmailClientSettings, + pub redis_uri: SecretString, } #[derive(Deserialize)] diff --git a/src/lib.rs b/src/lib.rs index 1bb9b5c..f0ffaa1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,6 @@ pub mod configuration; pub mod domain; pub mod email_client; pub mod routes; +pub mod session_state; pub mod startup; pub mod telemetry; diff --git a/src/routes.rs b/src/routes.rs index b0d8892..671805f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,3 +1,4 @@ +mod admin; mod health_check; mod home; mod login; @@ -5,6 +6,7 @@ mod newsletters; mod subscriptions; mod subscriptions_confirm; +pub use admin::*; pub use health_check::*; pub use home::*; pub use login::*; diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 0000000..0d289ac --- /dev/null +++ b/src/routes/admin.rs @@ -0,0 +1,159 @@ +use crate::{ + authentication::{self, Credentials, validate_credentials}, + routes::error_chain_fmt, + session_state::TypedSession, + startup::AppState, +}; +use axum::{ + Extension, Form, Json, + extract::{Request, State}, + middleware::Next, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_messages::Messages; +use reqwest::StatusCode; +use secrecy::{ExposeSecret, SecretString}; +use std::fmt::Write; +use uuid::Uuid; + +#[derive(thiserror::Error)] +pub enum AdminError { + #[error("Something went wrong.")] + UnexpectedError(#[from] anyhow::Error), + #[error("You must be logged in to access the admin dashboard.")] + NotAuthenticated, + #[error("Updating password failed.")] + ChangePassword, +} + +impl std::fmt::Debug for AdminError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + +impl IntoResponse for AdminError { + fn into_response(self) -> Response { + #[derive(serde::Serialize)] + struct ErrorResponse<'a> { + message: &'a str, + } + + tracing::error!("{:?}", self); + + match &self { + AdminError::UnexpectedError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + message: "An internal server error occured.", + }), + ) + .into_response(), + AdminError::NotAuthenticated => Redirect::to("/login").into_response(), + AdminError::ChangePassword => Redirect::to("/admin/password").into_response(), + } + } +} + +pub async fn require_auth( + session: TypedSession, + mut request: Request, + next: Next, +) -> Result { + let user_id = session + .get_user_id() + .await + .map_err(|e| AdminError::UnexpectedError(e.into()))? + .ok_or(AdminError::NotAuthenticated)?; + let username = session + .get_username() + .await + .map_err(|e| AdminError::UnexpectedError(e.into()))? + .ok_or(AdminError::UnexpectedError(anyhow::anyhow!( + "Could not find username in session." + )))?; + + request + .extensions_mut() + .insert(AuthenticatedUser { user_id, username }); + + Ok(next.run(request).await) +} + +#[derive(Clone)] +pub struct AuthenticatedUser { + user_id: Uuid, + username: String, +} + +pub async fn admin_dashboard( + Extension(AuthenticatedUser { username, .. }): Extension, +) -> Result { + Ok(Html(format!(include_str!("admin/dashboard.html"), username)).into_response()) +} + +#[derive(serde::Deserialize)] +pub struct PasswordFormData { + pub current_password: SecretString, + pub new_password: SecretString, + pub new_password_check: SecretString, +} + +pub async fn change_password_form(messages: Messages) -> Result { + let mut error_html = String::new(); + for message in messages { + writeln!(error_html, "

{}

", message).unwrap(); + } + Ok(Html(format!( + include_str!("admin/change_password_form.html"), + error_html + )) + .into_response()) +} + +pub async fn change_password( + Extension(AuthenticatedUser { user_id, username }): Extension, + State(AppState { + connection_pool, .. + }): State, + messages: Messages, + Form(form): Form, +) -> Result { + let credentials = Credentials { + username, + password: form.current_password, + }; + if form.new_password.expose_secret() != form.new_password_check.expose_secret() { + messages.error("You entered two different passwords - the field values must match."); + Err(AdminError::ChangePassword) + } else if validate_credentials(credentials, &connection_pool) + .await + .is_err() + { + messages.error("The current password is incorrect."); + Err(AdminError::ChangePassword) + } else if let Err(e) = verify_password(form.new_password.expose_secret()) { + messages.error(e); + Err(AdminError::ChangePassword) + } else { + authentication::change_password(user_id, form.new_password, &connection_pool) + .await + .map_err(|_| AdminError::ChangePassword)?; + messages.success("Your password has been changed."); + Ok(Redirect::to("/admin/password").into_response()) + } +} + +#[tracing::instrument(name = "Logging out", skip(messages, session))] +pub async fn logout(messages: Messages, session: TypedSession) -> Result { + session.clear().await; + messages.success("You have successfully logged out."); + Ok(Redirect::to("/login").into_response()) +} + +fn verify_password(password: &str) -> Result<(), String> { + if password.len() < 12 || password.len() > 128 { + return Err("The password must contain between 12 and 128 characters.".into()); + } + Ok(()) +} diff --git a/src/routes/admin/change_password_form.html b/src/routes/admin/change_password_form.html new file mode 100644 index 0000000..dbf70a9 --- /dev/null +++ b/src/routes/admin/change_password_form.html @@ -0,0 +1,26 @@ + + + + + + Change password + + +
+ + + + +
+ {} +

Back

+ + diff --git a/src/routes/admin/dashboard.html b/src/routes/admin/dashboard.html new file mode 100644 index 0000000..466d2c5 --- /dev/null +++ b/src/routes/admin/dashboard.html @@ -0,0 +1,20 @@ + + + + + + Admin dashboard + + +

Welcome {}!

+

Available actions:

+
    +
  1. Change password
  2. +
  3. +
    + +
    +
  4. +
+ + diff --git a/src/routes/home/home.html b/src/routes/home/home.html index cbf2601..cab76b9 100644 --- a/src/routes/home/home.html +++ b/src/routes/home/home.html @@ -1,11 +1,12 @@ - - + + Home - -

Welcome to our newsletter!

- + +

Welcome to our newsletter!

+

Login

+ diff --git a/src/routes/login.rs b/src/routes/login.rs index e7b02e5..0c6477f 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -1,6 +1,7 @@ use crate::{ authentication::{AuthError, Credentials, validate_credentials}, routes::error_chain_fmt, + session_state::TypedSession, startup::AppState, }; use axum::{ @@ -63,11 +64,8 @@ pub async fn get_login(messages: Messages) -> impl IntoResponse { Html(format!(include_str!("login/login.html"), error_html)) } -#[tracing::instrument( - skip(connection_pool, form), - fields(username=tracing::field::Empty, user_id=tracing::field::Empty) -)] pub async fn post_login( + session: TypedSession, messages: Messages, State(AppState { connection_pool, .. @@ -75,20 +73,37 @@ pub async fn post_login( Form(form): Form, ) -> Result { let credentials = Credentials { - username: form.username, + username: form.username.clone(), password: form.password, }; tracing::Span::current().record("username", tracing::field::display(&credentials.username)); - let user_id = validate_credentials(credentials, &connection_pool) - .await - .map_err(|e| match e { - AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), - AuthError::InvalidCredentials(_) => { - let e = LoginError::AuthError(e.into()); - messages.error(e.to_string()); - e - } - })?; - tracing::Span::current().record("user_id", tracing::field::display(&user_id)); - Ok(Redirect::to("/")) + match validate_credentials(credentials, &connection_pool).await { + Err(e) => { + let e = match e { + AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), + AuthError::InvalidCredentials(_) => { + let e = LoginError::AuthError(e.into()); + messages.error(e.to_string()); + e + } + }; + Err(e) + } + Ok(user_id) => { + tracing::Span::current().record("user_id", tracing::field::display(&user_id)); + session + .renew() + .await + .map_err(|e| LoginError::UnexpectedError(e.into()))?; + session + .insert_user_id(user_id) + .await + .map_err(|e| LoginError::UnexpectedError(e.into()))?; + session + .insert_username(form.username) + .await + .map_err(|e| LoginError::UnexpectedError(e.into()))?; + Ok(Redirect::to("/admin/dashboard")) + } + } } diff --git a/src/routes/login/login.html b/src/routes/login/login.html index 817dff1..80e94e0 100644 --- a/src/routes/login/login.html +++ b/src/routes/login/login.html @@ -1,16 +1,16 @@ - - + + Login - -
- - - -
- {} - + +
+ + + +
+ {} + diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 04360e2..3b9b40e 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -87,7 +87,7 @@ pub async fn subscribe( base_url, .. }): State, - Form(form): Form, + Form(form): Form, ) -> Result { let mut transaction = connection_pool .begin() @@ -195,15 +195,15 @@ Click here to confirm your subscription.", #[derive(Debug, Deserialize)] #[allow(dead_code)] -pub struct FormData { +pub struct SubscriptionFormData { name: String, email: String, } -impl TryFrom for NewSubscriber { +impl TryFrom for NewSubscriber { type Error = String; - fn try_from(value: FormData) -> Result { + fn try_from(value: SubscriptionFormData) -> Result { let name = SubscriberName::parse(value.name)?; let email = SubscriberEmail::parse(value.email)?; Ok(Self { name, email }) diff --git a/src/session_state.rs b/src/session_state.rs new file mode 100644 index 0000000..90d51ef --- /dev/null +++ b/src/session_state.rs @@ -0,0 +1,53 @@ +use axum::{extract::FromRequestParts, http::request::Parts}; +use std::result; +use tower_sessions::{Session, session::Error}; +use uuid::Uuid; + +pub struct TypedSession(Session); + +type Result = result::Result; + +impl TypedSession { + const USER_ID_KEY: &'static str = "user_id"; + const USERNAME_KEY: &'static str = "username"; + + pub async fn renew(&self) -> Result<()> { + self.0.cycle_id().await + } + + pub async fn insert_user_id(&self, user_id: Uuid) -> Result<()> { + self.0.insert(Self::USER_ID_KEY, user_id).await + } + + pub async fn get_user_id(&self) -> Result> { + self.0.get(Self::USER_ID_KEY).await + } + + pub async fn insert_username(&self, username: String) -> Result<()> { + self.0.insert(Self::USERNAME_KEY, username).await + } + + pub async fn get_username(&self) -> Result> { + self.0.get(Self::USERNAME_KEY).await + } + + pub async fn clear(&self) { + self.0.clear().await; + } +} + +impl FromRequestParts for TypedSession +where + S: Sync + Send, +{ + type Rejection = >::Rejection; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> result::Result { + Session::from_request_parts(parts, state) + .await + .map(TypedSession) + } +} diff --git a/src/startup.rs b/src/startup.rs index d96b4e0..3d362bb 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -3,15 +3,20 @@ use axum::{ Router, extract::MatchedPath, http::Request, + middleware, routing::{get, post}, }; use axum_messages::MessagesManagerLayer; -use secrecy::SecretString; +use secrecy::{ExposeSecret, SecretString}; use sqlx::{PgPool, postgres::PgPoolOptions}; use std::sync::Arc; use tokio::net::TcpListener; use tower_http::trace::TraceLayer; -use tower_sessions::{MemoryStore, SessionManagerLayer}; +use tower_sessions::SessionManagerLayer; +use tower_sessions_redis_store::{ + RedisStore, + fred::prelude::{ClientLike, Config, Pool}, +}; use uuid::Uuid; pub struct Application { @@ -37,11 +42,24 @@ impl Application { let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); let email_client = EmailClient::new(configuration.email_client); + let pool = Pool::new( + Config::from_url(configuration.redis_uri.expose_secret()) + .expect("Failed to parse Redis URL string"), + None, + None, + None, + 6, + ) + .unwrap(); + pool.connect(); + pool.wait_for_connect().await.unwrap(); + let redis_store = RedisStore::new(pool); let router = app( connection_pool, email_client, configuration.application.base_url, configuration.application.hmac_secret, + redis_store, ); Ok(Self { listener, router }) } @@ -61,6 +79,7 @@ pub fn app( email_client: EmailClient, base_url: String, hmac_secret: SecretString, + redis_store: RedisStore, ) -> Router { let app_state = AppState { connection_pool, @@ -68,6 +87,11 @@ pub fn app( base_url, hmac_secret, }; + let admin_routes = Router::new() + .route("/dashboard", get(admin_dashboard)) + .route("/password", get(change_password_form).post(change_password)) + .route("/logout", post(logout)) + .layer(middleware::from_fn(require_auth)); Router::new() .route("/", get(home)) .route("/login", get(get_login).post(post_login)) @@ -75,6 +99,7 @@ pub fn app( .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) .route("/newsletters", post(publish_newsletter)) + .nest("/admin", admin_routes) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request @@ -93,6 +118,6 @@ pub fn app( }), ) .layer(MessagesManagerLayer) - .layer(SessionManagerLayer::new(MemoryStore::default())) + .layer(SessionManagerLayer::new(redis_store).with_secure(false)) .with_state(app_state) } diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs new file mode 100644 index 0000000..2da09b3 --- /dev/null +++ b/tests/api/admin_dashboard.rs @@ -0,0 +1,34 @@ +use crate::helpers::{TestApp, assert_is_redirect_to}; + +#[tokio::test] +async fn you_must_be_logged_in_to_access_the_admin_dashboard() { + let app = TestApp::spawn().await; + + let response = app.get_admin_dashboard().await; + + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn logout_clears_session_state() { + let app = TestApp::spawn().await; + + let login_body = serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password, + }); + let response = app.post_login(&login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); + + let html_page = app.get_admin_dashboard_html().await; + assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); + + let response = app.post_logout().await; + assert_is_redirect_to(&response, "/login"); + + let html_page = app.get_login_html().await; + assert!(html_page.contains("You have successfully logged out")); + + let response = app.get_admin_dashboard().await; + assert_is_redirect_to(&response, "/login"); +} diff --git a/tests/api/change_password.rs b/tests/api/change_password.rs new file mode 100644 index 0000000..82b4438 --- /dev/null +++ b/tests/api/change_password.rs @@ -0,0 +1,115 @@ +use uuid::Uuid; + +use crate::helpers::{TestApp, assert_is_redirect_to}; + +#[tokio::test] +async fn you_must_be_logged_in_to_see_the_change_password_form() { + let app = TestApp::spawn().await; + + let response = app.get_change_password().await; + + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn you_must_be_logged_in_to_change_your_password() { + let app = TestApp::spawn().await; + + let new_password = Uuid::new_v4().to_string(); + let response = app + .post_change_password(&serde_json::json!({ + "current_password": Uuid::new_v4().to_string(), + "new_password": new_password, + "new_password_check": new_password, + })) + .await; + + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn new_password_fields_must_match() { + let app = TestApp::spawn().await; + + app.post_login(&serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password, + })) + .await; + + let new_password = Uuid::new_v4().to_string(); + let another_new_password = Uuid::new_v4().to_string(); + let response = app + .post_change_password(&serde_json::json!({ + "current_password": app.test_user.password, + "new_password": new_password, + "new_password_check": another_new_password, + })) + .await; + assert_is_redirect_to(&response, "/admin/password"); + + let html_page = app.get_change_password_html().await; + assert!(html_page.contains("You entered two different passwords")); +} + +#[tokio::test] +async fn current_password_is_invalid() { + let app = TestApp::spawn().await; + + app.post_login(&serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password, + })) + .await; + + let new_password = Uuid::new_v4().to_string(); + let response = app + .post_change_password(&serde_json::json!({ + "current_password": Uuid::new_v4().to_string(), + "new_password": new_password, + "new_password_check": new_password, + })) + .await; + assert_is_redirect_to(&response, "/admin/password"); + + let html_page = app.get_change_password_html().await; + assert!(html_page.contains("The current password is incorrect")); +} + +#[tokio::test] +async fn changing_password_works() { + let app = TestApp::spawn().await; + + let login_body = &serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password, + }); + let response = app.post_login(login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); + + let new_password = Uuid::new_v4().to_string(); + let response = app + .post_change_password(&serde_json::json!({ + "current_password": app.test_user.password, + "new_password": new_password, + "new_password_check": new_password, + })) + .await; + assert_is_redirect_to(&response, "/admin/password"); + + let html_page = app.get_change_password_html().await; + assert!(html_page.contains("Your password has been changed")); + + let response = app.post_logout().await; + assert_is_redirect_to(&response, "/login"); + + let html_page = app.get_login_html().await; + assert!(html_page.contains("You have successfully logged out")); + + let login_body = &serde_json::json!({ + "username": app.test_user.username, + "password": new_password, + }); + let response = app.post_login(login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 0ed7bf5..5c18a19 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,5 +1,5 @@ use argon2::{ - Argon2, PasswordHasher, + Algorithm, Argon2, Params, PasswordHasher, Version, password_hash::{SaltString, rand_core::OsRng}, }; use linkify::LinkFinder; @@ -43,10 +43,14 @@ impl TestUser { pub async fn store(&self, connection_pool: &PgPool) { let salt = SaltString::generate(&mut OsRng); - let password_hash = Argon2::default() - .hash_password(self.password.as_bytes(), &salt) - .unwrap() - .to_string(); + let password_hash = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(1500, 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, @@ -144,6 +148,30 @@ impl TestApp { .unwrap() } + pub async fn get_admin_dashboard(&self) -> reqwest::Response { + self.api_client + .get(format!("{}/admin/dashboard", &self.address)) + .send() + .await + .expect("Failed to execute request") + } + + pub async fn get_admin_dashboard_html(&self) -> String { + self.get_admin_dashboard().await.text().await.unwrap() + } + + pub async fn get_change_password(&self) -> reqwest::Response { + self.api_client + .get(format!("{}/admin/password", &self.address)) + .send() + .await + .expect("Failed to execute request") + } + + pub async fn get_change_password_html(&self) -> String { + self.get_change_password().await.text().await.unwrap() + } + pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { self.api_client .post(format!("{}/subscriptions", self.address)) @@ -173,7 +201,27 @@ impl TestApp { .form(body) .send() .await - .expect("Failed to execute request") + .expect("failed to execute request") + } + + pub async fn post_logout(&self) -> reqwest::Response { + self.api_client + .post(format!("{}/admin/logout", self.address)) + .send() + .await + .expect("failed to execute request") + } + + pub async fn post_change_password(&self, body: &Body) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .post(format!("{}/admin/password", self.address)) + .form(body) + .send() + .await + .expect("failed to execute request") } } diff --git a/tests/api/login.rs b/tests/api/login.rs index d4c9b2f..626c028 100644 --- a/tests/api/login.rs +++ b/tests/api/login.rs @@ -20,3 +20,19 @@ async fn an_error_flash_message_is_set_on_failure() { let login_page_html = app.get_login_html().await; assert!(!login_page_html.contains("Authentication failed")); } + +#[tokio::test] +async fn login_redirects_to_admin_dashboard_after_login_success() { + let app = TestApp::spawn().await; + + let login_body = serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password + }); + + let response = app.post_login(&login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); + + let html_page = app.get_admin_dashboard_html().await; + assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); +} diff --git a/tests/api/main.rs b/tests/api/main.rs index d7a12d3..51eb8f2 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,3 +1,5 @@ +mod admin_dashboard; +mod change_password; mod health_check; mod helpers; mod login;