11import httpMocks from 'node-mocks-http'
22import {
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' , ( ) => {
191192192193 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+ 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