refactor(logging): log errors and security events in auth routes

- Log all map_err(|_| StatusCode) patterns in webauthn, tokens, and account handlers
- Log fire-and-forget errors: registration token mark_used, passkey credential
  counter update, passkey update_last_used
- Add security event logging: token create/revoke, passkey delete, login/logout,
  CLI token creation via passkey auth
- Log session delete failures and auth check errors
This commit is contained in:
Jon Seager 2026-02-05 18:13:47 +00:00
parent 5ee89549a3
commit 4ab00dd630
No known key found for this signature in database
4 changed files with 163 additions and 84 deletions

View file

@ -6,6 +6,7 @@ use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use tower_cookies::Cookies; use tower_cookies::Cookies;
use tracing::{error, info, warn};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::routes::render_html; use crate::application::routes::render_html;
@ -98,7 +99,10 @@ pub(crate) async fn account_page(
.passkey_repo .passkey_repo
.list_by_user(auth_user.id) .list_by_user(auth_user.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|err| {
error!(error = %err, "failed to list passkeys for account page");
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter() .into_iter()
.map(|p| PasskeyView { .map(|p| PasskeyView {
id: i64::from(p.id), id: i64::from(p.id),
@ -112,7 +116,10 @@ pub(crate) async fn account_page(
.token_repo .token_repo
.list_by_user(auth_user.id) .list_by_user(auth_user.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|err| {
error!(error = %err, "failed to list tokens for account page");
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter() .into_iter()
.filter(crate::domain::tokens::Token::is_active) .filter(crate::domain::tokens::Token::is_active)
.map(|t| TokenView { .map(|t| TokenView {
@ -123,17 +130,18 @@ pub(crate) async fn account_page(
}) })
.collect(); .collect();
let ai_usage = state let ai_usage = match state.ai_usage_repo.summary_for_user(auth_user.id).await {
.ai_usage_repo Ok(summary) => Some(summary),
.summary_for_user(auth_user.id) Err(err) => {
.await warn!(error = %err, "failed to load AI usage summary");
.ok() None
.filter(|s| s.total_calls > 0) }
.map(|s| AiUsageView { };
total_calls: s.total_calls, let ai_usage = ai_usage.filter(|s| s.total_calls > 0).map(|s| AiUsageView {
total_tokens: format_number(s.total_tokens), total_calls: s.total_calls,
total_cost: format_cost(s.total_cost), total_tokens: format_number(s.total_tokens),
}); total_cost: format_cost(s.total_cost),
});
let template = AccountTemplate { let template = AccountTemplate {
nav_active: "account", nav_active: "account",
@ -165,7 +173,10 @@ pub(crate) async fn list_passkeys(
.passkey_repo .passkey_repo
.list_by_user(auth_user.0.id) .list_by_user(auth_user.0.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, "failed to list passkeys");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let responses: Vec<PasskeyResponse> = passkeys let responses: Vec<PasskeyResponse> = passkeys
.into_iter() .into_iter()
@ -186,11 +197,10 @@ pub(crate) async fn delete_passkey(
Path(passkey_id): Path<PasskeyCredentialId>, Path(passkey_id): Path<PasskeyCredentialId>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
// Verify the passkey belongs to the user // Verify the passkey belongs to the user
let passkey = state let passkey = state.passkey_repo.get(passkey_id).await.map_err(|err| {
.passkey_repo error!(error = %err, %passkey_id, "failed to get passkey for deletion");
.get(passkey_id) StatusCode::NOT_FOUND
.await })?;
.map_err(|_| StatusCode::NOT_FOUND)?;
if passkey.user_id != auth_user.0.id { if passkey.user_id != auth_user.0.id {
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
@ -201,17 +211,21 @@ pub(crate) async fn delete_passkey(
.passkey_repo .passkey_repo
.list_by_user(auth_user.0.id) .list_by_user(auth_user.0.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, "failed to list passkeys for deletion check");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if all_passkeys.len() <= 1 { if all_passkeys.len() <= 1 {
return Err(StatusCode::CONFLICT); return Err(StatusCode::CONFLICT);
} }
state state.passkey_repo.delete(passkey_id).await.map_err(|err| {
.passkey_repo error!(error = %err, %passkey_id, "failed to delete passkey");
.delete(passkey_id) StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(%passkey_id, user_id = %auth_user.0.id, "passkey deleted");
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -226,15 +240,27 @@ async fn extract_user_from_session(
let session_token = cookie.value(); let session_token = cookie.value();
let session_token_hash = crate::infrastructure::auth::hash_token(session_token); let session_token_hash = crate::infrastructure::auth::hash_token(session_token);
let session = state let session = match state
.session_repo .session_repo
.get_by_token_hash(&session_token_hash) .get_by_token_hash(&session_token_hash)
.await .await
.ok()?; {
Ok(s) => s,
Err(err) => {
warn!(error = %err, "session lookup failed on account page");
return None;
}
};
if session.is_expired() { if session.is_expired() {
return None; return None;
} }
state.user_repo.get(session.user_id).await.ok() match state.user_repo.get(session.user_id).await {
Ok(user) => Some(user),
Err(err) => {
warn!(error = %err, user_id = %session.user_id, "user lookup failed for valid session");
None
}
}
} }

View file

@ -4,6 +4,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use tower_cookies::{Cookie, Cookies}; use tower_cookies::{Cookie, Cookies};
use tracing::{info, warn};
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::server::AppState; use crate::application::server::AppState;
@ -55,11 +56,13 @@ pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> R
.session_repo .session_repo
.get_by_token_hash(&session_token_hash) .get_by_token_hash(&session_token_hash)
.await .await
&& let Err(err) = state.session_repo.delete(session.id).await
{ {
let _ = state.session_repo.delete(session.id).await; warn!(error = %err, session_id = %session.id, "failed to delete session on logout");
} }
} }
info!("user logged out");
cookies.remove(Cookie::from(SESSION_COOKIE_NAME)); cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
Redirect::to("/") Redirect::to("/")
} }
@ -79,6 +82,9 @@ pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
.await .await
{ {
Ok(session) => !session.is_expired(), Ok(session) => !session.is_expired(),
Err(_) => false, Err(err) => {
warn!(error = %err, "session lookup failed during auth check");
false
}
} }
} }

View file

@ -3,6 +3,7 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::server::AppState; use crate::application::server::AppState;
@ -51,16 +52,20 @@ pub async fn create_token(
auth_user: AuthenticatedUser, auth_user: AuthenticatedUser,
Json(payload): Json<CreateTokenRequest>, Json(payload): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, StatusCode> { ) -> Result<Json<CreateTokenResponse>, StatusCode> {
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let token_value = generate_token().map_err(|err| {
error!(error = %err, "failed to generate token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_hash_value = hash_token(&token_value); let token_hash_value = hash_token(&token_value);
let new_token = NewToken::new(auth_user.0.id, token_hash_value, payload.name.clone()); let new_token = NewToken::new(auth_user.0.id, token_hash_value, payload.name.clone());
let stored_token = state let stored_token = state.token_repo.insert(new_token).await.map_err(|err| {
.token_repo error!(error = %err, "failed to store token");
.insert(new_token) StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(token_id = %stored_token.id, token_name = %stored_token.name, user_id = %auth_user.0.id, "API token created");
Ok(Json(CreateTokenResponse { Ok(Json(CreateTokenResponse {
id: stored_token.id, id: stored_token.id,
@ -78,7 +83,10 @@ pub async fn list_tokens(
.token_repo .token_repo
.list_by_user(auth_user.0.id) .list_by_user(auth_user.0.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, "failed to list tokens");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_responses: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect(); let token_responses: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
@ -92,11 +100,10 @@ pub async fn revoke_token(
Path(token_id): Path<TokenId>, Path(token_id): Path<TokenId>,
) -> Result<Json<TokenResponse>, StatusCode> { ) -> Result<Json<TokenResponse>, StatusCode> {
// Get the token to ensure it exists and belongs to the user // Get the token to ensure it exists and belongs to the user
let token = state let token = state.token_repo.get(token_id).await.map_err(|err| {
.token_repo error!(error = %err, %token_id, "failed to get token for revocation");
.get(token_id) StatusCode::NOT_FOUND
.await })?;
.map_err(|_| StatusCode::NOT_FOUND)?;
// Ensure the token belongs to the authenticated user // Ensure the token belongs to the authenticated user
if token.user_id != auth_user.0.id { if token.user_id != auth_user.0.id {
@ -104,11 +111,12 @@ pub async fn revoke_token(
} }
// Revoke the token // Revoke the token
let revoked_token = state let revoked_token = state.token_repo.revoke(token_id).await.map_err(|err| {
.token_repo error!(error = %err, %token_id, "failed to revoke token");
.revoke(token_id) StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(%token_id, user_id = %auth_user.0.id, "API token revoked");
Ok(Json(TokenResponse::from(revoked_token))) Ok(Json(TokenResponse::from(revoked_token)))
} }

View file

@ -138,7 +138,10 @@ pub(crate) async fn register_start(
.registration_token_repo .registration_token_repo
.get_by_token_hash(&token_hash) .get_by_token_hash(&token_hash)
.await .await
.map_err(|_| StatusCode::UNAUTHORIZED)?; .map_err(|err| {
warn!(error = %err, "registration token lookup failed");
StatusCode::UNAUTHORIZED
})?;
if !reg_token.is_valid() { if !reg_token.is_valid() {
return Err(StatusCode::GONE); return Err(StatusCode::GONE);
@ -153,14 +156,19 @@ pub(crate) async fn register_start(
})?; })?;
// Mark registration token as used // Mark registration token as used
let _ = state if let Err(err) = state
.registration_token_repo .registration_token_repo
.mark_used(reg_token.id, user.id) .mark_used(reg_token.id, user.id)
.await; .await
{
warn!(error = %err, token_id = %reg_token.id, "failed to mark registration token as used");
}
// Start passkey registration ceremony // Start passkey registration ceremony
let webauthn_uuid = let webauthn_uuid = Uuid::parse_str(&user_uuid).map_err(|err| {
Uuid::parse_str(&user_uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; error!(error = %err, "failed to parse user UUID");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let exclude_credentials = Vec::new(); let exclude_credentials = Vec::new();
let (ccr, reg_state) = state let (ccr, reg_state) = state
@ -214,8 +222,10 @@ pub(crate) async fn register_finish(
})?; })?;
// Store the credential // Store the credential
let credential_json = let credential_json = serde_json::to_string(&passkey).map_err(|err| {
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; error!(error = %err, "failed to serialize passkey credential");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.passkey_name); let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.passkey_name);
state state
.passkey_repo .passkey_repo
@ -242,11 +252,10 @@ pub(crate) async fn auth_start(
Query(query): Query<AuthStartQuery>, Query(query): Query<AuthStartQuery>,
) -> Result<Json<AuthStartResponse>, StatusCode> { ) -> Result<Json<AuthStartResponse>, StatusCode> {
// Load all passkey credentials from all users // Load all passkey credentials from all users
let users = state let users = state.user_repo.list_all().await.map_err(|err| {
.user_repo error!(error = %err, "failed to list users for auth start");
.list_all() StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut all_passkeys: Vec<Passkey> = Vec::new(); let mut all_passkeys: Vec<Passkey> = Vec::new();
for user in &users { for user in &users {
@ -254,7 +263,10 @@ pub(crate) async fn auth_start(
.passkey_repo .passkey_repo
.list_by_user(user.id) .list_by_user(user.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, user_id = %user.id, "failed to list passkeys for user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
for cred in credentials { for cred in credentials {
let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| {
@ -300,6 +312,7 @@ pub(crate) async fn auth_start(
// --- Authentication finish --- // --- Authentication finish ---
#[allow(clippy::too_many_lines)]
#[tracing::instrument(skip(state, cookies, payload))] #[tracing::instrument(skip(state, cookies, payload))]
pub(crate) async fn auth_finish( pub(crate) async fn auth_finish(
State(state): State<AppState>, State(state): State<AppState>,
@ -324,11 +337,10 @@ pub(crate) async fn auth_finish(
// Find the user who owns this credential // Find the user who owns this credential
let credential_id = auth_result.cred_id(); let credential_id = auth_result.cred_id();
let users = state let users = state.user_repo.list_all().await.map_err(|err| {
.user_repo error!(error = %err, "failed to list users for credential lookup");
.list_all() StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut found_user_id = None; let mut found_user_id = None;
let mut found_cred_id = None; let mut found_cred_id = None;
@ -339,11 +351,17 @@ pub(crate) async fn auth_finish(
.passkey_repo .passkey_repo
.list_by_user(user.id) .list_by_user(user.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, user_id = %user.id, "failed to list passkeys for user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
for cred in &credentials { for cred in &credentials {
let passkey: Passkey = serde_json::from_str(&cred.credential_json) let passkey: Passkey = serde_json::from_str(&cred.credential_json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey credential");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if passkey.cred_id() == credential_id { if passkey.cred_id() == credential_id {
found_user_id = Some(user.id); found_user_id = Some(user.id);
found_cred_id = Some(cred.id); found_cred_id = Some(cred.id);
@ -361,31 +379,45 @@ pub(crate) async fn auth_finish(
&& let Some(mut passkey) = found_passkey && let Some(mut passkey) = found_passkey
{ {
passkey.update_credential(&auth_result); passkey.update_credential(&auth_result);
if let Ok(updated_json) = serde_json::to_string(&passkey) { match serde_json::to_string(&passkey) {
let _ = state Ok(updated_json) => {
.passkey_repo if let Err(err) = state
.update_credential_json(cred_db_id, &updated_json) .passkey_repo
.await; .update_credential_json(cred_db_id, &updated_json)
.await
{
warn!(error = %err, credential_id = %cred_db_id, "failed to update passkey credential counter");
}
}
Err(err) => {
warn!(error = %err, "failed to serialize updated passkey credential");
}
} }
} }
// Update last used timestamp // Update last used timestamp
let passkey_repo = state.passkey_repo.clone(); let passkey_repo = state.passkey_repo.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _ = passkey_repo.update_last_used(cred_db_id).await; if let Err(err) = passkey_repo.update_last_used(cred_db_id).await {
warn!(error = %err, credential_id = %cred_db_id, "failed to update passkey last_used");
}
}); });
// Handle CLI callback flow // Handle CLI callback flow
if let Some(cli_info) = cli_callback { if let Some(cli_info) = cli_callback {
// Generate a bearer token for the CLI // Generate a bearer token for the CLI
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let token_value = generate_token().map_err(|err| {
error!(error = %err, "failed to generate CLI bearer token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_hash_value = hash_token(&token_value); let token_hash_value = hash_token(&token_value);
let new_token = NewToken::new(user_id, token_hash_value, cli_info.token_name); let new_token = NewToken::new(user_id, token_hash_value, cli_info.token_name);
state state.token_repo.insert(new_token).await.map_err(|err| {
.token_repo error!(error = %err, "failed to store CLI bearer token");
.insert(new_token) StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(user_id = %user_id, "CLI token created via passkey auth");
let redirect_url = format!( let redirect_url = format!(
"{}?token={}&state={}", "{}?token={}&state={}",
@ -425,15 +457,20 @@ pub(crate) async fn passkey_add_start(
Json(payload): Json<PasskeyAddStartRequest>, Json(payload): Json<PasskeyAddStartRequest>,
) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, StatusCode> { ) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, StatusCode> {
let user = auth_user.0; let user = auth_user.0;
let webauthn_uuid = let webauthn_uuid = Uuid::parse_str(&user.uuid).map_err(|err| {
Uuid::parse_str(&user.uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; error!(error = %err, "failed to parse user UUID for passkey add");
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Load existing credentials to exclude (prevents re-registering same authenticator) // Load existing credentials to exclude (prevents re-registering same authenticator)
let existing = state let existing = state
.passkey_repo .passkey_repo
.list_by_user(user.id) .list_by_user(user.id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
error!(error = %err, user_id = %user.id, "failed to list existing passkeys");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let exclude_credentials = existing let exclude_credentials = existing
.iter() .iter()
@ -485,8 +522,10 @@ pub(crate) async fn passkey_add_finish(
StatusCode::BAD_REQUEST StatusCode::BAD_REQUEST
})?; })?;
let credential_json = let credential_json = serde_json::to_string(&passkey).map_err(|err| {
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; error!(error = %err, "failed to serialize new passkey credential");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.name); let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.name);
state state
.passkey_repo .passkey_repo