Skip to content

Commit 8e052bd

Browse files
authored
Merge pull request #2400 from opengovsg/adriangohjw/isom-2003-vapt-active-denial-of-service-of-login-to-gogovsg
isom-2003 feat(otp): update OTP verification to include IP address
2 parents c83ebf4 + 6161aa4 commit 8e052bd

File tree

10 files changed

+407
-125
lines changed

10 files changed

+407
-125
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: 240 additions & 21 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'
@@ -152,6 +152,7 @@ describe('LoginController', () => {
152152
expect(res.ok).toHaveBeenCalled()
153153
expect(setOtpForEmail).toHaveBeenCalledWith(
154154
email,
155+
ip,
155156
expect.objectContaining({
156157
hashedOtp: otp,
157158
retries: expect.any(Number),
@@ -190,6 +191,7 @@ describe('LoginController', () => {
190191
describe('verifyOtp tests', () => {
191192
const email = '[email protected]'
192193
const otp = '1'
194+
const ip = '1.1.1.1' // This should match the IP set in createRequestWithEmailAndIpAndOtp
193195

194196
const hash = jest.fn()
195197
const compare = jest.fn()
@@ -243,54 +245,54 @@ describe('LoginController', () => {
243245
test('valid email and otp', async () => {
244246
const user = { id: 1, email }
245247

246-
getOtpForEmail.mockImplementation((e) =>
248+
getOtpForEmail.mockImplementation((e, i) =>
247249
Promise.resolve(
248-
e === email
250+
e === email && i === ip
249251
? {
250252
hashedOtp: otp,
251253
retries: 100,
252254
}
253255
: null,
254256
),
255257
)
256-
const req = createRequestWithEmailAndOtp(email, otp)
258+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
257259
const res = getMockResponse()
258260
findOrCreateWithEmail.mockResolvedValue(user)
259261

260262
await controller.verifyOtp(req, res)
261263

262-
expect(deleteOtpByEmail).toHaveBeenCalledWith(email)
264+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip)
263265
expect(req.session!.user).toStrictEqual(user)
264266
expect(res.ok).toHaveBeenCalled()
265267
})
266268

267269
test('valid email, wrong otp and expiring', async () => {
268270
const badOtp = '0'
269271

270-
getOtpForEmail.mockImplementation((e) =>
272+
getOtpForEmail.mockImplementation((e, i) =>
271273
Promise.resolve(
272-
e === email
274+
e === email && i === ip
273275
? {
274276
hashedOtp: otp,
275277
retries: 1,
276278
}
277279
: null,
278280
),
279281
)
280-
const req = createRequestWithEmailAndOtp(email, badOtp)
282+
const req = createRequestWithEmailAndIpAndOtp(email, ip, badOtp)
281283
const res = getMockResponse()
282284

283285
await controller.verifyOtp(req, res)
284286

285-
expect(deleteOtpByEmail).toHaveBeenCalledWith(email)
287+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip)
286288
expect(req.session!.user).toBeUndefined()
287289
expect(res.unauthorized).toHaveBeenCalled()
288290
})
289291

290292
test('valid email and no otp in cache', async () => {
291293
getOtpForEmail.mockResolvedValue(null)
292294

293-
const req = createRequestWithEmailAndOtp(email, otp)
295+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
294296
const res = getMockResponse()
295297

296298
await controller.verifyOtp(req, res)
@@ -302,9 +304,9 @@ describe('LoginController', () => {
302304

303305
test('valid email and wrong otp with retries left', async () => {
304306
const badOtp = '0'
305-
getOtpForEmail.mockImplementation((e) =>
307+
getOtpForEmail.mockImplementation((e, i) =>
306308
Promise.resolve(
307-
e === email
309+
e === email && i === ip
308310
? {
309311
hashedOtp: otp,
310312
retries: 100,
@@ -313,12 +315,12 @@ describe('LoginController', () => {
313315
),
314316
)
315317

316-
const req = createRequestWithEmailAndOtp(email, badOtp)
318+
const req = createRequestWithEmailAndIpAndOtp(email, ip, badOtp)
317319
const res = getMockResponse()
318320

319321
await controller.verifyOtp(req, res)
320322

321-
expect(setOtpForEmail).toHaveBeenCalledWith(email, {
323+
expect(setOtpForEmail).toHaveBeenCalledWith(email, ip, {
322324
hashedOtp: otp,
323325
retries: 99,
324326
})
@@ -327,17 +329,17 @@ describe('LoginController', () => {
327329
})
328330

329331
test('no email and has valid otp in request', async () => {
330-
getOtpForEmail.mockImplementation((e) =>
332+
getOtpForEmail.mockImplementation((e, i) =>
331333
Promise.resolve(
332-
e === email
334+
e === email && i === ip
333335
? {
334336
hashedOtp: otp,
335337
retries: 100,
336338
}
337339
: null,
338340
),
339341
)
340-
const req = createRequestWithEmailAndOtp(undefined, '1')
342+
const req = createRequestWithEmailAndIpAndOtp(undefined, ip, otp)
341343
const res = getMockResponse()
342344

343345
await controller.verifyOtp(req, res)
@@ -351,7 +353,7 @@ describe('LoginController', () => {
351353

352354
test('cache down', async () => {
353355
getOtpForEmail.mockRejectedValue(new Error())
354-
const req = createRequestWithEmailAndOtp(email, otp)
356+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
355357
const res = getMockResponse()
356358

357359
await controller.verifyOtp(req, res)
@@ -363,9 +365,9 @@ describe('LoginController', () => {
363365
})
364366

365367
test('db down', async () => {
366-
getOtpForEmail.mockImplementation((e) =>
368+
getOtpForEmail.mockImplementation((e, i) =>
367369
Promise.resolve(
368-
e === email
370+
e === email && i === ip
369371
? {
370372
hashedOtp: otp,
371373
retries: 100,
@@ -375,7 +377,7 @@ describe('LoginController', () => {
375377
)
376378
findOrCreateWithEmail.mockRejectedValue(new Error())
377379

378-
const req = createRequestWithEmailAndOtp(email, otp)
380+
const req = createRequestWithEmailAndIpAndOtp(email, ip, otp)
379381
const res = getMockResponse()
380382

381383
await controller.verifyOtp(req, res)
@@ -387,4 +389,221 @@ describe('LoginController', () => {
387389
expect(logger.error).toBeCalled()
388390
})
389391
})
392+
393+
// Ensure no active denial of service attacks can be performed on an email address
394+
// by requesting OTPs for the same email address from different IPs
395+
describe('OTP IP-based Storage Tests', () => {
396+
const email = '[email protected]'
397+
const otpA = '123456'
398+
const otpB = '654321'
399+
const ip1 = '192.168.1.1'
400+
const ip2 = '192.168.1.2'
401+
402+
const hash = jest.fn()
403+
const compare = jest.fn()
404+
const mailOTP = jest.fn()
405+
const initMailer = jest.fn()
406+
const mailJobFailure = jest.fn()
407+
const mailJobSuccess = jest.fn()
408+
const deleteOtpByEmail = jest.fn()
409+
const setOtpForEmail = jest.fn()
410+
const getOtpForEmail = jest.fn()
411+
412+
const urlMapper = new UrlMapper()
413+
const userRepository = new UserRepository(
414+
new UserMapper(urlMapper),
415+
urlMapper,
416+
)
417+
const findOrCreateWithEmail = jest.spyOn(
418+
userRepository,
419+
'findOrCreateWithEmail',
420+
)
421+
422+
const authService = new AuthService(
423+
{ hash, compare },
424+
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
425+
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
426+
userRepository,
427+
)
428+
const controller = new LoginController(authService)
429+
430+
beforeEach(() => {
431+
hash.mockClear()
432+
compare.mockClear()
433+
mailOTP.mockClear()
434+
initMailer.mockClear()
435+
deleteOtpByEmail.mockClear()
436+
setOtpForEmail.mockClear()
437+
getOtpForEmail.mockClear()
438+
findOrCreateWithEmail.mockClear()
439+
440+
compare.mockImplementation((data, encrypted) =>
441+
Promise.resolve(data === encrypted),
442+
)
443+
deleteOtpByEmail.mockResolvedValue(undefined)
444+
hash.mockResolvedValue('hashedOtp')
445+
})
446+
447+
// For cases where a user has multiple devices, we want to ensure that they can login from any of them
448+
// This is more of a basic test to ensure that OTP is also dependent on the IP and not just the email address
449+
test('Same user from different IPs can both complete login successfully', async () => {
450+
const user = { id: 1, email }
451+
findOrCreateWithEmail.mockResolvedValue(user)
452+
453+
// Set up OTPs for both IPs
454+
getOtpForEmail.mockImplementation((e, i) => {
455+
if (e === email && i === ip1) {
456+
return Promise.resolve({ hashedOtp: otpA, retries: 3 })
457+
}
458+
if (e === email && i === ip2) {
459+
return Promise.resolve({ hashedOtp: otpB, retries: 3 })
460+
}
461+
return Promise.resolve(null)
462+
})
463+
464+
// User logs in from mobile (IP1)
465+
const req1 = createRequestWithEmailAndIpAndOtp(email, ip1, otpA)
466+
const res1 = getMockResponse()
467+
await controller.verifyOtp(req1, res1)
468+
469+
// User logs in from desktop (IP2)
470+
const req2 = createRequestWithEmailAndIpAndOtp(email, ip2, otpB)
471+
const res2 = getMockResponse()
472+
await controller.verifyOtp(req2, res2)
473+
474+
// Both should succeed
475+
expect(res1.ok).toHaveBeenCalled()
476+
expect(res2.ok).toHaveBeenCalled()
477+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip1)
478+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip2)
479+
})
480+
481+
test('User A cannot verify with User B OTP', async () => {
482+
// Set up OTPs for both users with different values
483+
getOtpForEmail.mockImplementation((e, i) => {
484+
if (e === email && i === ip1) {
485+
return Promise.resolve({ hashedOtp: otpA, retries: 3 })
486+
}
487+
if (e === email && i === ip2) {
488+
return Promise.resolve({ hashedOtp: otpB, retries: 3 })
489+
}
490+
return Promise.resolve(null)
491+
})
492+
493+
// User A tries to verify with User B's OTP
494+
const req = createRequestWithEmailAndIpAndOtp(email, ip1, otpB)
495+
const res = getMockResponse()
496+
await controller.verifyOtp(req, res)
497+
498+
// Should fail
499+
expect(res.unauthorized).toHaveBeenCalled()
500+
expect(deleteOtpByEmail).not.toHaveBeenCalled()
501+
})
502+
503+
// Note: we use the same OTP to ensure OTP deletion is not dependent on the OTP value
504+
test('Only User A OTP is deleted after successful verification, User B OTP remains', async () => {
505+
const user = { id: 1, email }
506+
findOrCreateWithEmail.mockResolvedValue(user)
507+
508+
// Set up OTPs for both users
509+
getOtpForEmail.mockImplementation((e, i) => {
510+
if (e === email && i === ip1) {
511+
return Promise.resolve({ hashedOtp: otpA, retries: 3 })
512+
}
513+
if (e === email && i === ip2) {
514+
return Promise.resolve({ hashedOtp: otpA, retries: 3 })
515+
}
516+
return Promise.resolve(null)
517+
})
518+
519+
// User A verifies OTP successfully
520+
const req = createRequestWithEmailAndIpAndOtp(email, ip1, otpA)
521+
const res = getMockResponse()
522+
await controller.verifyOtp(req, res)
523+
524+
// Only User A's OTP should be deleted
525+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip1)
526+
expect(deleteOtpByEmail).not.toHaveBeenCalledWith(email, ip2)
527+
expect(deleteOtpByEmail).toHaveBeenCalledTimes(1)
528+
})
529+
530+
test('Different IPs create different Redis keys for same email', async () => {
531+
// User A requests OTP
532+
const req1 = createRequestWithEmail(email)
533+
req1.ip = ip1
534+
const res1 = getMockResponse()
535+
await controller.generateOtp(req1, res1)
536+
537+
// User B requests OTP
538+
const req2 = createRequestWithEmail(email)
539+
req2.ip = ip2
540+
const res2 = getMockResponse()
541+
await controller.generateOtp(req2, res2)
542+
543+
// Verify different keys were used
544+
expect(setOtpForEmail).toHaveBeenCalledWith(
545+
email,
546+
ip1,
547+
expect.any(Object),
548+
)
549+
expect(setOtpForEmail).toHaveBeenCalledWith(
550+
email,
551+
ip2,
552+
expect.any(Object),
553+
)
554+
expect(setOtpForEmail).toHaveBeenCalledTimes(2)
555+
})
556+
557+
test('User A locked out after 3 wrong attempts, User B can still try', async () => {
558+
compare.mockResolvedValue(false) // Wrong OTP
559+
560+
// Set up OTPs for both users with mutable retry counts
561+
let userARetries = 3
562+
let userBRetries = 3
563+
564+
getOtpForEmail.mockImplementation((e, i) => {
565+
if (e === email && i === ip1) {
566+
return Promise.resolve({ hashedOtp: otpA, retries: userARetries })
567+
}
568+
if (e === email && i === ip2) {
569+
return Promise.resolve({ hashedOtp: otpA, retries: userBRetries })
570+
}
571+
return Promise.resolve(null)
572+
})
573+
574+
// Mock setOtpForEmail to update retry counts
575+
setOtpForEmail.mockImplementation((e, i, otpData) => {
576+
if (e === email && i === ip1) {
577+
userARetries = otpData.retries
578+
}
579+
if (e === email && i === ip2) {
580+
userBRetries = otpData.retries
581+
}
582+
return Promise.resolve()
583+
})
584+
585+
// User A enters wrong OTP 3 times
586+
for (let i = 0; i < 3; i += 1) {
587+
const req = createRequestWithEmailAndIpAndOtp(email, ip1, 'wrong')
588+
const res = getMockResponse()
589+
// eslint-disable-next-line no-await-in-loop
590+
await controller.verifyOtp(req, res)
591+
expect(res.unauthorized).toHaveBeenCalled()
592+
}
593+
594+
// User A should be locked out (OTP deleted)
595+
expect(deleteOtpByEmail).toHaveBeenCalledWith(email, ip1)
596+
597+
// User B should still be able to try
598+
const req2 = createRequestWithEmailAndIpAndOtp(email, ip2, 'wrong')
599+
const res2 = getMockResponse()
600+
await controller.verifyOtp(req2, res2)
601+
602+
// User B should still have retries left
603+
expect(setOtpForEmail).toHaveBeenCalledWith(email, ip2, {
604+
hashedOtp: otpA,
605+
retries: 2,
606+
})
607+
})
608+
})
390609
})

0 commit comments

Comments
 (0)