From fbde51b8fd95745b0cb1856af5d1803a76089d33 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:08:41 -0600 Subject: [PATCH] feat: Add login/logout audit logging (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add login event logging to getUserStatus() controller method - Create POST /auth/track-logout endpoint for logout tracking Frontend: - Create useLogout hook that wraps Auth0 logout with audit tracking - Update all logout locations to use the new hook (SettingsPage, Layout, MobileSettingsScreen, useDeletion) Login events are logged when the frontend calls /auth/user-status after Auth0 callback. Logout events are logged via fire-and-forget call to /auth/track-logout before Auth0 logout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/features/auth/api/auth.controller.ts | 53 +++++++++++++++++++ backend/src/features/auth/api/auth.routes.ts | 6 +++ frontend/src/components/Layout.tsx | 6 ++- frontend/src/core/auth/useLogout.ts | 46 ++++++++++++++++ .../features/settings/hooks/useDeletion.ts | 7 +-- .../settings/mobile/MobileSettingsScreen.tsx | 10 ++-- frontend/src/pages/SettingsPage.tsx | 6 ++- 7 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 frontend/src/core/auth/useLogout.ts diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts index 63823a5..2de1a33 100644 --- a/backend/src/features/auth/api/auth.controller.ts +++ b/backend/src/features/auth/api/auth.controller.ts @@ -193,6 +193,9 @@ export class AuthController { * GET /api/auth/user-status * Get user status for routing decisions * Protected endpoint - requires JWT + * + * Note: This endpoint is called once per Auth0 callback (from CallbackPage/CallbackMobileScreen). + * We log the login event here since it's the first authenticated request after Auth0 redirect. */ async getUserStatus(request: FastifyRequest, reply: FastifyReply) { try { @@ -200,6 +203,17 @@ export class AuthController { const result = await this.authService.getUserStatus(userId); + // Log login event to audit trail (called once per Auth0 callback) + const ipAddress = this.getClientIp(request); + await auditLogService.info( + 'auth', + userId, + 'User login', + 'user', + userId, + { ipAddress } + ).catch(err => logger.error('Failed to log login audit event', { error: err })); + logger.info('User status retrieved', { userId: userId.substring(0, 8) + '...', emailVerified: result.emailVerified, @@ -287,4 +301,43 @@ export class AuthController { }); } } + + /** + * POST /api/auth/track-logout + * Track user logout event for audit logging + * Protected endpoint - requires JWT + * + * Called by frontend before Auth0 logout to capture the logout event. + * Returns success even if audit logging fails (non-blocking). + */ + async trackLogout(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + const ipAddress = this.getClientIp(request); + + // Log logout event to audit trail + await auditLogService.info( + 'auth', + userId, + 'User logout', + 'user', + userId, + { ipAddress } + ).catch(err => logger.error('Failed to log logout audit event', { error: err })); + + logger.info('User logout tracked', { + userId: userId.substring(0, 8) + '...', + }); + + return reply.code(200).send({ success: true }); + } catch (error: any) { + // Don't block logout on audit failure - always return success + logger.error('Failed to track logout', { + error, + userId: (request as any).user?.sub, + }); + + return reply.code(200).send({ success: true }); + } + } } diff --git a/backend/src/features/auth/api/auth.routes.ts b/backend/src/features/auth/api/auth.routes.ts index 4941d32..a3b460b 100644 --- a/backend/src/features/auth/api/auth.routes.ts +++ b/backend/src/features/auth/api/auth.routes.ts @@ -48,4 +48,10 @@ export const authRoutes: FastifyPluginAsync = async ( preHandler: [fastify.authenticate], handler: authController.requestPasswordReset.bind(authController), }); + + // POST /api/auth/track-logout - Track logout event for audit (requires JWT) + fastify.post('/auth/track-logout', { + preHandler: [fastify.authenticate], + handler: authController.trackLogout.bind(authController), + }); }; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 58297d5..161db61 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { Link, useLocation } from 'react-router-dom'; +import { useLogout } from '../core/auth/useLogout'; import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; @@ -26,7 +27,8 @@ interface LayoutProps { } export const Layout: React.FC = ({ children, mobileMode = false }) => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const location = useLocation(); @@ -222,7 +224,7 @@ export const Layout: React.FC = ({ children, mobileMode = false }) variant="secondary" size="sm" className="w-full" - onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })} + onClick={() => logout()} > Sign Out diff --git a/frontend/src/core/auth/useLogout.ts b/frontend/src/core/auth/useLogout.ts new file mode 100644 index 0000000..d80cfec --- /dev/null +++ b/frontend/src/core/auth/useLogout.ts @@ -0,0 +1,46 @@ +/** + * @ai-summary Custom logout hook with audit logging + * @ai-context Tracks logout event before Auth0 logout for audit trail + */ + +import { useAuth0 } from '@auth0/auth0-react'; +import { useCallback } from 'react'; + +/** + * Custom hook that wraps Auth0 logout with audit tracking. + * Calls /api/auth/track-logout before performing Auth0 logout. + * The audit call is fire-and-forget to ensure logout always completes. + */ +export const useLogout = () => { + const { logout: auth0Logout, getAccessTokenSilently } = useAuth0(); + + const logout = useCallback(async () => { + // Fire-and-forget audit call (don't block logout) + try { + const token = await getAccessTokenSilently({ cacheMode: 'on' as const }); + if (token) { + // Use fetch directly to avoid axios interceptor issues during logout + fetch('/api/auth/track-logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }).catch(() => { + // Silently ignore errors - don't block logout + }); + } + } catch { + // Token not available - proceed with logout anyway + } + + // Perform Auth0 logout + auth0Logout({ + logoutParams: { + returnTo: window.location.origin, + }, + }); + }, [auth0Logout, getAccessTokenSilently]); + + return { logout }; +}; diff --git a/frontend/src/features/settings/hooks/useDeletion.ts b/frontend/src/features/settings/hooks/useDeletion.ts index ffd3253..f34237d 100644 --- a/frontend/src/features/settings/hooks/useDeletion.ts +++ b/frontend/src/features/settings/hooks/useDeletion.ts @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuth0 } from '@auth0/auth0-react'; +import { useLogout } from '../../../core/auth/useLogout'; import { profileApi } from '../api/profile.api'; import { RequestDeletionRequest } from '../types/profile.types'; import toast from 'react-hot-toast'; @@ -36,7 +37,7 @@ export const useDeletionStatus = () => { export const useRequestDeletion = () => { const queryClient = useQueryClient(); - const { logout } = useAuth0(); + const { logout } = useLogout(); return useMutation({ mutationFn: (data: RequestDeletionRequest) => profileApi.requestDeletion(data), @@ -45,9 +46,9 @@ export const useRequestDeletion = () => { queryClient.invalidateQueries({ queryKey: ['user-profile'] }); toast.success(response.data.message || 'Account deletion scheduled'); - // Logout after 2 seconds + // Logout after 2 seconds (with audit tracking) setTimeout(() => { - logout({ logoutParams: { returnTo: window.location.origin } }); + logout(); }, 2000); }, onError: (error: ApiError) => { diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index 7a02751..78e3f1a 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; +import { useLogout } from '../../../core/auth/useLogout'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { useSettings } from '../hooks/useSettings'; @@ -75,7 +76,8 @@ const Modal: React.FC = ({ isOpen, onClose, title, children }) => { }; export const MobileSettingsScreen: React.FC = () => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const { navigateToScreen } = useNavigationStore(); const { settings, updateSetting, isLoading, error } = useSettings(); const { data: profile, isLoading: profileLoading } = useProfile(); @@ -98,11 +100,7 @@ export const MobileSettingsScreen: React.FC = () => { }, [profile, isEditingProfile]); const handleLogout = () => { - logout({ - logoutParams: { - returnTo: window.location.origin - } - }); + logout(); }; const handleExportData = () => { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 716925c..6f57d09 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import React, { useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { useNavigate } from 'react-router-dom'; +import { useLogout } from '../core/auth/useLogout'; import { useUnits } from '../core/units/UnitsContext'; import { useAdminAccess } from '../core/auth/useAdminAccess'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; @@ -44,7 +45,8 @@ import CancelIcon from '@mui/icons-material/Cancel'; import { Card } from '../shared-minimal/components/Card'; export const SettingsPage: React.FC = () => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const navigate = useNavigate(); const { unitSystem, setUnitSystem } = useUnits(); const { isAdmin, loading: adminLoading } = useAdminAccess(); @@ -73,7 +75,7 @@ export const SettingsPage: React.FC = () => { }, [profile, isEditingProfile]); const handleLogout = () => { - logout({ logoutParams: { returnTo: window.location.origin } }); + logout(); }; const handleEditProfile = () => {