From e6811d45ade6faa5fd107cc9b8610ebdd947cd9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:35:27 +0000 Subject: [PATCH] feat(frontend): add login page and session-based authentication - Add tower and tower-cookies dependencies for session management - Create login page template with username/password form - Implement /login and /logout routes with cookie-based sessions - Update navigation bar to show Login/Logout based on auth state - Add is_authenticated field to all page templates - Hide create/update/delete UI controls when unauthenticated - Session tokens stored in secure HttpOnly cookies with SameSite=Lax - Password verification uses constant-time comparison via Argon2 Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com> --- Cargo.lock | 99 +++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + src/infrastructure/auth.rs | 7 +++ src/presentation/templates.rs | 5 ++ src/server/routes/auth.rs | 99 +++++++++++++++++++++++++++++++++++ src/server/routes/mod.rs | 6 +++ src/server/routes/roasters.rs | 7 +++ src/server/routes/roasts.rs | 8 +++ src/server/routes/timeline.rs | 4 ++ templates/login.html | 49 +++++++++++++++++ templates/nav.html | 7 +++ templates/roasters.html | 4 ++ templates/roasts.html | 4 +- 13 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 src/server/routes/auth.rs create mode 100644 templates/login.html diff --git a/Cargo.lock b/Cargo.lock index 62c988c..44992a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -350,6 +350,8 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tower 0.4.13", + "tower-cookies", "tracing", "tracing-subscriber", "wiremock", @@ -461,6 +463,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -555,6 +568,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -1426,6 +1448,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1634,6 +1662,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1864,7 +1898,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -2558,6 +2592,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2654,6 +2719,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -2670,6 +2746,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.6" @@ -2683,7 +2776,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] diff --git a/Cargo.toml b/Cargo.toml index dbcbf59..28b75c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ thiserror = "1.0" tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "signal"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower = "0.4" +tower-cookies = "0.10" [dev-dependencies] portpicker = "0.1" diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index 5a8c863..211f141 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -49,6 +49,13 @@ pub fn hash_token(token: &str) -> String { general_purpose::STANDARD.encode(&result) } +/// Generates a session token for cookie-based authentication +pub fn generate_session_token() -> String { + let mut token_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut token_bytes); + general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/presentation/templates.rs b/src/presentation/templates.rs index 3926297..4958071 100644 --- a/src/presentation/templates.rs +++ b/src/presentation/templates.rs @@ -12,6 +12,7 @@ use crate::domain::timeline::TimelineSortKey; #[template(path = "roasters.html")] pub struct RoastersTemplate { pub nav_active: &'static str, + pub is_authenticated: bool, pub roasters: Paginated, pub navigator: ListNavigator, } @@ -27,6 +28,7 @@ pub struct RoasterListTemplate { #[template(path = "roaster_detail.html")] pub struct RoasterDetailTemplate { pub nav_active: &'static str, + pub is_authenticated: bool, pub roaster: RoasterView, pub roasts: Vec, } @@ -35,6 +37,7 @@ pub struct RoasterDetailTemplate { #[template(path = "roasts.html")] pub struct RoastsTemplate { pub nav_active: &'static str, + pub is_authenticated: bool, pub roasts: Paginated, pub roaster_options: Vec, pub navigator: ListNavigator, @@ -44,6 +47,7 @@ pub struct RoastsTemplate { #[template(path = "roast_detail.html")] pub struct RoastDetailTemplate { pub nav_active: &'static str, + pub is_authenticated: bool, pub roast: RoastView, } @@ -58,6 +62,7 @@ pub struct RoastListTemplate { #[template(path = "timeline.html")] pub struct TimelineTemplate { pub nav_active: &'static str, + pub is_authenticated: bool, pub events: Paginated, pub navigator: ListNavigator, pub months: Vec, diff --git a/src/server/routes/auth.rs b/src/server/routes/auth.rs new file mode 100644 index 0000000..9132ff0 --- /dev/null +++ b/src/server/routes/auth.rs @@ -0,0 +1,99 @@ +use askama::Template; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Form; +use serde::Deserialize; +use tower_cookies::{Cookie, Cookies}; +use tracing::warn; +use crate::infrastructure::auth::{generate_session_token, verify_password}; +use crate::server::routes::render_html; +use crate::server::server::AppState; + +const SESSION_COOKIE_NAME: &str = "brewlog_session"; + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate { + nav_active: &'static str, + is_authenticated: bool, + error: Option, +} + +#[derive(Deserialize)] +pub struct LoginForm { + username: String, + password: String, +} + +pub(crate) async fn login_page(cookies: Cookies) -> Result { + // Check if already authenticated + if cookies.get(SESSION_COOKIE_NAME).is_some() { + return Ok(Redirect::to("/timeline").into_response()); + } + + let template = LoginTemplate { + nav_active: "login", + is_authenticated: false, + error: None, + }; + + render_html(template).map(IntoResponse::into_response) +} + +pub(crate) async fn login_submit( + State(state): State, + cookies: Cookies, + Form(form): Form, +) -> Result { + // Validate credentials + let user = match state.user_repo.get_by_username(&form.username).await { + Ok(user) => user, + Err(err) => { + warn!(username = %form.username, error = %err, "login attempt with non-existent username or error"); + return show_login_error("Invalid username or password"); + } + }; + + // Verify password + if !verify_password(&form.password, &user.password_hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + warn!(username = %form.username, "login attempt with incorrect password"); + return show_login_error("Invalid username or password"); + } + + // Create session token + let session_token = generate_session_token(); + + // Set secure cookie + let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token); + cookie.set_path("/"); + cookie.set_http_only(true); + cookie.set_same_site(tower_cookies::cookie::SameSite::Lax); + // In production, set secure flag: cookie.set_secure(true); + + cookies.add(cookie); + + Ok(Redirect::to("/timeline").into_response()) +} + +pub(crate) async fn logout(cookies: Cookies) -> Redirect { + cookies.remove(Cookie::from(SESSION_COOKIE_NAME)); + Redirect::to("/timeline") +} + +fn show_login_error(message: &str) -> Result { + let template = LoginTemplate { + nav_active: "login", + is_authenticated: false, + error: Some(message.to_string()), + }; + + render_html(template).map(IntoResponse::into_response) +} + +/// Check if user is authenticated based on session cookie +pub fn is_authenticated(cookies: &Cookies) -> bool { + cookies.get(SESSION_COOKIE_NAME).is_some() +} diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 54ef687..4db4f36 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod roasters; pub mod roasts; pub mod support; @@ -8,6 +9,8 @@ use askama::Template; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{get, post}; +use tower::ServiceBuilder; +use tower_cookies::CookieManagerLayer; use tracing::error; use crate::server::server::AppState; @@ -43,6 +46,8 @@ pub fn app_router(state: AppState) -> axum::Router { axum::Router::new() .route("/", get(root_redirect)) + .route("/login", get(auth::login_page).post(auth::login_submit)) + .route("/logout", post(auth::logout)) .route("/roasters", get(roasters::roasters_page)) .route("/roasters/:id", get(roasters::roaster_page)) .route("/roasts", get(roasts::roasts_page)) @@ -51,6 +56,7 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/styles.css", get(styles)) .route("/favicon.ico", get(favicon)) .nest("/api/v1", api_routes) + .layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) .with_state(state) } diff --git a/src/server/routes/roasters.rs b/src/server/routes/roasters.rs index 3636b26..558c2b1 100644 --- a/src/server/routes/roasters.rs +++ b/src/server/routes/roasters.rs @@ -40,6 +40,7 @@ async fn load_roaster_page( pub(crate) async fn roasters_page( State(state): State, + cookies: tower_cookies::Cookies, headers: HeaderMap, Query(query): Query, ) -> Result { @@ -55,8 +56,11 @@ pub(crate) async fn roasters_page( .await .map_err(|err| map_app_error(err))?; + let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies); + let template = RoastersTemplate { nav_active: "roasters", + is_authenticated, roasters, navigator, }; @@ -66,6 +70,7 @@ pub(crate) async fn roasters_page( pub(crate) async fn roaster_page( State(state): State, + cookies: tower_cookies::Cookies, Path(id): Path, ) -> Result, StatusCode> { let roaster = state @@ -80,9 +85,11 @@ pub(crate) async fn roaster_page( .map_err(|err| map_app_error(AppError::from(err)))?; let roaster_view = RoasterView::from(roaster); + let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies); let template = RoasterDetailTemplate { nav_active: "roasters", + is_authenticated, roaster: roaster_view, roasts: roasts.into_iter().map(RoastView::from_list_item).collect(), }; diff --git a/src/server/routes/roasts.rs b/src/server/routes/roasts.rs index 85ad366..bcc21f9 100644 --- a/src/server/routes/roasts.rs +++ b/src/server/routes/roasts.rs @@ -39,6 +39,7 @@ async fn load_roast_page( pub(crate) async fn roasts_page( State(state): State, + cookies: tower_cookies::Cookies, headers: HeaderMap, Query(query): Query, ) -> Result { @@ -62,8 +63,11 @@ pub(crate) async fn roasts_page( .await .map_err(|err| map_app_error(err))?; + let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies); + let template = RoastsTemplate { nav_active: "roasts", + is_authenticated, roasts, roaster_options, navigator, @@ -74,6 +78,7 @@ pub(crate) async fn roasts_page( pub(crate) async fn roast_page( State(state): State, + cookies: tower_cookies::Cookies, Path(id): Path, ) -> Result, StatusCode> { let roast = state @@ -87,8 +92,11 @@ pub(crate) async fn roast_page( .await .map_err(|err| map_app_error(AppError::from(err)))?; + let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies); + let template = RoastDetailTemplate { nav_active: "roasts", + is_authenticated, roast: RoastView::from_domain(roast, &roaster.name), }; diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs index 3fc2a08..c3614bd 100644 --- a/src/server/routes/timeline.rs +++ b/src/server/routes/timeline.rs @@ -20,6 +20,7 @@ const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5; pub(crate) async fn timeline_page( State(state): State, + cookies: tower_cookies::Cookies, headers: HeaderMap, Query(query): Query, ) -> Result { @@ -34,8 +35,11 @@ pub(crate) async fn timeline_page( .await .map_err(|err| map_app_error(err))?; + let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies); + let template = TimelineTemplate { nav_active: "timeline", + is_authenticated, events: data.events, navigator: data.navigator, months: data.months, diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..cc8bac6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Brewlog ยท Login{% endblock %} +{% block content %} +
+
+

Login

+

+ Sign in to manage your roasters and roasts. +

+ + {% if error.is_some() %} +
+ {{ error.as_ref().unwrap() }} +
+ {% endif %} + +
+ + + + + +
+
+
+{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index a5756ee..1790aed 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -4,5 +4,12 @@ Roasters Roasts Timeline + {% if is_authenticated %} +
+ +
+ {% else %} + Login + {% endif %} diff --git a/templates/roasters.html b/templates/roasters.html index b5d702e..9f7ce91 100644 --- a/templates/roasters.html +++ b/templates/roasters.html @@ -5,6 +5,7 @@

Roasters

Browse the coffee roasters known to Brewlog.

+ {% if is_authenticated %} + {% endif %} + {% if is_authenticated %}
+ {% endif %} {% include "partials/roaster_list.html" %} {% endblock %} diff --git a/templates/roasts.html b/templates/roasts.html index 3d6f5bb..331fda0 100644 --- a/templates/roasts.html +++ b/templates/roasts.html @@ -5,7 +5,7 @@

Roasts

Explore the latest roasts logged in Brewlog.

- {% if !roaster_options.is_empty() %} + {% if is_authenticated && !roaster_options.is_empty() %}