Skip to content

Commit d76deac

Browse files
committed
Merge branch 'feat/2fa-audit-and-owner-notifications' into feat/step-up-authentication
2 parents 4ff7399 + d56d136 commit d76deac

10 files changed

Lines changed: 378 additions & 52 deletions

File tree

‎onadata/apps/api/tasks.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ def send_account_lockout_email(email, message_txt, subject):
137137
send_generic_email(email, message_txt, subject)
138138

139139

140+
@app.task(base=AutoRetryTask)
141+
def send_two_factor_changed_email(email, message_txt, subject):
142+
"""Sends second-factor change notification email."""
143+
send_generic_email(email, message_txt, subject)
144+
145+
140146
@app.task(base=AutoRetryTask)
141147
def send_account_deactivation_email(email, message_txt, subject):
142148
"""Sends inactive-account deactivation warning email."""

‎onadata/apps/api/tests/viewsets/test_totp_concurrency.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# -*- coding: utf-8 -*-
21
"""Concurrency tests for one-time-code verification.
32
43
Threads here stand in for the deployment's uWSGI workers (``--workers 30
@@ -11,19 +10,24 @@
1110
transaction is never committed, so a second connection would not see the
1211
fixture at all.
1312
"""
13+
1414
import threading
1515

16+
from django.conf import settings
1617
from django.contrib.auth.models import User
1718
from django.db import connection
1819
from django.test import TransactionTestCase, override_settings
1920

21+
import jwt
2022
from django_otp.oath import totp
2123
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
2224
from django_otp.plugins.otp_totp.models import TOTPDevice
25+
from rest_framework.test import APIRequestFactory
2326

2427
from onadata.apps.api.viewsets.totp_viewset import (
2528
RECOVERY_DEVICE_NAME,
2629
TOTP_DEVICE_NAME,
30+
TOTPViewSet,
2731
_verify_recovery,
2832
_verify_totp,
2933
)
@@ -134,3 +138,72 @@ def worker():
134138
1,
135139
f"the code verified {sum(results)} times; one code, one use",
136140
)
141+
142+
143+
@override_settings(ENABLE_TWO_FACTOR=True)
144+
class EnrolmentConfirmationConcurrencyTestCase(TransactionTestCase):
145+
"""A pending authenticator is confirmed by exactly one request.
146+
147+
Worse here than a double-spent code: confirmation mints the recovery set,
148+
so every racing worker is handed one and each replaces the last. The user
149+
is shown several sets and only the final one still works.
150+
"""
151+
152+
def setUp(self):
153+
super().setUp()
154+
self.user = User.objects.create_user(
155+
"enrolmentrace", "enrolmentrace@example.com", "pw-9F3kz"
156+
)
157+
self.device = TOTPDevice.objects.create(
158+
user=self.user, name=TOTP_DEVICE_NAME, confirmed=False
159+
)
160+
161+
def test_one_pending_code_confirms_one_enrolment(self):
162+
code = f"{totp(self.device.bin_key, self.device.step, self.device.t0):06d}"
163+
config = settings.OPENID_CONNECT_VIEWSET_CONFIG
164+
sso = jwt.encode(
165+
{"email": self.user.email},
166+
config["JWT_SECRET_KEY"],
167+
algorithm=config["JWT_ALGORITHM"],
168+
)
169+
barrier = threading.Barrier(CONCURRENT)
170+
responses = []
171+
guard = threading.Lock()
172+
173+
def worker():
174+
try:
175+
request = APIRequestFactory().post(
176+
"/", data={"code": code}, HTTP_SSO=sso
177+
)
178+
barrier.wait(timeout=10)
179+
response = TOTPViewSet.as_view({"post": "enroll_confirm"})(request)
180+
with guard:
181+
responses.append(response)
182+
finally:
183+
connection.close()
184+
185+
threads = [threading.Thread(target=worker) for _ in range(CONCURRENT)]
186+
for thread in threads:
187+
thread.start()
188+
for thread in threads:
189+
thread.join(timeout=30)
190+
191+
self.assertEqual(len(responses), CONCURRENT, "a worker did not finish")
192+
# The losers find no pending row when the lock releases, rather than
193+
# going on to spend the code: a double-click is a 409, not a "that
194+
# code is not valid" on a code that was perfectly good.
195+
self.assertEqual({response.status_code for response in responses}, {200, 409})
196+
won = [response for response in responses if response.status_code == 200]
197+
self.assertEqual(
198+
len(won), 1, f"{len(won)} racing confirmations were each told they won"
199+
)
200+
# The half that bites: the set the winner was shown is the set that
201+
# actually survived, so the codes on the user's screen are the live ones.
202+
self.assertEqual(
203+
set(won[0].data["codes"]),
204+
set(
205+
StaticToken.objects.filter(device__user=self.user).values_list(
206+
"token", flat=True
207+
)
208+
),
209+
)

‎onadata/apps/api/tests/viewsets/test_totp_viewset.py‎

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from django.conf import settings
1313
from django.contrib.auth.models import User
14+
from django.core import mail
15+
from django.core.cache import cache
1416
from django.test import RequestFactory, override_settings
1517
from django.views.debug import SafeExceptionReporterFilter
1618

@@ -22,10 +24,12 @@
2224

2325
from onadata.apps.api.tests.viewsets.test_abstract_viewset import TestAbstractViewSet
2426
from 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

Comments
 (0)