From 16a4704fcffcd61032460afb685d8cc00a05872a Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 5 Feb 2026 11:24:43 +0000 Subject: [PATCH] feat(account): add account page with passkey and token management - Add /account page with passkey list, token list, and sign-out - Replace nav login/logout buttons with user icon linking to /account or /login - Add passkey management: add new passkeys (WebAuthn ceremony), delete with confirmation - Add token management: create, copy one-time value, revoke with confirmation - Add user/key/clipboard icon macros - Add PasskeyCredentialRepository::get() for ownership verification - Add WebAuthn passkey/start and passkey/finish endpoints for adding passkeys --- src/application/routes/account.rs | 194 +++++++++ src/application/routes/mod.rs | 12 +- src/application/routes/webauthn.rs | 99 +++++ src/domain/repositories.rs | 1 + .../repositories/passkey_credentials.rs | 21 + templates/account.html | 368 ++++++++++++++++++ templates/nav.html | 22 +- templates/partials/icons.html | 19 + templates/webauthn.js | 35 ++ 9 files changed, 757 insertions(+), 14 deletions(-) create mode 100644 src/application/routes/account.rs create mode 100644 templates/account.html diff --git a/src/application/routes/account.rs b/src/application/routes/account.rs new file mode 100644 index 0000000..70bb5ed --- /dev/null +++ b/src/application/routes/account.rs @@ -0,0 +1,194 @@ +use askama::Template; +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use tower_cookies::Cookies; + +use crate::application::auth::AuthenticatedUser; +use crate::application::routes::render_html; +use crate::application::server::AppState; +use crate::domain::ids::PasskeyCredentialId; + +use super::auth::is_authenticated; + +// --- View types --- + +#[derive(Serialize)] +pub struct PasskeyView { + pub id: i64, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} + +#[derive(Serialize)] +pub struct TokenView { + pub id: i64, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} + +fn format_date(dt: DateTime) -> String { + dt.format("%Y-%m-%d").to_string() +} + +// --- Templates --- + +#[derive(Template)] +#[template(path = "account.html")] +struct AccountTemplate { + nav_active: &'static str, + is_authenticated: bool, + passkeys: Vec, + tokens: Vec, +} + +// --- Page handler --- + +pub(crate) async fn account_page( + State(state): State, + cookies: Cookies, +) -> Result { + if !is_authenticated(&state, &cookies).await { + return Ok(Redirect::to("/login").into_response()); + } + + // We need the authenticated user for repo queries — re-extract from session + let auth_user = extract_user_from_session(&state, &cookies) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + + let passkeys = state + .passkey_repo + .list_by_user(auth_user.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_iter() + .map(|p| PasskeyView { + id: i64::from(p.id), + name: p.name, + created_at: format_date(p.created_at), + last_used_at: p.last_used_at.map(format_date), + }) + .collect(); + + let tokens = state + .token_repo + .list_by_user(auth_user.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_iter() + .filter(crate::domain::tokens::Token::is_active) + .map(|t| TokenView { + id: i64::from(t.id), + name: t.name, + created_at: format_date(t.created_at), + last_used_at: t.last_used_at.map(format_date), + }) + .collect(); + + let template = AccountTemplate { + nav_active: "account", + is_authenticated: true, + passkeys, + tokens, + }; + + render_html(template).map(IntoResponse::into_response) +} + +// --- Passkey API --- + +#[derive(Serialize)] +pub struct PasskeyResponse { + pub id: i64, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +pub(crate) async fn list_passkeys( + State(state): State, + auth_user: AuthenticatedUser, +) -> Result>, StatusCode> { + let passkeys = state + .passkey_repo + .list_by_user(auth_user.0.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = passkeys + .into_iter() + .map(|p| PasskeyResponse { + id: i64::from(p.id), + name: p.name, + created_at: p.created_at, + last_used_at: p.last_used_at, + }) + .collect(); + + Ok(Json(responses)) +} + +pub(crate) async fn delete_passkey( + State(state): State, + auth_user: AuthenticatedUser, + Path(passkey_id): Path, +) -> Result { + // Verify the passkey belongs to the user + let passkey = state + .passkey_repo + .get(passkey_id) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + if passkey.user_id != auth_user.0.id { + return Err(StatusCode::FORBIDDEN); + } + + // Ensure the user has more than one passkey + let all_passkeys = state + .passkey_repo + .list_by_user(auth_user.0.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if all_passkeys.len() <= 1 { + return Err(StatusCode::CONFLICT); + } + + state + .passkey_repo + .delete(passkey_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::NO_CONTENT) +} + +// --- Helpers --- + +async fn extract_user_from_session( + state: &AppState, + cookies: &Cookies, +) -> Option { + let cookie = cookies.get("brewlog_session")?; + let session_token = cookie.value(); + let session_token_hash = crate::infrastructure::auth::hash_token(session_token); + + let session = state + .session_repo + .get_by_token_hash(&session_token_hash) + .await + .ok()?; + + if session.is_expired() { + return None; + } + + state.user_repo.get(session.user_id).await.ok() +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index f3a3ed7..3b2dc0a 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod add; pub mod auth; pub mod backup; @@ -33,6 +34,7 @@ use crate::application::server::AppState; use crate::presentation::web::templates::render_template; +#[allow(clippy::too_many_lines)] pub fn app_router(state: AppState) -> axum::Router { let api_routes = axum::Router::new() // Public API routes @@ -100,6 +102,11 @@ pub fn app_router(state: AppState) -> axum::Router { post(tokens::create_token).get(tokens::list_tokens), ) .route("/tokens/:id/revoke", post(tokens::revoke_token)) + .route("/passkeys", get(account::list_passkeys)) + .route( + "/passkeys/:id", + axum::routing::delete(account::delete_passkey), + ) .route("/backup", get(backup::export_backup)) .route( "/backup/restore", @@ -110,12 +117,15 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/register/start", post(webauthn::register_start)) .route("/register/finish", post(webauthn::register_finish)) .route("/auth/start", get(webauthn::auth_start)) - .route("/auth/finish", post(webauthn::auth_finish)); + .route("/auth/finish", post(webauthn::auth_finish)) + .route("/passkey/start", post(webauthn::passkey_add_start)) + .route("/passkey/finish", post(webauthn::passkey_add_finish)); axum::Router::new() .route("/", get(home::home_page)) .route("/login", get(auth::login_page)) .route("/logout", post(auth::logout)) + .route("/account", get(account::account_page)) .route("/register/:token", get(webauthn::register_page)) .route("/auth/cli-callback", get(webauthn::cli_callback_page)) .route("/data", get(data::data_page)) diff --git a/src/application/routes/webauthn.rs b/src/application/routes/webauthn.rs index 55443b5..f00c008 100644 --- a/src/application/routes/webauthn.rs +++ b/src/application/routes/webauthn.rs @@ -10,6 +10,7 @@ use tracing::{error, info, warn}; use uuid::Uuid; use webauthn_rs::prelude::*; +use crate::application::auth::AuthenticatedUser; use crate::application::routes::render_html; use crate::application::server::AppState; use crate::domain::passkey_credentials::NewPasskeyCredential; @@ -398,6 +399,104 @@ pub(crate) async fn auth_finish( Ok(Json(AuthFinishResponse { redirect: None })) } +// --- Add passkey to existing account --- + +#[derive(Deserialize)] +pub struct PasskeyAddStartRequest { + pub name: String, +} + +#[derive(Deserialize)] +pub struct PasskeyAddFinishRequest { + pub challenge_id: String, + pub name: String, + pub credential: RegisterPublicKeyCredential, +} + +#[tracing::instrument(skip(state, auth_user, payload), fields(passkey_name = %payload.name))] +pub(crate) async fn passkey_add_start( + State(state): State, + auth_user: AuthenticatedUser, + Json(payload): Json, +) -> Result>, StatusCode> { + let user = auth_user.0; + let webauthn_uuid = + Uuid::parse_str(&user.uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Load existing credentials to exclude (prevents re-registering same authenticator) + let existing = state + .passkey_repo + .list_by_user(user.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let exclude_credentials = existing + .iter() + .filter_map(|c| serde_json::from_str::(&c.credential_json).ok()) + .map(|p| p.cred_id().clone()) + .collect::>(); + + let (ccr, reg_state) = state + .webauthn + .start_passkey_registration( + webauthn_uuid, + &user.username, + &user.username, + Some(exclude_credentials), + ) + .map_err(|err| { + error!(error = %err, "failed to start passkey registration for existing user"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let challenge_id = generate_session_token(); + state + .challenge_store + .store_registration(challenge_id.clone(), user.id, reg_state) + .await; + + Ok(Json(ChallengeResponse { + challenge_id, + options: ccr, + })) +} + +#[tracing::instrument(skip(state, payload))] +pub(crate) async fn passkey_add_finish( + State(state): State, + Json(payload): Json, +) -> Result { + let (user_id, reg_state) = state + .challenge_store + .take_registration(&payload.challenge_id) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + + let passkey = state + .webauthn + .finish_passkey_registration(&payload.credential, ®_state) + .map_err(|err| { + warn!(error = %err, "passkey add registration failed"); + StatusCode::BAD_REQUEST + })?; + + let credential_json = + serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.name); + state + .passkey_repo + .insert(new_credential) + .await + .map_err(|err| { + error!(error = %err, "failed to store new passkey credential"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + info!(user_id = %user_id, "additional passkey registered successfully"); + + Ok(StatusCode::OK) +} + // --- CLI callback page --- pub(crate) async fn cli_callback_page() -> Result { diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 3a50bda..f2ef840 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -231,6 +231,7 @@ pub trait PasskeyCredentialRepository: Send + Sync { &self, credential: NewPasskeyCredential, ) -> Result; + async fn get(&self, id: PasskeyCredentialId) -> Result; async fn list_by_user( &self, user_id: UserId, diff --git a/src/infrastructure/repositories/passkey_credentials.rs b/src/infrastructure/repositories/passkey_credentials.rs index 1eb79de..0f8dbf4 100644 --- a/src/infrastructure/repositories/passkey_credentials.rs +++ b/src/infrastructure/repositories/passkey_credentials.rs @@ -64,6 +64,27 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository { Ok(Self::to_domain(record)) } + async fn get(&self, id: PasskeyCredentialId) -> Result { + let sql = r" + SELECT id, user_id, credential_json, name, created_at, last_used_at + FROM passkey_credentials + WHERE id = ? + "; + + let record = query_as::<_, PasskeyCredentialRecord>(sql) + .bind(i64::from(id)) + .fetch_one(&self.pool) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => RepositoryError::NotFound, + err => { + RepositoryError::unexpected(format!("failed to get passkey credential: {err}")) + } + })?; + + Ok(Self::to_domain(record)) + } + async fn list_by_user( &self, user_id: UserId, diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..2dd4c3e --- /dev/null +++ b/templates/account.html @@ -0,0 +1,368 @@ +{% extends "base.html" %} {% import "partials/icons.html" as icons %} +{% block title %}Brewlog · Account{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} + +
+

