Skip to content

Commit a0bb5a9

Browse files
authored
fix: passcode limit attempts (#1275)
1 parent e505eac commit a0bb5a9

12 files changed

Lines changed: 287 additions & 16 deletions

File tree

docs/EndToEndTests/Passcode.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Passcode/ security e2e tests test the user's interactions with the security butt
4848
- When phone is turned off and back on, when reopening CoMapeo, passcode is required
4949
- Number keyboard is displayed and letters can't be entered
5050
- An error is displayed if the wrong passcode is entered
51+
- If a wrong passcode is entered five times, a lockout time of 1 minutes is enforced
52+
- After that 1 minute, someone can log in with the correct passcode
5153

5254
### Special Considerations
5355

messages/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@
366366
"constants.red": {
367367
"message": "Red"
368368
},
369+
"hooks.usePasscodeLockout.lockoutMessage": {
370+
"message": "Try again in {minutes, plural, one {# minute} other {# minutes}}"
371+
},
369372
"projectInfoCard.coordinator": {
370373
"message": "You are a coordinator on this project."
371374
},
@@ -704,7 +707,7 @@
704707
"message": "Enter your passcode"
705708
},
706709
"screens.EnterPassword.wrongPass": {
707-
"message": "Incorrect passcode, please try again"
710+
"message": "Incorrect Passcode"
708711
},
709712
"screens.Exchange.WifiCard.noWifi": {
710713
"message": "No Wi-Fi"

src/frontend/contexts/AuthContext.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ const {FlagSecureModule} = NativeModules;
44

55
import {useIsShareDialogOpen} from '../hooks/share';
66
import {DEFAULT_OBSCURE_CODE} from '../lib/security';
7-
import {useSecurityState} from './SecurityStoreContext';
7+
import {useSecurityState, useSecurityActions} from './SecurityStoreContext';
88
import {useIsAudioPermissionModalOpen} from '../hooks/useAudioPermissionTracker';
9+
import {getLockoutThreshold} from '../lib/security';
910

1011
export type AuthState = 'unauthenticated' | 'authenticated' | 'obscured';
1112

@@ -30,7 +31,9 @@ export const useAuthContext = () => {
3031
};
3132

3233
export const AuthProvider = ({children}: {children: React.ReactNode}) => {
33-
const {passcode, obscureCodeEnabled} = useSecurityState();
34+
const {passcode, obscureCodeEnabled, lockUntil} = useSecurityState();
35+
const {incrementAndGetAttempts, resetFailedAttempts, setLockUntil} =
36+
useSecurityActions();
3437
const [authState, setAuthState] = React.useState<AuthState>(
3538
passcode === null ? 'authenticated' : 'unauthenticated',
3639
);
@@ -73,19 +76,39 @@ export const AuthProvider = ({children}: {children: React.ReactNode}) => {
7376
(passcodeValue, validateOnly = false) => {
7477
if (validateOnly) return passcodeValue === passcode;
7578

79+
const isLockedOut = Date.now() < lockUntil;
80+
if (isLockedOut) {
81+
throw new Error('LOCKED_OUT');
82+
}
83+
7684
if (obscureCodeEnabled && passcodeValue === DEFAULT_OBSCURE_CODE) {
7785
setAuthState('obscured');
86+
resetFailedAttempts();
7887
return true;
7988
}
8089

8190
if (passcodeValue === passcode) {
8291
setAuthState('authenticated');
92+
resetFailedAttempts();
8393
return true;
8494
}
8595

96+
const attempts = incrementAndGetAttempts();
97+
const minutes = getLockoutThreshold(attempts);
98+
if (minutes) {
99+
setLockUntil(Date.now() + minutes * 60 * 1000);
100+
}
86101
throw new Error('Incorrect Passcode');
87102
},
88-
[passcode, obscureCodeEnabled],
103+
[
104+
passcode,
105+
obscureCodeEnabled,
106+
incrementAndGetAttempts,
107+
resetFailedAttempts,
108+
setLockUntil,
109+
lockUntil,
110+
setAuthState,
111+
],
89112
);
90113

91114
const contextValue: AuthContextType = React.useMemo(

src/frontend/contexts/SecurityStoreContext.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ test('initial state', () => {
3131
expect(stateHook.result.current).toStrictEqual({
3232
passcode: null,
3333
obscureCodeEnabled: false,
34+
failedAttempts: 0,
35+
lockUntil: 0,
3436
});
3537
});
3638

@@ -64,6 +66,8 @@ test('passcode cannot be set to invalid value', () => {
6466
expect(stateHook.result.current).toStrictEqual({
6567
passcode: null,
6668
obscureCodeEnabled: false,
69+
failedAttempts: 0,
70+
lockUntil: 0,
6771
});
6872
});
6973

@@ -86,6 +90,8 @@ test('obscure code cannot be set when passcode is not set', () => {
8690
expect(stateHook.result.current).toStrictEqual({
8791
passcode: null,
8892
obscureCodeEnabled: false,
93+
failedAttempts: 0,
94+
lockUntil: 0,
8995
});
9096
});
9197

@@ -108,6 +114,8 @@ test('obscure code has expected value when enabled', () => {
108114
expect(stateHook.result.current).toStrictEqual({
109115
passcode: '12345',
110116
obscureCodeEnabled: false,
117+
failedAttempts: 0,
118+
lockUntil: 0,
111119
});
112120

113121
act(() => {
@@ -117,6 +125,8 @@ test('obscure code has expected value when enabled', () => {
117125
expect(stateHook.result.current).toStrictEqual({
118126
passcode: '12345',
119127
obscureCodeEnabled: true,
128+
failedAttempts: 0,
129+
lockUntil: 0,
120130
});
121131
});
122132

@@ -140,6 +150,8 @@ test('obscure code is unset when passcode is unset', () => {
140150
expect(stateHook.result.current).toStrictEqual({
141151
passcode: '12345',
142152
obscureCodeEnabled: true,
153+
failedAttempts: 0,
154+
lockUntil: 0,
143155
});
144156

145157
act(() => {
@@ -149,5 +161,37 @@ test('obscure code is unset when passcode is unset', () => {
149161
expect(stateHook.result.current).toStrictEqual({
150162
passcode: null,
151163
obscureCodeEnabled: false,
164+
failedAttempts: 0,
165+
lockUntil: 0,
152166
});
153167
});
168+
test('increments attempts and sets lockout', () => {
169+
const store = createSecurityStore();
170+
const wrapper = createWrapper(store);
171+
const actionsHook = renderHook(() => useSecurityActions(), {wrapper});
172+
const stateHook = renderHook(() => useSecurityState(), {wrapper});
173+
174+
act(() => {
175+
actionsHook.result.current.incrementAndGetAttempts();
176+
actionsHook.result.current.incrementAndGetAttempts();
177+
actionsHook.result.current.setLockUntil(123456789);
178+
});
179+
180+
expect(stateHook.result.current.failedAttempts).toBe(2);
181+
expect(stateHook.result.current.lockUntil).toBe(123456789);
182+
});
183+
test('resets attempts and lockout', () => {
184+
const store = createSecurityStore();
185+
const wrapper = createWrapper(store);
186+
const actionsHook = renderHook(() => useSecurityActions(), {wrapper});
187+
const stateHook = renderHook(() => useSecurityState(), {wrapper});
188+
189+
act(() => {
190+
actionsHook.result.current.incrementAndGetAttempts();
191+
actionsHook.result.current.setLockUntil(999999);
192+
actionsHook.result.current.resetFailedAttempts();
193+
});
194+
195+
expect(stateHook.result.current.failedAttempts).toBe(0);
196+
expect(stateHook.result.current.lockUntil).toBe(0);
197+
});

src/frontend/contexts/SecurityStoreContext.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ const SecurityStateSchema = v.variant('passcode', [
1414
v.object({
1515
passcode: PasscodeSchema,
1616
obscureCodeEnabled: v.boolean(),
17+
failedAttempts: v.number(),
18+
lockUntil: v.number(),
1719
}),
1820
v.object({
1921
passcode: v.null(),
2022
obscureCodeEnabled: v.literal(false),
23+
failedAttempts: v.literal(0),
24+
lockUntil: v.literal(0),
2125
}),
2226
]);
2327

@@ -30,6 +34,8 @@ function createInitialState(): SecurityState {
3034
return {
3135
passcode: null,
3236
obscureCodeEnabled: false,
37+
failedAttempts: 0,
38+
lockUntil: 0,
3339
};
3440
}
3541

@@ -64,7 +70,12 @@ export function createSecurityStore({persist} = {persist: false}) {
6470
setPasscode: (passcode: string | null) => {
6571
// Obscure code needs to be unset when passcode is unset
6672
if (passcode === null) {
67-
store.setState({passcode, obscureCodeEnabled: false});
73+
store.setState({
74+
passcode: null,
75+
obscureCodeEnabled: false,
76+
failedAttempts: 0,
77+
lockUntil: 0,
78+
});
6879
return;
6980
}
7081

@@ -82,6 +93,19 @@ export function createSecurityStore({persist} = {persist: false}) {
8293

8394
store.setState({obscureCodeEnabled: enable});
8495
},
96+
incrementAndGetAttempts: () => {
97+
const current = store.getState().failedAttempts + 1;
98+
store.setState({failedAttempts: current});
99+
return current;
100+
},
101+
102+
resetFailedAttempts: () => {
103+
store.setState({failedAttempts: 0, lockUntil: 0});
104+
},
105+
106+
setLockUntil: (lockUntil: number) => {
107+
store.setState({lockUntil});
108+
},
85109
};
86110

87111
return {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {useEffect, useState} from 'react';
2+
import {
3+
useSecurityActions,
4+
useSecurityState,
5+
} from '../contexts/SecurityStoreContext';
6+
import {getRemainingLockoutMinutes} from '../lib/security';
7+
import {defineMessages, useIntl} from 'react-intl';
8+
9+
const m = defineMessages({
10+
lockoutMessage: {
11+
id: 'hooks.usePasscodeLockout.lockoutMessage',
12+
defaultMessage:
13+
'Try again in {minutes, plural, one {# minute} other {# minutes}}',
14+
},
15+
});
16+
17+
export function usePasscodeLockout() {
18+
const {lockUntil} = useSecurityState();
19+
const {setLockUntil} = useSecurityActions();
20+
const [minutes, setMinutes] = useState(0);
21+
const {formatMessage} = useIntl();
22+
23+
useEffect(() => {
24+
let timeout: NodeJS.Timeout | null = null;
25+
if (lockUntil) {
26+
const calcMinutes = getRemainingLockoutMinutes(lockUntil);
27+
setMinutes(calcMinutes);
28+
timeout = setTimeout(() => {
29+
setLockUntil(0);
30+
}, calcMinutes * 60_000);
31+
}
32+
33+
return () => {
34+
if (timeout) clearTimeout(timeout);
35+
};
36+
}, [lockUntil, setLockUntil]);
37+
38+
return {
39+
isLockedOut: !!lockUntil,
40+
message: formatMessage(m.lockoutMessage, {minutes}),
41+
};
42+
}
Lines changed: 13 additions & 0 deletions
Loading

src/frontend/lib/security.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {is} from 'valibot';
22

3-
import {DEFAULT_OBSCURE_CODE, PasscodeSchema} from './security';
3+
import {
4+
DEFAULT_OBSCURE_CODE,
5+
PasscodeSchema,
6+
getRemainingLockoutMinutes,
7+
getLockoutThreshold,
8+
} from './security';
49

510
test('DEFAULT_OBSCURE_CODE has expected value', () => {
611
expect(DEFAULT_OBSCURE_CODE).toBe('00000');
@@ -22,3 +27,34 @@ describe('PasscodeSchema', () => {
2227
expect(is(PasscodeSchema, '123.4')).toBe(false);
2328
});
2429
});
30+
31+
describe('getRemainingLockoutMinutes', () => {
32+
test('returns 0 when lockUntil is 0', () => {
33+
expect(getRemainingLockoutMinutes(0)).toBe(0);
34+
});
35+
36+
test('returns rounded-up minutes if in future', () => {
37+
const future = Date.now() + 2.3 * 60 * 1000;
38+
expect(getRemainingLockoutMinutes(future)).toBe(3);
39+
});
40+
41+
test('returns 0 if lockUntil is in the past', () => {
42+
const past = Date.now() - 60 * 1000;
43+
expect(getRemainingLockoutMinutes(past)).toBe(0);
44+
});
45+
});
46+
47+
describe('getLockoutThreshold', () => {
48+
test.each([
49+
[1, 0],
50+
[4, 0],
51+
[5, 1],
52+
[6, 0],
53+
[7, 3],
54+
[8, 5],
55+
[9, 5],
56+
[20, 5],
57+
])('returns expected minutes for %i attempts', (attempts, expected) => {
58+
expect(getLockoutThreshold(attempts)).toBe(expected);
59+
});
60+
});

src/frontend/lib/security.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,17 @@ export const PasscodeSchema = pipe(
88
digits(),
99
notValue(DEFAULT_OBSCURE_CODE, 'Passcode is reserved'),
1010
);
11+
12+
export function getRemainingLockoutMinutes(lockUntil: number): number {
13+
if (!lockUntil) return 0;
14+
const msRemaining = lockUntil - Date.now();
15+
return msRemaining > 0 ? Math.ceil(msRemaining / 60000) : 0;
16+
}
17+
18+
export function getLockoutThreshold(attempts: number): number | null {
19+
if (attempts === 5) return 1;
20+
if (attempts === 7) return 3;
21+
if (attempts >= 8) return 5;
22+
23+
return 0;
24+
}

0 commit comments

Comments
 (0)