1111
1212from django .conf import settings
1313from django .contrib .auth .models import User
14+ from django .core import mail
15+ from django .core .cache import cache
1416from django .test import RequestFactory , override_settings
1517from django .views .debug import SafeExceptionReporterFilter
1618
2224
2325from onadata .apps .api .tests .viewsets .test_abstract_viewset import TestAbstractViewSet
2426from onadata .apps .api .viewsets .totp_viewset import (
27+ RECOVERY_CODE_ALPHABET ,
2528 RECOVERY_CODE_COUNT ,
2629 RECOVERY_DEVICE_NAME ,
2730 TOTP_DEVICE_NAME ,
2831 TOTPViewSet ,
32+ _verify_recovery ,
2933)
3034
3135#: Any test spending two codes must move between windows: django-otp records
@@ -124,9 +128,7 @@ def _enroll(self):
124128 Always passes the password so the helper works either way: it is
125129 ignored unless the deployment asks for one, and required when it does.
126130 """
127- started = self ._post_session (
128- "enroll_start" , {"password" : self .login_password }
129- )
131+ started = self ._post_session ("enroll_start" , {"password" : self .login_password })
130132 self .assertEqual (started .status_code , 201 )
131133 device = TOTPDevice .objects .get (
132134 user = self .user , name = TOTP_DEVICE_NAME , confirmed = False
@@ -393,6 +395,50 @@ def test_generating_recovery_codes_returns_them_once(self):
393395 {"generated" : True , "remaining" : RECOVERY_CODE_COUNT },
394396 )
395397
398+ def test_recovery_codes_carry_at_least_64_bits_of_randomness (self ):
399+ """Base32 spends 5 bits a character, so 64 bits needs 13 of them.
400+
401+ Rate limiting is what actually makes these unguessable -- see the
402+ throttling test below -- but the entropy is the half that holds if
403+ the throttle is ever turned off.
404+ """
405+ device = self ._enroll ()
406+ with next_totp_window ():
407+ response = self ._post_with_api_key (
408+ "recovery_generate" , {"code" : current_code (device .key )}
409+ )
410+ self .assertEqual (response .status_code , 201 )
411+
412+ for code in response .data ["codes" ]:
413+ self .assertGreaterEqual (len (code ), 13 , code )
414+ self .assertTrue (set (code ) <= set (RECOVERY_CODE_ALPHABET ), code )
415+
416+ def test_wrong_recovery_codes_are_throttled (self ):
417+ """The entropy above is sized against a throttled attacker.
418+
419+ django-otp backs off by ``factor * 2 ** (failures - 1)`` seconds and
420+ the factor is a setting, so a deployment can switch this off. Asserted
421+ here because turning it off changes what the code length has to be.
422+ """
423+ self ._enroll ()
424+ with next_totp_window ():
425+ self ._post_with_api_key (
426+ "recovery_generate" ,
427+ {"code" : current_code (default_device (self .user ).key )},
428+ )
429+ device = StaticDevice .objects .get (user = self .user , name = RECOVERY_DEVICE_NAME )
430+ self .assertTrue (device .throttling_enabled , "backoff is off; codes stand alone" )
431+
432+ # Five misses rather than one: the delay doubles each time, so this
433+ # leaves ~16 seconds rather than racing a 1-second window.
434+ for miss in range (5 ):
435+ self .assertFalse (_verify_recovery (self .user , f"not-a-code{ miss } " ))
436+
437+ # A real code, refused only because the misses started the backoff.
438+ spendable = device .token_set .first ().token
439+ self .assertFalse (_verify_recovery (self .user , spendable ))
440+ self .assertTrue (device .token_set .filter (token = spendable ).exists ())
441+
396442 def test_regenerating_replaces_rather_than_appends (self ):
397443 """Spent codes left behind would make the remaining count a lie."""
398444 device = self ._enroll ()
@@ -800,9 +846,7 @@ def test_first_enrolment_refuses_without_the_account_password(self):
800846 )
801847
802848 def test_first_enrolment_accepts_the_account_password (self ):
803- response = self ._post_session (
804- "enroll_start" , {"password" : self .login_password }
805- )
849+ response = self ._post_session ("enroll_start" , {"password" : self .login_password })
806850
807851 self .assertEqual (response .status_code , 201 )
808852 self .assertIn ("otpauthUri" , response .data )
@@ -813,6 +857,43 @@ def test_first_enrolment_refuses_a_wrong_password(self):
813857 self .assertEqual (response .status_code , 403 )
814858 self .assertFalse (TOTPDevice .objects .filter (user = self .user ).exists ())
815859
860+ def test_enrolment_password_failures_count_towards_the_login_lockout (self ):
861+ """Guesses here spend the same allowance as guesses at the login form.
862+
863+ Both doors open on one password, so a budget of its own here would be
864+ a way around the lockout rather than a second lock.
865+ """
866+ user = User .objects .create_user (
867+ "enrolmentlockout" , "enrolmentlockout@example.com" , "correct-password"
868+ )
869+ # The counter lives in the cache, which the test runner does not roll
870+ # back, so it would otherwise outlast this test.
871+ self .addCleanup (cache .clear )
872+ view = TOTPViewSet .as_view ({"post" : "enroll_start" })
873+
874+ def start_enrolment (password ):
875+ return view (
876+ self .factory .post ("/" , data = {"password" : password }, ** self ._sso (user ))
877+ )
878+
879+ # The probe at the end says nothing unless this is accepted first.
880+ self .assertEqual (start_enrolment ("correct-password" ).status_code , 201 )
881+ TOTPDevice .objects .filter (user = user ).delete ()
882+
883+ with patch (
884+ "onadata.libs.authentication.send_account_lockout_email.apply_async"
885+ ):
886+ for attempt in range (settings .MAX_LOGIN_ATTEMPTS ):
887+ self .assertEqual (
888+ start_enrolment ("wrong-password" ).status_code , 403 , attempt
889+ )
890+
891+ refused = start_enrolment ("correct-password" )
892+
893+ self .assertEqual (refused .status_code , 403 )
894+ self .assertEqual (refused .data ["reason" ], "locked_out" )
895+ self .assertFalse (TOTPDevice .objects .filter (user = user ).exists ())
896+
816897 def test_an_account_with_no_password_cannot_enrol (self ):
817898 """Having no password is not a way past the password.
818899
@@ -859,9 +940,7 @@ def test_submitted_secrets_are_scrubbed_from_error_reports(self):
859940 with DEBUG off because the reporter's filter is inert when it is on.
860941 """
861942 secret = "sup3r-s3cret-pw" # nosec B105 - test fixture, not a credential
862- request = RequestFactory ().post (
863- "/" , data = {"password" : secret }, ** self ._sso ()
864- )
943+ request = RequestFactory ().post ("/" , data = {"password" : secret }, ** self ._sso ())
865944
866945 TOTPViewSet .as_view ({"post" : "enroll_start" })(request )
867946
@@ -942,9 +1021,7 @@ def test_viewing_returns_the_codes_that_still_work(self):
9421021 )
9431022
9441023 self .assertEqual (viewed .status_code , 200 )
945- self .assertEqual (
946- sorted (viewed .data ["codes" ]), sorted (generated .data ["codes" ])
947- )
1024+ self .assertEqual (sorted (viewed .data ["codes" ]), sorted (generated .data ["codes" ]))
9481025 self .assertEqual (
9491026 self ._get_status ().data ["recoveryCodes" ],
9501027 {"generated" : True , "remaining" : RECOVERY_CODE_COUNT },
@@ -1000,6 +1077,50 @@ def test_viewing_a_set_that_was_never_generated_is_a_404(self):
10001077
10011078 self .assertEqual (response .status_code , 404 )
10021079
1080+ def test_enrolment_is_written_to_the_security_audit_log (self ):
1081+ with self .assertLogs ("audit_logger" , level = "DEBUG" ) as captured :
1082+ self ._enroll ()
1083+
1084+ actions = {
1085+ getattr (record , "formhub_action" , None ) for record in captured .records
1086+ }
1087+ self .assertIn ("two-factor-enrolled" , actions )
1088+
1089+ def test_failed_verification_is_written_to_the_security_audit_log (self ):
1090+ self ._enroll ()
1091+
1092+ with self .assertLogs ("audit_logger" , level = "DEBUG" ) as captured :
1093+ response = self ._verify ({"code" : "000000" , "method" : "totp" })
1094+
1095+ self .assertEqual (response .status_code , 403 )
1096+ actions = {
1097+ getattr (record , "formhub_action" , None ) for record in captured .records
1098+ }
1099+ self .assertIn ("two-factor-verification-failed" , actions )
1100+
1101+ def test_enrolment_notifies_the_account_owner (self ):
1102+ mail .outbox = []
1103+
1104+ self ._enroll ()
1105+
1106+ self .assertTrue (
1107+ any (self .user .email in message .to for message in mail .outbox ),
1108+ "enrolling a factor did not notify the account owner" ,
1109+ )
1110+
1111+ def test_disabling_two_factor_notifies_the_account_owner (self ):
1112+ device = self ._enroll ()
1113+ mail .outbox = []
1114+ with next_totp_window ():
1115+ response = self ._post_with_api_key (
1116+ "disable" , {"code" : current_code (device .key )}
1117+ )
1118+
1119+ self .assertEqual (response .status_code , 200 )
1120+ self .assertTrue (
1121+ any (self .user .email in message .to for message in mail .outbox ),
1122+ "disabling a factor did not notify the account owner" ,
1123+ )
10031124
10041125
10051126@override_settings (ENABLE_TWO_FACTOR = False )
0 commit comments