Passkeys

+ +
+ {% for passkey in passkeys %} +
+
+
+ {% call icons::key("inline h-4 w-4 mr-1 text-amber-600") %}{{ passkey.name }} +
+ {% if passkeys.len() > 1 %} + + {% endif %} +
+
+

Added {{ passkey.created_at }}

+ {% if let Some(last_used) = passkey.last_used_at %} +

Last used {{ last_used }}

+ {% else %} +

Never used

+ {% endif %} +
+
+ {% endfor %} +
+ + {% if passkeys.len() <= 1 %} +

Add another passkey before removing your only one.

+ {% endif %} + + + + + +
+ + +
+

API Tokens

+ + {% if tokens.is_empty() %} +

No active tokens. Create one to use the CLI or API.

+ {% else %} +
+ {% for token in tokens %} +
+
+
+ {{ token.name }} +
+ +
+
+

Created {{ token.created_at }}

+ {% if let Some(last_used) = token.last_used_at %} +

Last used {{ last_used }}

+ {% else %} +

Never used

+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + + + + + + + +
+ + +
+
+ +
+
+ + +{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 1fcf550..5c134db 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -11,14 +11,12 @@ {% call icons::checkin("inline h-4 w-4") %} -
- -
+ + {% call icons::user("inline h-4 w-4") %} + {% else %} - {% call icons::login("inline h-4 w-4") %} + {% call icons::user("inline h-4 w-4") %} {% endif %} @@ -38,15 +36,13 @@ {% call icons::checkin("inline h-4 w-4") %} Check In -
- -
+ + {% call icons::user("inline h-4 w-4") %} + Account + {% else %} - {% call icons::login("inline h-4 w-4") %} + {% call icons::user("inline h-4 w-4") %} Login {% endif %} diff --git a/templates/partials/icons.html b/templates/partials/icons.html index d12a4fb..b4192bc 100644 --- a/templates/partials/icons.html +++ b/templates/partials/icons.html @@ -165,3 +165,22 @@ {% endmacro %} + +{% macro user(class) %} + +{% endmacro %} + +{% macro clipboard(class) %} + +{% endmacro %} + +{% macro key(class) %} + +{% endmacro %} diff --git a/templates/webauthn.js b/templates/webauthn.js index 69f8ad3..c117783 100644 --- a/templates/webauthn.js +++ b/templates/webauthn.js @@ -145,3 +145,38 @@ async function startPasskeyAuthentication(queryParams) { return finishResponse.json(); } + +// Add a passkey to an existing authenticated account +async function addPasskey(name) { + // 1. Get challenge from server + const startResponse = await fetch("/api/v1/webauthn/passkey/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (!startResponse.ok) { + throw new Error("Failed to start passkey registration (HTTP " + startResponse.status + ")."); + } + + const { challenge_id, options } = await startResponse.json(); + + // 2. Create credential via browser WebAuthn API + const creationOptions = prepareCreationOptions(options); + const credential = await navigator.credentials.create(creationOptions); + + // 3. Send credential to server + const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge_id, + name, + credential: serializeRegistrationCredential(credential), + }), + }); + + if (!finishResponse.ok) { + throw new Error("Failed to complete passkey registration (HTTP " + finishResponse.status + ")."); + } +}