From 62191a0f3ffce7636b427ae976b5b707db07dcc9 Mon Sep 17 00:00:00 2001 From: Piotr Orzechowski Date: Thu, 11 Apr 2024 21:44:39 +0200 Subject: [PATCH] Add admin password and logout endpoints --- ...0680697a500ffe1af1d1b39108910594b581b.json | 15 ++ Cargo.lock | 23 +- Cargo.toml | 2 +- migrations/20240407064230_seed_user.sql | 7 + src/app_state.rs | 2 +- src/authentication.rs | 44 +++- src/lib.rs | 2 +- src/routes/admin/dashboard.rs | 45 ++-- src/routes/admin/logout.rs | 19 ++ src/routes/admin/mod.rs | 15 +- src/routes/admin/password/action.rs | 81 +++++++ src/routes/admin/password/form.rs | 36 +++ src/routes/admin/password/mod.rs | 5 + src/routes/login/action.rs | 63 +++-- src/routes/login/form.rs | 35 ++- src/session/extract.rs | 28 +++ src/session/middleware.rs | 115 ++++++++++ src/session/mod.rs | 3 + src/{session_state.rs => session/state.rs} | 8 + src/startup.rs | 10 +- templates/web/change_password_form.html | 27 +++ templates/web/dashboard.html | 13 +- templates/web/login_form.html | 6 +- tests/api/admin_dashboard.rs | 5 +- tests/api/admin_password.rs | 216 ++++++++++++++++++ tests/api/helpers.rs | 60 ++++- tests/api/login.rs | 15 +- tests/api/main.rs | 1 + 28 files changed, 776 insertions(+), 125 deletions(-) create mode 100644 .sqlx/query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json create mode 100644 migrations/20240407064230_seed_user.sql create mode 100644 src/routes/admin/logout.rs create mode 100644 src/routes/admin/password/action.rs create mode 100644 src/routes/admin/password/form.rs create mode 100644 src/routes/admin/password/mod.rs create mode 100644 src/session/extract.rs create mode 100644 src/session/middleware.rs create mode 100644 src/session/mod.rs rename src/{session_state.rs => session/state.rs} (86%) create mode 100644 templates/web/change_password_form.html create mode 100644 tests/api/admin_password.rs diff --git a/.sqlx/query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json b/.sqlx/query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json new file mode 100644 index 0000000..946a605 --- /dev/null +++ b/.sqlx/query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET password_hash = $1\n WHERE user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b" +} diff --git a/Cargo.lock b/Cargo.lock index 52043fd..01d272f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,26 +252,19 @@ dependencies = [ ] [[package]] -name = "axum-extra" -version = "0.9.3" +name = "axum-messages" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +checksum = "c9cdcfb99b05dca5351c6999fbc7d4cdcf283fb703788b6a431a516ecdde51bb" dependencies = [ - "axum", + "async-trait", "axum-core", - "bytes", - "cookie 0.18.1", - "futures-util", "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "mime", - "pin-project-lite", + "parking_lot", "serde", + "serde_json", "tower", - "tower-layer", - "tower-service", - "tracing", + "tower-sessions-core", ] [[package]] @@ -3497,7 +3490,7 @@ dependencies = [ "askama", "askama_axum", "axum", - "axum-extra", + "axum-messages", "base64 0.22.0", "claims", "config", diff --git a/Cargo.toml b/Cargo.toml index 974b758..4f4828d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/migrations/20240407064230_seed_user.sql b/migrations/20240407064230_seed_user.sql new file mode 100644 index 0000000..f364011 --- /dev/null +++ b/migrations/20240407064230_seed_user.sql @@ -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' +); \ No newline at end of file diff --git a/src/app_state.rs b/src/app_state.rs index da95e59..bf7ac48 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -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 { diff --git a/src/authentication.rs b/src/authentication.rs index 821d0c7..b10e136 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -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; @@ -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, +) -> 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) -> Result, 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, diff --git a/src/lib.rs b/src/lib.rs index 306d74d..f2d657f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/routes/admin/dashboard.rs b/src/routes/admin/dashboard.rs index 5a0e28a..7b5e6a8 100644 --- a/src/routes/admin/dashboard.rs +++ b/src/routes/admin/dashboard.rs @@ -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, - session: TypedSession, -) -> Result> { - 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, HttpError> { + 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 { +#[tracing::instrument(skip(db_pool, user_id))] +pub async fn get_username(db_pool: &PgPool, user_id: Uuid) -> Result { let row = sqlx::query!( r#" SELECT username @@ -48,7 +47,11 @@ async fn get_username(db_pool: &PgPool, user_id: Uuid) -> Result #[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, } diff --git a/src/routes/admin/logout.rs b/src/routes/admin/logout.rs new file mode 100644 index 0000000..b804db0 --- /dev/null +++ b/src/routes/admin/logout.rs @@ -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> { + 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")) +} diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index 4a6618b..0a8c94c 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -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 { - 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)) } diff --git a/src/routes/admin/password/action.rs b/src/routes/admin/password/action.rs new file mode 100644 index 0000000..48dc448 --- /dev/null +++ b/src/routes/admin/password/action.rs @@ -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, + SessionUserId(user_id): SessionUserId, + messages: Messages, + Form(form): Form, +) -> Result> { + 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, + new_password: Secret, + new_password_check: Secret, +} + +fn validate_password(password: &Secret) -> 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(()) +} diff --git a/src/routes/admin/password/form.rs b/src/routes/admin/password/form.rs new file mode 100644 index 0000000..d721ace --- /dev/null +++ b/src/routes/admin/password/form.rs @@ -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, +} diff --git a/src/routes/admin/password/mod.rs b/src/routes/admin/password/mod.rs new file mode 100644 index 0000000..202df11 --- /dev/null +++ b/src/routes/admin/password/mod.rs @@ -0,0 +1,5 @@ +mod action; +mod form; + +pub(super) use action::change_password; +pub(super) use form::change_password_form; diff --git a/src/routes/login/action.rs b/src/routes/login/action.rs index 8271ca1..ca6f0f2 100644 --- a/src/routes/login/action.rs +++ b/src/routes/login/action.rs @@ -1,7 +1,7 @@ use crate::{ app_state::AppState, authentication::{validate_credentials, AuthError, Credentials}, - session_state::TypedSession, + session::state::TypedSession, }; use axum::{ extract::State, @@ -9,21 +9,18 @@ use axum::{ response::{IntoResponse, Redirect, Response}, Form, }; -use axum_extra::extract::{ - cookie::{Cookie, SameSite}, - SignedCookieJar, -}; +use axum_messages::Messages; use secrecy::Secret; use serde::Deserialize; #[tracing::instrument( - skip(app_state, form, session, jar), + skip(app_state, session, messages, form), fields(username = tracing::field::Empty, user_id = tracing::field::Empty) )] pub(super) async fn login( State(app_state): State, session: TypedSession, - jar: SignedCookieJar, + messages: Messages, Form(form): Form, ) -> Result { tracing::Span::current().record("username", &tracing::field::display(&form.username)); @@ -40,7 +37,10 @@ pub(super) async fn login( Ok(user_id) => user_id, Err(e) => match e { AuthError::InvalidCredentials(_) => { - return Err(LoginErrorResponse::new_auth_with_redirect(e.into(), jar)); + return Err(LoginErrorResponse::new_auth_with_redirect( + e.into(), + messages, + )); } AuthError::UnexpectedError(_) => { return Err(LoginErrorResponse::new_unexpected(e.into())); @@ -52,15 +52,14 @@ pub(super) async fn login( if let Err(e) = session.cycle_id().await { return Err(LoginErrorResponse::new_unexpected_with_redirect( - e.into(), - jar, + e, messages, )); } session .insert_user_id(user_id) .await - .map_err(|e| LoginErrorResponse::new_unexpected_with_redirect(e.into(), jar))?; + .map_err(|e| LoginErrorResponse::new_unexpected_with_redirect(e, messages))?; Ok(Redirect::to("/admin/dashboard")) } @@ -73,25 +72,29 @@ pub(super) struct FormData { pub(super) struct LoginErrorResponse { error: LoginError, - jar: Option, + messages: Option, } impl LoginErrorResponse { fn new_unexpected(error: anyhow::Error) -> Self { - let error = LoginError::UnexpectedError(error); - Self { error, jar: None } + Self { + error: LoginError::UnexpectedError(error), + messages: None, + } } - fn new_auth_with_redirect(error: anyhow::Error, jar: SignedCookieJar) -> Self { - let error = LoginError::AuthError(error); - let jar = Some(jar.add(&error)); - Self { error, jar } + fn new_auth_with_redirect(error: anyhow::Error, messages: Messages) -> Self { + Self { + error: LoginError::AuthError(error), + messages: Some(messages), + } } - fn new_unexpected_with_redirect(error: anyhow::Error, jar: SignedCookieJar) -> Self { - let error = LoginError::UnexpectedError(error); - let jar = Some(jar.add(&error)); - Self { error, jar } + fn new_unexpected_with_redirect(error: anyhow::Error, messages: Messages) -> Self { + Self { + error: LoginError::UnexpectedError(error), + messages: Some(messages), + } } } @@ -99,8 +102,11 @@ impl IntoResponse for LoginErrorResponse { fn into_response(self) -> Response { tracing::error!("{:#?}", self.error); - match (self.error, self.jar) { - (_, Some(jar)) => (jar, Redirect::to("/login")).into_response(), + match (self.error, self.messages) { + (error, Some(messages)) => { + messages.error(error.to_string()); + Redirect::to("/login").into_response() + } (LoginError::AuthError(_), None) => StatusCode::UNAUTHORIZED.into_response(), (LoginError::UnexpectedError(_), None) => { StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -116,12 +122,3 @@ enum LoginError { #[error("Something went wrong")] UnexpectedError(#[from] anyhow::Error), } - -impl From<&LoginError> for Cookie<'static> { - fn from(value: &LoginError) -> Self { - Cookie::build(("_flash", value.to_string())) - .http_only(true) - .same_site(SameSite::Strict) - .build() - } -} diff --git a/src/routes/login/form.rs b/src/routes/login/form.rs index 97437fd..5a1d8da 100644 --- a/src/routes/login/form.rs +++ b/src/routes/login/form.rs @@ -1,25 +1,20 @@ use askama_axum::Template; -use axum_extra::extract::{cookie::Cookie, SignedCookieJar}; +use axum_messages::Messages; -#[tracing::instrument(skip(jar))] -pub(super) async fn login_form(jar: SignedCookieJar) -> (SignedCookieJar, LoginForm<'static>) { - const FLASH: &str = "_flash"; +#[tracing::instrument(skip(messages))] +pub(super) async fn login_form(messages: Messages) -> LoginForm<'static> { + let flashes = messages.map(|m| m.message).collect(); - let flash = jar.get(FLASH).map(|c| c.value().into()); - - ( - jar.remove(Cookie::from(FLASH)), - LoginForm { - title: "Login", - username_label: "Username", - username_placeholder: "Enter username", - password_label: "Password", - password_placeholder: "Enter password", - submit_label: "Login", - flash, - action: "/login", - }, - ) + LoginForm { + title: "Login", + username_label: "Username", + username_placeholder: "Enter username", + password_label: "Password", + password_placeholder: "Enter password", + submit_label: "Login", + flashes, + action: "/login", + } } #[derive(Template)] @@ -31,6 +26,6 @@ pub(super) struct LoginForm<'a> { password_label: &'a str, password_placeholder: &'a str, submit_label: &'a str, - flash: Option, action: &'a str, + flashes: Vec, } diff --git a/src/session/extract.rs b/src/session/extract.rs new file mode 100644 index 0000000..b3fe62d --- /dev/null +++ b/src/session/extract.rs @@ -0,0 +1,28 @@ +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use uuid::Uuid; + +pub struct SessionUserId(pub Uuid); + +#[async_trait] +impl FromRequestParts for SessionUserId +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .map(SessionUserId) + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "User id not present in session", + )) + } +} diff --git a/src/session/middleware.rs b/src/session/middleware.rs new file mode 100644 index 0000000..3d3940f --- /dev/null +++ b/src/session/middleware.rs @@ -0,0 +1,115 @@ +use crate::session::state::TypedSession; +use anyhow::anyhow; +use axum::http::{header::LOCATION, HeaderValue, Request, Response, StatusCode}; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use tower::{Layer, Service}; +use tower_sessions::Session; +use tracing::Instrument; + +#[derive(Debug, Clone)] +pub struct AuthorizedSessionLayer { + protected_paths: &'static [&'static str], +} + +impl AuthorizedSessionLayer { + pub fn new(protected_paths: &'static [&'static str]) -> Self { + Self { protected_paths } + } +} + +impl Layer for AuthorizedSessionLayer { + type Service = AuthorizedSession; + + fn layer(&self, inner: S) -> Self::Service { + AuthorizedSession { + inner, + protected_paths: self.protected_paths, + } + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizedSession { + inner: S, + protected_paths: &'static [&'static str], +} + +impl AuthorizedSession { + fn see_other() -> Response + where + ResBody: Default, + { + tracing::info!("User id not found in session"); + let mut res = Response::default(); + *res.status_mut() = StatusCode::SEE_OTHER; + res.headers_mut() + .insert(LOCATION, HeaderValue::from_static("/login")); + res + } + + fn internal_server_error(error: anyhow::Error) -> Response + where + ResBody: Default, + { + tracing::error!("{:#?}", error); + let mut res = Response::default(); + *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + res + } +} + +impl Service> for AuthorizedSession +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send, + ReqBody: Send + 'static, + ResBody: Default + Send, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + #[inline] + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let span = tracing::info_span!("call"); + let protected_paths = self.protected_paths; + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + + Box::pin( + async move { + if protected_paths.contains(&req.uri().path()) { + let Some(session) = req + .extensions() + .get::() + .cloned() + .map(TypedSession::new) + else { + return Ok(Self::internal_server_error(anyhow!("Session not found"))); + }; + + match session.get_user_id().await { + Ok(Some(user_id)) => { + tracing::info!("User id `{user_id}` found in session"); + req.extensions_mut().insert(user_id); + } + Ok(None) => return Ok(Self::see_other()), + Err(e) => return Ok(Self::internal_server_error(e)), + }; + } + + inner.call(req).await + } + .instrument(span), + ) + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..2aea949 --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,3 @@ +pub mod extract; +pub mod middleware; +pub mod state; diff --git a/src/session_state.rs b/src/session/state.rs similarity index 86% rename from src/session_state.rs rename to src/session/state.rs index e0e35ad..9de4145 100644 --- a/src/session_state.rs +++ b/src/session/state.rs @@ -12,6 +12,10 @@ pub struct TypedSession(Session); impl TypedSession { const USER_ID_KEY: &'static str = "user_id"; + pub fn new(session: Session) -> Self { + Self(session) + } + pub async fn cycle_id(&self) -> Result<(), Error> { self.0 .cycle_id() @@ -19,6 +23,10 @@ impl TypedSession { .context("Failed to cycle session id") } + pub async fn flush(&self) -> Result<(), Error> { + self.0.flush().await.context("Failed to flush session") + } + pub async fn insert_user_id(&self, user_id: Uuid) -> Result<(), Error> { self.0 .insert(Self::USER_ID_KEY, user_id) diff --git a/src/startup.rs b/src/startup.rs index 256777f..54087ec 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -4,11 +4,12 @@ use crate::{ email_client::EmailClient, request_id::RequestUuid, routes::{admin, health_check, home, login, newsletters, subscriptions, subscriptions_confirm}, + session::middleware::AuthorizedSessionLayer, telemetry::request_span, }; use anyhow::anyhow; use axum::{http::Uri, serve::Serve, Router}; -use axum_extra::extract::cookie::Key; +use axum_messages::MessagesManagerLayer; use secrecy::{ExposeSecret, Secret}; use sqlx::{postgres::PgPoolOptions, PgPool}; use std::{net::SocketAddr, str::FromStr}; @@ -19,7 +20,7 @@ use tower_http::{ trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}, ServiceBuilderExt, }; -use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions::{cookie::Key, Expiry, SessionManagerLayer}; use tower_sessions_redis_store::{ fred::{ clients::RedisPool, @@ -136,6 +137,11 @@ async fn run( .merge(login::router()) .merge(admin::router()) .with_state(app_state) + .layer(AuthorizedSessionLayer::new(&[ + "/admin/dashboard", + "/admin/password", + ])) + .layer(MessagesManagerLayer) .layer( SessionManagerLayer::new(RedisStore::new(redis_pool)) .with_expiry(Expiry::OnInactivity(Duration::minutes(10))) diff --git a/templates/web/change_password_form.html b/templates/web/change_password_form.html new file mode 100644 index 0000000..5f61191 --- /dev/null +++ b/templates/web/change_password_form.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +{%- for flash in flashes %} +

{{ flash }}

+{%- endfor %} + +
+ +
+ +
+ +
+ +
+

<- {{ back_link }}

+{% endblock %} \ No newline at end of file diff --git a/templates/web/dashboard.html b/templates/web/dashboard.html index fb1b499..26d4262 100644 --- a/templates/web/dashboard.html +++ b/templates/web/dashboard.html @@ -1,5 +1,14 @@ {% extends "base.html" %} {% block content %} -

Welcome {{ username }}!

-{% endblock %} +

{{ welcome }}, {{ username }}!

+

{{ available_actions }}:

+
    +
  1. {{ change_password }}
  2. +
  3. +
    + +
    +
  4. +
+{% endblock %} \ No newline at end of file diff --git a/templates/web/login_form.html b/templates/web/login_form.html index 44e7533..d094cc4 100644 --- a/templates/web/login_form.html +++ b/templates/web/login_form.html @@ -1,9 +1,9 @@ {% extends "base.html" %} {% block content %} -{%- if let Some(flash) = flash %} +{%- for flash in flashes %}

{{ flash }}

-{%- endif %} +{%- endfor %}