Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/__tests__/store/authLockout.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
44 changes: 44 additions & 0 deletions src/pages/mobile/MobileLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +66,23 @@ export const MobileLogin: React.FC<MobileLoginProps> = ({
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<string | null>(null);
const emailRef = useRef<TextInput>(null);
const setError = (message: string | null) => setDisplayError(message);
Expand Down Expand Up @@ -216,6 +234,16 @@ export const MobileLogin: React.FC<MobileLoginProps> = ({

{/* ── Card ── */}
<View style={[styles.card, { backgroundColor: cardBg, borderColor }]}>
{/* Lockout banner */}
{isLocked && (
<View style={styles.lockoutBanner}>
<AlertCircle size={scale(14)} color="#b45309" />
<Text allowFontScaling={false} style={styles.lockoutText}>
Too many failed attempts. Try again in {lockSecondsLeft}s.
</Text>
</View>
)}

{/* Error banner */}
{displayError && (
<View style={styles.errorBanner}>
Expand Down Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 39 additions & 2 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
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;
Expand All @@ -33,6 +37,10 @@
logout: () => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
incrementAuthFailure: () => void;
resetAuthFailures: () => void;
incrementRefreshFailure: () => void;
resetRefreshFailures: () => void;
}

const INITIAL_APP_STATE = {
Expand Down Expand Up @@ -101,6 +109,9 @@
refreshToken: null,
sessionExpiresAt: null,
sessionExpiringSoon: false,
authFailureCount: 0,
authLockedUntil: null,
refreshFailureCount: 0,
},
false,
'logout'
Expand All @@ -111,8 +122,32 @@
},
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'),
})),

Check failure on line 150 in src/store/index.ts

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

';' expected.
{
name: 'app-auth-storage',
storage: secureStorageJSONStorage,
Expand All @@ -132,6 +167,7 @@
refreshToken: state.refreshToken,
sessionExpiresAt: toUnixMs(state.sessionExpiresAt),
theme: state.theme,
authLockedUntil: state.authLockedUntil,
}),
merge: (persistedState, currentState) => {
const hydratedState = (persistedState ?? {}) as Partial<AppState>;
Expand All @@ -140,13 +176,14 @@
...currentState,
...hydratedState,
sessionExpiresAt: toUnixMs(hydratedState.sessionExpiresAt),
authLockedUntil: hydratedState.authLockedUntil ?? null,
};
},
}
),
{ name: 'AppStore' }
)
);

Check failure on line 186 in src/store/index.ts

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Declaration or statement expected.

export * from './courseProgressStore';
export * from './deviceStore';
Expand Down
Loading