Skip to content

Commit e603ff0

Browse files
committed
feat(otp): update OTP verification to include IP address
1 parent 2d94333 commit e603ff0

File tree

10 files changed

+202
-127
lines changed

10 files changed

+202
-127
lines changed

src/server/modules/auth/LoginController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export class LoginController {
6868
const { email, otp }: VerifyOtpRequest = req.body
6969

7070
try {
71-
const user = await this.authService.verifyOtp(email, otp)
71+
const user = await this.authService.verifyOtp(email, otp, getIp(req))
7272
req.session!.user = user
7373
res.ok(jsonMessage('OTP hash verification ok.'))
7474
dogstatsd.increment(OTP_VERIFY_SUCCESS, 1, 1)

src/server/modules/auth/__tests__/LoginController.test.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import httpMocks from 'node-mocks-http'
22
import {
33
createRequestWithEmail,
4-
createRequestWithEmailAndOtp,
4+
createRequestWithEmailAndIpAndOtp,
55
createRequestWithUser,
66
userModelMock,
77
} from '../../../../../test/server/api/util'
@@ -116,6 +116,7 @@ describe('LoginController', () => {
116116
const mailJobFailure = jest.fn()
117117
const mailJobSuccess = jest.fn()
118118

119+
const getRedisKey = jest.fn()
119120
const deleteOtpByEmail = jest.fn()
120121
const setOtpForEmail = jest.fn()
121122
const getOtpForEmail = jest.fn()
@@ -124,7 +125,7 @@ describe('LoginController', () => {
124125
const authService = new AuthService(
125126
{ hash, compare },
126127
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
127-
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
128+
{ getRedisKey, deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
128129
new UserRepository(new UserMapper(urlMapper), urlMapper),
129130
)
130131
const controller = new LoginController(authService)
@@ -152,6 +153,7 @@ describe('LoginController', () => {
152153
expect(res.ok).toHaveBeenCalled()
153154
expect(setOtpForEmail).toHaveBeenCalledWith(
154155
email,
156+
ip,
155157
expect.objectContaining({
156158
hashedOtp: otp,
157159
retries: expect.any(Number),
@@ -190,6 +192,7 @@ describe('LoginController', () => {
190192
describe('verifyOtp tests', () => {
191193
const email = '[email protected]'
192194
const otp = '1'
195+
const ip = '1.1.1.1' // This should match the IP set in createRequestWithEmailAndIpAndOtp
193196

194197
const hash = jest.fn()
195198
const compare = jest.fn()
@@ -199,6 +202,7 @@ describe('LoginController', () => {
199202
const mailJobFailure = jest.fn()
200203
const mailJobSuccess = jest.fn()
201204

205+
const getRedisKey = jest.fn()
202206
const deleteOtpByEmail = jest.fn()
203207
const setOtpForEmail = jest.fn()
204208
const getOtpForEmail = jest.fn()
@@ -217,7 +221,7 @@ describe('LoginController', () => {
217221
const authService = new AuthService(
218222
{ hash, compare },
219223
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
220-
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
224+
{ getRedisKey, deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
221225
userRepository,
222226
)
223227

@@ -243,54 +247,54 @@ describe('LoginController', () => {
243247
test('valid email and otp', async () => {
244248
const user = { id: 1, email }
245249

246-
getOtpForEmail.mockImplementation((e) =>
250+
getOtpForEmail.mockImplementation((e, i) =>
247251
Promise.resolve(
248-
e === email
252+
e === email && i === ip
249253
? {
250254
hashedOtp: otp,
251255
retries: 100,
252256
}
253257
: null,
254258
),
255259
)
256-
const req = createRequestWithEmailAndOtp(email, otp)
260+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
257261
const res = getMockResponse()
258262
findOrCreateWithEmail.mockResolvedValue(user)
259263

260264
await controller.verifyOtp(req, res)
261265

262-
expect(deleteOtpByEmail).toHaveBeenCalledWith(email)
266+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip)
263267
expect(req.session!.user).toStrictEqual(user)
264268
expect(res.ok).toHaveBeenCalled()
265269
})
266270

267271
test('valid email, wrong otp and expiring', async () => {
268272
const badOtp = '0'
269273

270-
getOtpForEmail.mockImplementation((e) =>
274+
getOtpForEmail.mockImplementation((e, i) =>
271275
Promise.resolve(
272-
e === email
276+
e === email && i === ip
273277
? {
274278
hashedOtp: otp,
275279
retries: 1,
276280
}
277281
: null,
278282
),
279283
)
280-
const req = createRequestWithEmailAndOtp(email, badOtp)
284+
const req = createRequestWithEmailAndIpAndOtp(email, ip, badOtp)
281285
const res = getMockResponse()
282286

283287
await controller.verifyOtp(req, res)
284288

285-
expect(deleteOtpByEmail).toHaveBeenCalledWith(email)
289+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip)
286290
expect(req.session!.user).toBeUndefined()
287291
expect(res.unauthorized).toHaveBeenCalled()
288292
})
289293

290294
test('valid email and no otp in cache', async () => {
291295
getOtpForEmail.mockResolvedValue(null)
292296

293-
const req = createRequestWithEmailAndOtp(email, otp)
297+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
294298
const res = getMockResponse()
295299

296300
await controller.verifyOtp(req, res)
@@ -302,9 +306,9 @@ describe('LoginController', () => {
302306

303307
test('valid email and wrong otp with retries left', async () => {
304308
const badOtp = '0'
305-
getOtpForEmail.mockImplementation((e) =>
309+
getOtpForEmail.mockImplementation((e, i) =>
306310
Promise.resolve(
307-
e === email
311+
e === email && i === ip
308312
? {
309313
hashedOtp: otp,
310314
retries: 100,
@@ -313,12 +317,12 @@ describe('LoginController', () => {
313317
),
314318
)
315319

316-
const req = createRequestWithEmailAndOtp(email, badOtp)
320+
const req = createRequestWithEmailAndIpAndOtp(email, ip, badOtp)
317321
const res = getMockResponse()
318322

319323
await controller.verifyOtp(req, res)
320324

321-
expect(setOtpForEmail).toHaveBeenCalledWith(email, {
325+
expect(setOtpForEmail).toHaveBeenCalledWith(email, ip, {
322326
hashedOtp: otp,
323327
retries: 99,
324328
})
@@ -327,17 +331,17 @@ describe('LoginController', () => {
327331
})
328332

329333
test('no email and has valid otp in request', async () => {
330-
getOtpForEmail.mockImplementation((e) =>
334+
getOtpForEmail.mockImplementation((e, i) =>
331335
Promise.resolve(
332-
e === email
336+
e === email && i === ip
333337
? {
334338
hashedOtp: otp,
335339
retries: 100,
336340
}
337341
: null,
338342
),
339343
)
340-
const req = createRequestWithEmailAndOtp(undefined, '1')
344+
const req = createRequestWithEmailAndIpAndOtp(undefined, ip, otp)
341345
const res = getMockResponse()
342346

343347
await controller.verifyOtp(req, res)
@@ -351,7 +355,7 @@ describe('LoginController', () => {
351355

352356
test('cache down', async () => {
353357
getOtpForEmail.mockRejectedValue(new Error())
354-
const req = createRequestWithEmailAndOtp(email, otp)
358+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
355359
const res = getMockResponse()
356360

357361
await controller.verifyOtp(req, res)
@@ -363,9 +367,9 @@ describe('LoginController', () => {
363367
})
364368

365369
test('db down', async () => {
366-
getOtpForEmail.mockImplementation((e) =>
370+
getOtpForEmail.mockImplementation((e, i) =>
367371
Promise.resolve(
368-
e === email
372+
e === email && i === ip
369373
? {
370374
hashedOtp: otp,
371375
retries: 100,
@@ -375,7 +379,7 @@ describe('LoginController', () => {
375379
)
376380
findOrCreateWithEmail.mockRejectedValue(new Error())
377381

378-
const req = createRequestWithEmailAndOtp(email, otp)
382+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
379383
const res = getMockResponse()
380384

381385
await controller.verifyOtp(req, res)

src/server/modules/auth/interfaces/AuthService.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,21 @@ export interface AuthService {
44
/**
55
* Generates and stores a random otp for the input email.
66
* @param {string} email The email of the user to generate an otp for.
7+
* @param {string} ip The IP address of the user.
78
* @returns Promise that resolves to void.
89
*/
910
generateOtp(email: string, ip: string): Promise<void>
1011

1112
/**
12-
* Verifies that the input otp matches the stored otp for the input email.
13+
* Verifies that the input otp matches the stored otp for the input email and IP.
1314
* Throws an error if the otp is different or there is no otp generated
14-
* for the input email.
15+
* for the input email and IP combination.
1516
* @param {string} email Email of the user.
1617
* @param {string} otp Otp to verify.
18+
* @param {string} ip IP address of the user.
1719
* @returns Promise that resolves to the user if the otp is valid.
1820
*/
19-
verifyOtp(email: string, otp: string): Promise<StorableUser>
21+
verifyOtp(email: string, otp: string, ip: string): Promise<StorableUser>
2022

2123
/**
2224
* Generates the user with the officer email provided, if the

src/server/modules/auth/interfaces/OtpRepository.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,35 @@ import { StorableOtp } from '../../../repositories/types'
22

33
export interface OtpRepository {
44
/**
5-
* Delete the otp associated with the user with the input email.
5+
* Get the redis key for the otp associated with the user with the input email and IP.
66
* @param {string} email Email of the user.
7+
* @param {string} ip IP address of the user.
8+
* @returns Promise that resolves to the redis key.
9+
*/
10+
getRedisKey(email: string, ip: string): string
11+
12+
/**
13+
* Delete the otp associated with the user with the input email and IP.
14+
* @param {string} email Email of the user.
15+
* @param {string} ip IP address of the user.
716
* @returns Promise that resolves to void.
817
*/
9-
deleteOtpByEmail(email: string): Promise<void>
18+
deleteOtpByEmail(email: string, ip: string): Promise<void>
1019

1120
/**
12-
* Sets or replaces the otp associated with the user with the input email.
21+
* Sets or replaces the otp associated with the user with the input email and IP.
1322
* @param {string} email Email of the user.
23+
* @param {string} ip IP address of the user.
1424
* @param {StorableOtp} otp The new otp to be associated.
1525
* @returns Promise that resolves to void.
1626
*/
17-
setOtpForEmail(email: string, otp: StorableOtp): Promise<void>
27+
setOtpForEmail(email: string, ip: string, otp: StorableOtp): Promise<void>
1828

1929
/**
20-
* Retrieves the otp associated with the user with the input email.
30+
* Retrieves the otp associated with the user with the input email and IP.
2131
* @param {string} email Email of the user.
32+
* @param {string} ip IP address of the user.
2233
* @returns Promise that resolves the otp or null if it does not exist.
2334
*/
24-
getOtpForEmail(email: string): Promise<StorableOtp | null>
35+
getOtpForEmail(email: string, ip: string): Promise<StorableOtp | null>
2536
}

src/server/modules/auth/repositories/OtpRepository.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@ export class OtpRepository implements interfaces.OtpRepository {
1919
this.otpMapper = otpMapper
2020
}
2121

22-
public deleteOtpByEmail: (email: string) => Promise<void> = (email) => {
22+
private getRedisKey(email: string, ip: string): string {
23+
return `${email}:${ip}`
24+
}
25+
26+
public deleteOtpByEmail: (email: string, ip: string) => Promise<void> = (
27+
email,
28+
ip,
29+
) => {
2330
return new Promise((resolve, reject) => {
24-
otpClient.del(email, (redisDelError, resdisDelResponse) => {
31+
const key = this.getRedisKey(email, ip)
32+
otpClient.del(key, (redisDelError, resdisDelResponse) => {
2533
if (redisDelError || resdisDelResponse !== 1) {
2634
reject(redisDelError)
2735
return
@@ -32,13 +40,15 @@ export class OtpRepository implements interfaces.OtpRepository {
3240
})
3341
}
3442

35-
public setOtpForEmail: (email: string, otp: StorableOtp) => Promise<void> = (
36-
email,
37-
otp,
38-
) => {
43+
public setOtpForEmail: (
44+
email: string,
45+
ip: string,
46+
otp: StorableOtp,
47+
) => Promise<void> = (email, ip, otp) => {
3948
return new Promise((resolve, reject) => {
49+
const key = this.getRedisKey(email, ip)
4050
otpClient.set(
41-
email,
51+
key,
4252
this.otpMapper.dtoToPersistence(otp),
4353
'EX',
4454
otpExpiry,
@@ -54,11 +64,13 @@ export class OtpRepository implements interfaces.OtpRepository {
5464
})
5565
}
5666

57-
public getOtpForEmail: (email: string) => Promise<StorableOtp | null> = (
58-
email,
59-
) => {
67+
public getOtpForEmail: (
68+
email: string,
69+
ip: string,
70+
) => Promise<StorableOtp | null> = (email, ip) => {
6071
return new Promise((resolve, reject) => {
61-
otpClient.get(email, (redisError, string) => {
72+
const key = this.getRedisKey(email, ip)
73+
otpClient.get(key, (redisError, string) => {
6274
if (redisError) {
6375
reject(redisError)
6476
return

0 commit comments

Comments
 (0)