From dc90674469cf0188bfd09ef18e80b59cc3e1ad42 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Sat, 27 Jun 2026 23:11:51 +0100 Subject: [PATCH] feat: biometric re-authentication timeout for sensitive operations. --- src/__tests__/hooks/useRequireReauth.test.ts | 116 +++++++++++++++++++ src/components/mobile/MobileProfile.tsx | 17 ++- src/components/mobile/MobileSettings.tsx | 67 ++++++++++- src/hooks/index.ts | 1 + src/hooks/useRequireReauth.ts | 47 ++++++++ src/store/deviceStore.ts | 6 + 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/hooks/useRequireReauth.test.ts create mode 100644 src/hooks/useRequireReauth.ts diff --git a/src/__tests__/hooks/useRequireReauth.test.ts b/src/__tests__/hooks/useRequireReauth.test.ts new file mode 100644 index 00000000..b635ef7e --- /dev/null +++ b/src/__tests__/hooks/useRequireReauth.test.ts @@ -0,0 +1,116 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { useRequireReauth } from '../../hooks/useRequireReauth'; +import { useDeviceStore } from '../../store/deviceStore'; +import { useBiometricAuth } from '../../hooks/useBiometricAuth'; + +jest.mock('../../hooks/useBiometricAuth', () => ({ + useBiometricAuth: jest.fn(), +})); + +const mockUseBiometricAuth = useBiometricAuth as jest.Mock; + +describe('useRequireReauth', () => { + const mockAuthenticate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + useDeviceStore.setState({ + lastBiometricAuth: null, + }); + mockUseBiometricAuth.mockReturnValue({ + authenticate: mockAuthenticate, + isAvailable: true, + isEnabled: true, + }); + }); + + it('triggers biometric challenge if lastBiometricAuth is null', async () => { + mockAuthenticate.mockResolvedValue({ user: {}, tokens: {} }); + + const { result } = renderHook(() => useRequireReauth()); + let allowed: boolean | null = null; + + await act(async () => { + allowed = await result.current.performReauthCheck(); + }); + + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(allowed).toBe(true); + expect(useDeviceStore.getState().lastBiometricAuth).not.toBeNull(); + }); + + it('skips biometric challenge if within 5-minute threshold', async () => { + const recentTime = Date.now() - 120000; // 2 minutes ago + useDeviceStore.setState({ + lastBiometricAuth: recentTime, + }); + + const { result } = renderHook(() => useRequireReauth(300000)); + let allowed: boolean | null = null; + + await act(async () => { + allowed = await result.current.performReauthCheck(); + }); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(allowed).toBe(true); + expect(useDeviceStore.getState().lastBiometricAuth).toBe(recentTime); + }); + + it('triggers biometric challenge if past 5-minute threshold', async () => { + const oldTime = Date.now() - 360000; // 6 minutes ago + useDeviceStore.setState({ + lastBiometricAuth: oldTime, + }); + mockAuthenticate.mockResolvedValue({ user: {}, tokens: {} }); + + const { result } = renderHook(() => useRequireReauth(300000)); + let allowed: boolean | null = null; + + await act(async () => { + allowed = await result.current.performReauthCheck(); + }); + + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(allowed).toBe(true); + expect(useDeviceStore.getState().lastBiometricAuth).toBeGreaterThan(oldTime); + }); + + it('blocks operation (returns false) if biometric challenge is cancelled/fails', async () => { + const oldTime = Date.now() - 360000; // 6 minutes ago + useDeviceStore.setState({ + lastBiometricAuth: oldTime, + }); + mockAuthenticate.mockResolvedValue(null); // biometric cancelled + + const { result } = renderHook(() => useRequireReauth(300000)); + let allowed: boolean | null = null; + + await act(async () => { + allowed = await result.current.performReauthCheck(); + }); + + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(allowed).toBe(false); + expect(useDeviceStore.getState().lastBiometricAuth).toBe(oldTime); // timestamp not updated + }); + + it('enforces minimum 1-minute threshold', async () => { + // 50 seconds ago, past the requested 30s threshold but within the enforced 60s minimum + const time = Date.now() - 50000; + useDeviceStore.setState({ + lastBiometricAuth: time, + }); + + const { result } = renderHook(() => useRequireReauth(30000)); // request 30s threshold + let allowed: boolean | null = null; + + await act(async () => { + allowed = await result.current.performReauthCheck(); + }); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(allowed).toBe(true); + }); +}); diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx index a573215c..51ee076c 100644 --- a/src/components/mobile/MobileProfile.tsx +++ b/src/components/mobile/MobileProfile.tsx @@ -20,6 +20,7 @@ import { import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, + Alert, Animated, Platform, SafeAreaView, @@ -35,7 +36,7 @@ import { Achievement, AchievementBadges } from './AchievementBadges'; import { AvatarCamera } from './AvatarCamera'; import { MobileFormInput } from './MobileFormInput'; import { StatisticsDisplay } from './StatisticsDisplay'; -import { useFormCache } from '../../hooks/useFormCache'; +import { useFormCache, useRequireReauth } from '../../hooks'; import { PROFILE_FORM_CACHE_KEYS, cacheFormValues } from '../../services/formCache'; import { configureNext } from '../../utils/layoutAnimation'; import { AppText as Text } from '../common/AppText'; @@ -262,6 +263,7 @@ export const MobileProfile: React.FC = ({ const [isEditing, setIsEditing] = useState(false); const [isCameraVisible, setIsCameraVisible] = useState(false); const [isSaving, setIsSaving] = useState(false); + const { performReauthCheck } = useRequireReauth(); const fadeAnim = useRef(new Animated.Value(isLoading ? 0 : 1)).current; @@ -364,6 +366,19 @@ export const MobileProfile: React.FC = ({ }, []); const handleSave = handleSubmit(async (data) => { + const isEmailChanged = data.email.trim() !== profile.email; + if (isEmailChanged) { + const authorized = await performReauthCheck(); + if (!authorized) { + Alert.alert( + 'Re-authentication Failed', + 'Biometric verification is required to change your account email.', + [{ text: 'OK' }] + ); + return; + } + } + setIsSaving(true); await new Promise(resolve => setTimeout(resolve, 800)); setProfile(prev => ({ diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index 29cc5c6a..4ae37f38 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -2,6 +2,7 @@ import { BarChart2, ChevronDown, ChevronUp, + CreditCard, Database, Download, Eye, @@ -10,6 +11,7 @@ import { LogOut, RefreshCw, Settings2, + ShieldAlert, Sun, Trash2, User, @@ -18,11 +20,12 @@ import { } from 'lucide-react-native'; import React, { memo, useCallback, useState } from 'react'; import { ActivityIndicator, Alert, ScrollView, TouchableOpacity, View } from 'react-native'; +import { useRouter } from 'expo-router'; import { NativeToggle } from './NativeToggle'; import { PickerOption, SettingsPicker } from './SettingsPicker'; import { SettingsSection } from './SettingsSection'; -import { useDynamicFontSize } from '../../hooks'; +import { useDynamicFontSize, useRequireReauth } from '../../hooks'; import { useBiometricAuth } from '../../hooks/useBiometricAuth'; import { useFormCache } from '../../hooks/useFormCache'; import { useAppStore, useTheme } from '../../store'; @@ -145,6 +148,8 @@ const AdvancedToggle = ({ expanded, onToggle }: AdvancedToggleProps) => { export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }: any) => { const theme = useTheme(); const setTheme = useAppStore(state => state.setTheme); + const router = useRouter(); + const { performReauthCheck } = useRequireReauth(); // Progressive disclosure: advanced settings collapsed by default const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); @@ -242,6 +247,42 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts } setShowAdvancedSettings(prev => !prev); }, []); + const handleChangePaymentMethod = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Payment Method', 'Payment method updated successfully.'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to change payment method.'); + } + }, [performReauthCheck]); + + const handleViewFullCardNumber = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Card Details', 'Card Number: **** **** **** 4242'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to view card details.'); + } + }, [performReauthCheck]); + + const handleExportData = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Export Data', 'Your personal data export request has been submitted successfully.'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to export personal data.'); + } + }, [performReauthCheck]); + + const handleAdminDashboard = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + router.push('/health-dashboard'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to access Admin Dashboard.'); + } + }, [performReauthCheck, router]); + return ( {/* ── ESSENTIAL: ACCOUNT ─────────────────────────────── */} @@ -287,6 +328,16 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts } )} } label="Change Password" onPress={onChangePassword} /> + } + label="Change Payment Method" + onPress={handleChangePaymentMethod} + /> + } + label="View Full Card Number" + onPress={handleViewFullCardNumber} + /> {/* ── ESSENTIAL: APP ─────────────────────────────────── */} @@ -332,6 +383,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts } onPress={handleClearFormCache} destructive /> + + } + label="Export Personal Data" + description="Export your account details and learning progress" + onPress={handleExportData} + /> {/* DOWNLOADS */} @@ -384,6 +442,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts } label="Clipboard Optimizer" description="Test & profile asynchronous clipboard operations" /> + + } + label="Admin Dashboard" + description="Access systems health & performance diagnostics" + onPress={handleAdminDashboard} + /> )} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b38d4884..9fa5c299 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useAdaptiveTheme'; export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; export * from './useBiometricAuth'; +export * from './useRequireReauth'; export * from './useCamera'; export * from './useCoursePagination'; export * from './useCourseProgress'; diff --git a/src/hooks/useRequireReauth.ts b/src/hooks/useRequireReauth.ts new file mode 100644 index 00000000..54f96ea3 --- /dev/null +++ b/src/hooks/useRequireReauth.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react'; + +import { useBiometricAuth } from './useBiometricAuth'; +import { useDeviceStore } from '../store/deviceStore'; + +/** + * Hook to enforce biometric re-authentication for sensitive operations. + * + * Checks if the last biometric authentication challenge occurred more than the threshold. + * If true, triggers a biometric challenge. If successful, updates the last auth timestamp + * and returns true. If authentication fails, cancels, or is unavailable, returns false. + * + * @param threshold The time in milliseconds after which re-authentication is required. + * Defaults to 5 minutes (300,000ms), minimum 1 minute (60,000ms). + */ +export function useRequireReauth(threshold = 300000) { + const actualThreshold = Math.max(threshold, 60000); // Enforce minimum of 1 minute + + const lastBiometricAuth = useDeviceStore(state => state.lastBiometricAuth); + const setLastBiometricAuth = useDeviceStore(state => state.setLastBiometricAuth); + const { authenticate } = useBiometricAuth(); + + const performReauthCheck = useCallback(async (): Promise => { + const now = Date.now(); + const needsReauth = + lastBiometricAuth === null || now - lastBiometricAuth > actualThreshold; + + if (needsReauth) { + try { + const result = await authenticate(); + if (result) { + setLastBiometricAuth(Date.now()); + return true; + } + return false; + } catch (error) { + return false; + } + } + + return true; + }, [lastBiometricAuth, setLastBiometricAuth, authenticate, actualThreshold]); + + return { + performReauthCheck, + }; +} diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts index d29c1ab9..18fde16f 100644 --- a/src/store/deviceStore.ts +++ b/src/store/deviceStore.ts @@ -12,11 +12,13 @@ interface DeviceState { isInBackground: boolean; /** True when the device is jailbroken (iOS) or rooted (Android) */ isDeviceCompromised: boolean; + lastBiometricAuth: number | null; // Actions updateBatteryInfo: (level: number, state: Battery.BatteryState, lowPowerMode: boolean) => void; setIsInBackground: (isBg: boolean) => void; setDeviceCompromised: (compromised: boolean) => void; + setLastBiometricAuth: (timestamp: number | null) => void; /** Runs jailbreak/root detection and updates state */ runDeviceCompromisedCheck: () => Promise; } @@ -28,6 +30,7 @@ export const useDeviceStore = create(set => ({ lowPowerMode: false, isInBackground: false, isDeviceCompromised: false, + lastBiometricAuth: null, updateBatteryInfo: (level, state, lowPowerMode) => { const isLowBattery = level > 0 && level < 0.2; @@ -44,6 +47,9 @@ export const useDeviceStore = create(set => ({ setDeviceCompromised: (compromised: boolean) => { set({ isDeviceCompromised: compromised }); }, + setLastBiometricAuth: (timestamp) => { + set({ lastBiometricAuth: timestamp }); + }, runDeviceCompromisedCheck: async () => { const compromised = await checkDeviceCompromised(); set({ isDeviceCompromised: compromised });