@@ -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+ 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