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>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 14:35:27 +00:00 committed by Jon Seager
parent 650ca7a372
commit e6811d45ad
No known key found for this signature in database
13 changed files with 297 additions and 4 deletions

99
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

View file

@ -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::*;

View file

@ -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<RoasterView>,
pub navigator: ListNavigator<RoasterSortKey>,
}
@ -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<RoastView>,
}
@ -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<RoastView>,
pub roaster_options: Vec<RoasterOptionView>,
pub navigator: ListNavigator<RoastSortKey>,
@ -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<TimelineEventView>,
pub navigator: ListNavigator<TimelineSortKey>,
pub months: Vec<TimelineMonthView>,

99
src/server/routes/auth.rs Normal file
View file

@ -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<String>,
}
#[derive(Deserialize)]
pub struct LoginForm {
username: String,
password: String,
}
pub(crate) async fn login_page(cookies: Cookies) -> Result<Response, StatusCode> {
// 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<AppState>,
cookies: Cookies,
Form(form): Form<LoginForm>,
) -> Result<Response, StatusCode> {
// 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<Response, StatusCode> {
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()
}

View file

@ -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)
}

View file

@ -40,6 +40,7 @@ async fn load_roaster_page(
pub(crate) async fn roasters_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
@ -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<AppState>,
cookies: tower_cookies::Cookies,
Path(id): Path<String>,
) -> Result<Html<String>, 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(),
};

View file

@ -39,6 +39,7 @@ async fn load_roast_page(
pub(crate) async fn roasts_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
@ -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<AppState>,
cookies: tower_cookies::Cookies,
Path(id): Path<String>,
) -> Result<Html<String>, 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),
};

View file

@ -20,6 +20,7 @@ const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5;
pub(crate) async fn timeline_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
@ -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,

49
templates/login.html Normal file
View file

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Brewlog · Login{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
<h1 class="text-2xl font-semibold text-amber-700">Login</h1>
<p class="mt-2 text-sm text-stone-600">
Sign in to manage your roasters and roasts.
</p>
{% if error.is_some() %}
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
{{ error.as_ref().unwrap() }}
</div>
{% endif %}
<form method="post" action="/login" class="mt-6 flex flex-col gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Username</span>
<input
type="text"
name="username"
required
autofocus
class="input-field"
placeholder="admin"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Password</span>
<input
type="password"
name="password"
required
class="input-field"
/>
</label>
<button
type="submit"
class="mt-2 rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Sign In
</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -4,5 +4,12 @@
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
{% if is_authenticated %}
<form method="post" action="/logout" class="inline">
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400">Logout</button>
</form>
{% else %}
<a class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" href="/login">Login</a>
{% endif %}
</div>
</nav>

View file

@ -5,6 +5,7 @@
<h1 class="text-3xl font-semibold">Roasters</h1>
<p class="max-w-2xl text-sm text-stone-600">Browse the coffee roasters known to Brewlog.</p>
</div>
{% if is_authenticated %}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
@ -14,8 +15,10 @@
>
<span aria-hidden="true">+</span>
</button>
{% endif %}
</header>
{% if is_authenticated %}
<div
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
data-show="$showForm"
@ -96,6 +99,7 @@
</div>
</form>
</div>
{% endif %}
</section>
{% include "partials/roaster_list.html" %} {% endblock %}

View file

@ -5,7 +5,7 @@
<h1 class="text-3xl font-semibold">Roasts</h1>
<p class="max-w-2xl text-sm text-stone-600">Explore the latest roasts logged in Brewlog.</p>
</div>
{% if !roaster_options.is_empty() %}
{% if is_authenticated && !roaster_options.is_empty() %}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
@ -18,6 +18,7 @@
{% endif %}
</header>
{% if is_authenticated %}
{% if roaster_options.is_empty() %}
<div
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
@ -122,6 +123,7 @@
</form>
</div>
{% endif %}
{% endif %}
</section>
{% include "partials/roast_list.html" %} {% endblock %}