From 52d8e7dea20b316d5594b9537d5812b4c4e011df Mon Sep 17 00:00:00 2001 From: Vox-d-glitch Date: Sat, 27 Jun 2026 22:49:27 +0100 Subject: [PATCH] fix(auth): add client-side lockout after repeated failed login attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile client had no friction against credential stuffing — failed login attempts were retried immediately via the existing exponential backoff logic designed for network errors, not auth failures. - Store: add authFailureCount, authLockedUntil, refreshFailureCount fields and incrementAuthFailure / resetAuthFailures / incrementRefreshFailure actions to useAppStore; authLockedUntil is persisted to secure storage so a force-close does not bypass the lock - Interceptor: count 401s on /auth/login (increments on each failed attempt, lock triggers at 5); reset counter on successful login response; force-logout after 3 consecutive /auth/refresh 401s - MobileLogin: show amber countdown banner and disable submit button for the 30-second lockout window - Tests: unit tests covering threshold, early-exit, reset-on-success, force-logout at 3 refresh failures, and logout clearing all counters Closes #581 --- src/__tests__/store/authLockout.test.ts | 135 ++++++++++++++++++++++++ src/pages/mobile/MobileLogin.tsx | 48 ++++++++- src/services/api/axios.config.ts | 18 ++++ src/store/index.ts | 43 +++++++- 4 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/store/authLockout.test.ts diff --git a/src/__tests__/store/authLockout.test.ts b/src/__tests__/store/authLockout.test.ts new file mode 100644 index 00000000..afe99549 --- /dev/null +++ b/src/__tests__/store/authLockout.test.ts @@ -0,0 +1,135 @@ +import { useAppStore } from '../../store'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resetStore() { + useAppStore.setState({ + user: null, + isAuthenticated: false, + isAuthLoading: false, + authError: null, + accessToken: null, + refreshToken: null, + sessionExpiresAt: null, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, + }); +} + +function getStore() { + return useAppStore.getState(); +} + +const MOCK_USER = { id: 'u1', name: 'Ada Lovelace', email: 'ada@teachlink.com' }; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('auth lockout', () => { + beforeEach(() => { + jest.useFakeTimers(); + resetStore(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Login lockout ────────────────────────────────────────────────────────── + + describe('incrementAuthFailure', () => { + it('increments the failure count on each call', () => { + getStore().incrementAuthFailure(); + getStore().incrementAuthFailure(); + expect(getStore().authFailureCount).toBe(2); + expect(getStore().authLockedUntil).toBeNull(); + }); + + it('locks for 30 seconds and resets count after 5 consecutive failures', () => { + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const expectedLockUntil = new Date('2026-01-01T00:00:30.000Z').getTime(); + + for (let i = 0; i < 5; i++) { + getStore().incrementAuthFailure(); + } + + expect(getStore().authFailureCount).toBe(0); + expect(getStore().authLockedUntil).toBe(expectedLockUntil); + }); + + it('does not lock before the 5th failure', () => { + for (let i = 0; i < 4; i++) { + getStore().incrementAuthFailure(); + } + expect(getStore().authLockedUntil).toBeNull(); + expect(getStore().authFailureCount).toBe(4); + }); + }); + + // ── Reset on success ─────────────────────────────────────────────────────── + + describe('resetAuthFailures', () => { + it('clears the failure count and lockout timestamp', () => { + for (let i = 0; i < 3; i++) { + getStore().incrementAuthFailure(); + } + getStore().resetAuthFailures(); + expect(getStore().authFailureCount).toBe(0); + expect(getStore().authLockedUntil).toBeNull(); + }); + + it('clears an active lockout', () => { + for (let i = 0; i < 5; i++) { + getStore().incrementAuthFailure(); + } + expect(getStore().authLockedUntil).not.toBeNull(); + getStore().resetAuthFailures(); + expect(getStore().authLockedUntil).toBeNull(); + }); + }); + + // ── Refresh token lockout ────────────────────────────────────────────────── + + describe('incrementRefreshFailure', () => { + it('increments the refresh failure count without logging out prematurely', () => { + useAppStore.setState({ user: MOCK_USER, isAuthenticated: true }); + + getStore().incrementRefreshFailure(); + getStore().incrementRefreshFailure(); + + expect(getStore().isAuthenticated).toBe(true); + expect(getStore().refreshFailureCount).toBe(2); + }); + + it('force-logs out and resets the counter after 3 consecutive refresh 401s', () => { + useAppStore.setState({ user: MOCK_USER, isAuthenticated: true, accessToken: 'at_abc' }); + + getStore().incrementRefreshFailure(); + getStore().incrementRefreshFailure(); + getStore().incrementRefreshFailure(); + + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().user).toBeNull(); + expect(getStore().accessToken).toBeNull(); + expect(getStore().refreshFailureCount).toBe(0); + }); + }); + + // ── Logout resets all counters ───────────────────────────────────────────── + + describe('logout', () => { + it('clears authFailureCount, authLockedUntil, and refreshFailureCount', () => { + useAppStore.setState({ + authFailureCount: 3, + refreshFailureCount: 2, + authLockedUntil: Date.now() + 30_000, + }); + + getStore().logout(); + + expect(getStore().authFailureCount).toBe(0); + expect(getStore().refreshFailureCount).toBe(0); + expect(getStore().authLockedUntil).toBeNull(); + }); + }); +}); diff --git a/src/pages/mobile/MobileLogin.tsx b/src/pages/mobile/MobileLogin.tsx index ea8f9e40..abfd6f3b 100644 --- a/src/pages/mobile/MobileLogin.tsx +++ b/src/pages/mobile/MobileLogin.tsx @@ -33,6 +33,7 @@ import { MobileFormInput } from '../../components/mobile/MobileFormInput'; import { useBiometricAuth, useDynamicFontSize, useFormValidation } from '../../hooks'; import authService, { AuthResult } from '../../services/mobileAuth'; import * as secureStorage from '../../services/secureStorage'; +import { useAppStore } from '../../store'; import { validateEmail, validateRequired } from '../../utils/validation'; interface LoginFormValues { @@ -75,6 +76,23 @@ export const MobileLogin: React.FC = ({ const [serverError, setServerError] = useState(null); const [showBiometricModal, setShowBiometricModal] = useState(false); + // ── Auth lockout ───────────────────────────────────────────────────────── + const authLockedUntil = useAppStore(state => state.authLockedUntil); + const [lockSecondsLeft, setLockSecondsLeft] = useState(0); + + useEffect(() => { + if (!authLockedUntil) { + setLockSecondsLeft(0); + return; + } + const tick = () => setLockSecondsLeft(Math.max(0, Math.ceil((authLockedUntil - Date.now()) / 1000))); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [authLockedUntil]); + + const isLocked = lockSecondsLeft > 0; + const passwordRef = useRef(null); const { @@ -212,6 +230,16 @@ export const MobileLogin: React.FC = ({ {/* ── Card ── */} + {/* Lockout banner */} + {isLocked && ( + + + + Too many failed attempts. Try again in {lockSecondsLeft}s. + + + )} + {/* Error banner */} {displayError && ( @@ -331,9 +359,9 @@ export const MobileLogin: React.FC = ({ {/* Primary CTA */} number, isDark: boolean) => shadowRadius: scale(16), elevation: 4, }, + lockoutBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: scale(8), + backgroundColor: '#fef3c7', + borderRadius: scale(10), + paddingHorizontal: scale(14), + paddingVertical: scale(10), + marginBottom: scale(4), + }, + lockoutText: { + color: '#b45309', + fontSize: scale(13), + fontWeight: '500', + flex: 1, + }, errorBanner: { flexDirection: 'row', alignItems: 'center', diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 0c2aa1d7..2b5e5625 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -16,6 +16,7 @@ import { invalidateCacheForBatchRequests, invalidateCacheForMutation, invalidate import { requestQueue } from './requestQueue'; import { getEnv } from '../../config'; import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig'; +import { useAppStore } from '../../store'; import { appLogger } from '../../utils/logger'; import { startTiming, notifyEntry } from '../../utils/performanceTiming'; import { healthMetricsService } from '../healthMetrics'; @@ -171,6 +172,12 @@ apiClient.interceptors.response.use( statusCode: response.status, }); invalidateSuccessfulMutationCache(cfg); + + // Successful login clears the client-side lockout counter + if (cfg.url?.includes('/auth/login')) { + useAppStore.getState().resetAuthFailures(); + } + return response; }, async (error: AxiosError) => { @@ -230,6 +237,12 @@ apiClient.interceptors.response.use( // ─── 401: Token refresh flow ─────────────────────────────────────────── + // Count consecutive bad-credential 401s on the login endpoint so the + // client can enforce a lockout before the 5th attempt reaches the server. + if (status === 401 && originalRequest.url?.includes('/auth/login') && !originalRequest._retry) { + useAppStore.getState().incrementAuthFailure(); + } + if ( status === 401 && !originalRequest._retry && @@ -269,6 +282,11 @@ apiClient.interceptors.response.use( return apiClient(originalRequest); } catch (refreshError) { + // Three consecutive refresh 401s indicate the refresh token is invalid; + // force a full logout rather than leaving the user in a broken auth state. + if ((refreshError as AxiosError)?.response?.status === 401) { + useAppStore.getState().incrementRefreshFailure(); + } processRefreshQueue(null, refreshError); return Promise.reject(refreshError); } finally { diff --git a/src/store/index.ts b/src/store/index.ts index 8d5651f5..026cc638 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -24,6 +24,10 @@ interface AppState { isLoading: boolean; error: string | null; theme: 'light' | 'dark'; + // ── Client-side auth lockout ───────────────────────────────────────────── + authFailureCount: number; + authLockedUntil: number | null; + refreshFailureCount: number; setUser: (user: User | null) => void; setTheme: (theme: 'light' | 'dark') => void; setTokens: (accessToken: string, refreshToken: string, expiresAt: number | Date) => void; @@ -33,12 +37,16 @@ interface AppState { logout: () => void; setLoading: (isLoading: boolean) => void; setError: (error: string | null) => void; + incrementAuthFailure: () => void; + resetAuthFailures: () => void; + incrementRefreshFailure: () => void; + resetRefreshFailures: () => void; } export const useAppStore = create()( devtools( persist( - subscribeWithSelector(set => ({ + subscribeWithSelector((set, get) => ({ user: null, isAuthenticated: false, isAuthLoading: false, @@ -50,6 +58,9 @@ export const useAppStore = create()( theme: 'light', isLoading: false, error: null, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, setUser: user => { set({ user, isAuthenticated: !!user }, false, 'setUser'); // Sync Sentry scope with the signed-in user so every subsequent @@ -91,6 +102,9 @@ export const useAppStore = create()( refreshToken: null, sessionExpiresAt: null, sessionExpiringSoon: false, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, }, false, 'logout' @@ -101,6 +115,31 @@ export const useAppStore = create()( }, setLoading: isLoading => set({ isLoading }, false, 'setLoading'), setError: error => set({ error }, false, 'setError'), + incrementAuthFailure: () => + set( + state => { + const next = state.authFailureCount + 1; + if (next >= 5) { + return { authFailureCount: 0, authLockedUntil: Date.now() + 30_000 }; + } + return { authFailureCount: next }; + }, + false, + 'incrementAuthFailure' + ), + resetAuthFailures: () => + set({ authFailureCount: 0, authLockedUntil: null }, false, 'resetAuthFailures'), + incrementRefreshFailure: () => { + const next = get().refreshFailureCount + 1; + if (next >= 3) { + get().logout(); + set({ refreshFailureCount: 0 }, false, 'forceLogoutOnRefreshFailure'); + } else { + set({ refreshFailureCount: next }, false, 'incrementRefreshFailure'); + } + }, + resetRefreshFailures: () => + set({ refreshFailureCount: 0 }, false, 'resetRefreshFailures'), })), { name: 'app-auth-storage', @@ -117,6 +156,7 @@ export const useAppStore = create()( refreshToken: state.refreshToken, sessionExpiresAt: toUnixMs(state.sessionExpiresAt), theme: state.theme, + authLockedUntil: state.authLockedUntil, }), merge: (persistedState, currentState) => { const hydratedState = (persistedState ?? {}) as Partial; @@ -125,6 +165,7 @@ export const useAppStore = create()( ...currentState, ...hydratedState, sessionExpiresAt: toUnixMs(hydratedState.sessionExpiresAt), + authLockedUntil: hydratedState.authLockedUntil ?? null, }; }, }