diff --git a/src/__tests__/store/authLockout.test.ts b/src/__tests__/store/authLockout.test.ts new file mode 100644 index 0000000..afe9954 --- /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 c86393e..6d82bf2 100644 --- a/src/pages/mobile/MobileLogin.tsx +++ b/src/pages/mobile/MobileLogin.tsx @@ -31,6 +31,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 { getAuthErrorMessage } from '../../utils/authErrorMessages'; import { appLogger } from '../../utils/logger'; import { validateEmail, validateRequired } from '../../utils/validation'; @@ -65,6 +66,23 @@ export const MobileLogin: React.FC = ({ const [passwordFocused, setPasswordFocused] = useState(false); 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 [displayError, setDisplayError] = useState(null); const emailRef = useRef(null); const setError = (message: string | null) => setDisplayError(message); @@ -216,6 +234,16 @@ export const MobileLogin: React.FC = ({ {/* ── Card ── */} + {/* Lockout banner */} + {isLocked && ( + + + + Too many failed attempts. Try again in {lockSecondsLeft}s. + + + )} + {/* Error banner */} {displayError && ( @@ -512,6 +540,22 @@ const createStyles = (scale: (size: number) => 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 8b2663b..42a1eb4 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -202,6 +202,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) => { @@ -298,6 +304,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 && @@ -337,6 +349,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 8a59902..9bd231b 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,6 +37,10 @@ interface AppState { logout: () => void; setLoading: (isLoading: boolean) => void; setError: (error: string | null) => void; + incrementAuthFailure: () => void; + resetAuthFailures: () => void; + incrementRefreshFailure: () => void; + resetRefreshFailures: () => void; } const INITIAL_APP_STATE = { @@ -101,6 +109,9 @@ export const useAppStore = create()( refreshToken: null, sessionExpiresAt: null, sessionExpiringSoon: false, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, }, false, 'logout' @@ -111,8 +122,32 @@ 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', storage: secureStorageJSONStorage, @@ -132,6 +167,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; @@ -140,6 +176,7 @@ export const useAppStore = create()( ...currentState, ...hydratedState, sessionExpiresAt: toUnixMs(hydratedState.sessionExpiresAt), + authLockedUntil: hydratedState.authLockedUntil ?? null, }; }, }