Skip to content

Commit 6161aa4

Browse files
committed
test(otp): add OTP IP-based storage tests to prevent denial of service attacks
1 parent 03830ff commit 6161aa4

File tree

1 file changed

+217
-0
lines changed

1 file changed

+217
-0
lines changed

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,221 @@ describe('LoginController', () => {
389389
expect(logger.error).toBeCalled()
390390
})
391391
})
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+
})
392609
})

0 commit comments

Comments
 (0)