feat(email): verified email-change flow - #3155
Conversation
f3822cd to
a3a9322
Compare
| @@ -0,0 +1,32 @@ | |||
| # Generated by Django 5.2.11 on 2026-06-29 10:32 | |||
|
|
|||
There was a problem hiding this comment.
Do we need a new table for this? I think all this info can be encoded and signed within the link we send to the user. Can something like this suffice?
class EmailChangeTokenGenerator(PasswordResetTokenGenerator):
"""Token generator for change in email link"""
def _make_hash_value(self, user, timestamp):
return (
six.text_type(user.pk)
+ six.text_type(timestamp)
+ six.text_type(user.email)
)
There was a problem hiding this comment.
But I now see we need a place to store the new email.
There was a problem hiding this comment.
Yes, we need to keep the new email in the DB and also keep track of the number of attempts the user has taken to reset the email.
Also, I don't think it would be secure to allow users to have access to the hashed OTP in the email alongside the OTP.
|
|
||
| def test_request_email_change_happy_path(self): | ||
| """Happy path: returns 200, creates PendingEmailChange, sends 1 email.""" | ||
| cache.clear() # start with a clean send-throttle counter |
There was a problem hiding this comment.
I think it would be better to have cache.clear in tearDown instead, example
|
|
||
| def test_request_email_change_bad_password(self): | ||
| """Wrong password returns 400 with a 'password' error key.""" | ||
| cache.clear() # clean send-throttle counter |
There was a problem hiding this comment.
Same as above, let's move cache.clear to tearDown
| ) | ||
| response = view(request, user="bob") | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("password", response.data) |
There was a problem hiding this comment.
Can we assert more info in the response data other than just password?
| ) | ||
| response = view(request, user="bob") | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("new_email", response.data) |
There was a problem hiding this comment.
Can we assert more in the response data other than just new_email?
|
|
||
| def test_request_email_change_throttled(self): | ||
| """OTP send is rate-limited so it can't spam the target address.""" | ||
| from onadata.apps.api.viewsets.user_profile_viewset import ( |
There was a problem hiding this comment.
Can we move this import to the top?
There was a problem hiding this comment.
Instead of importing MAX_EMAIL_CHANGE_REQUESTS can we use override_settings decorator?
| MAX_EMAIL_CHANGE_REQUESTS, | ||
| ) | ||
|
|
||
| cache.clear() |
There was a problem hiding this comment.
Let's move cache.clear to tearDown
| """The rate limit gates entry, not just the send: even calls that fail | ||
| the password check count against the budget, so the password/uniqueness | ||
| checks can't be exercised as unbounded oracles.""" | ||
| from onadata.apps.api.viewsets.user_profile_viewset import ( |
There was a problem hiding this comment.
Let's use override_settings instead of importing from the viewset
| MAX_EMAIL_CHANGE_REQUESTS, | ||
| ) | ||
|
|
||
| cache.clear() |
There was a problem hiding this comment.
Let's move cache.clear to tearDown
| view = UserProfileViewSet.as_view({"post": "request_email_change"}) | ||
|
|
||
| # Exhaust the budget with wrong-password calls (each returns 400). | ||
| for _ in range(MAX_EMAIL_CHANGE_REQUESTS): |
There was a problem hiding this comment.
This is ok but I think for this test we can just set the cache value directly to simulate exhausted retry limit
| request = self.factory.post("/", data={"otp": "000000"}, **self.extra) | ||
| response = view_confirm(request, user="bob") | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("otp", response.data) |
There was a problem hiding this comment.
We can assert more info in the response data other than just "otp"
| request = self.factory.post("/", data={"otp": "123456"}, **self.extra) | ||
| response = view_confirm(request, user="bob") | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("otp", response.data) |
There was a problem hiding this comment.
We can assert more info from the response data other just "otp"
| def test_confirm_invalidates_profile_cache(self): | ||
| """The cached profile response must be cleared so the new email is | ||
| served immediately, not stale until the cache TTL expires.""" | ||
| from onadata.libs.utils.cache_tools import ( |
There was a problem hiding this comment.
Let's move the import to the top. I think to avoid coupling between implementation and tests, we can use the literal string for the key e.g cache_key = f"foo-{self.user.username}{self.user.username}" and use standard cache.get and cache.set
| ttl_seconds = getattr(settings, "EMAIL_CHANGE_OTP_TTL_SECONDS", 300) | ||
| ttl_minutes = max(1, ttl_seconds // 60) | ||
| subject = "Verify your new email address" | ||
| body = ( |
There was a problem hiding this comment.
Should we have a HTML template like the rest of the emails?
| User = get_user_model() | ||
|
|
||
|
|
||
| class PendingEmailChangeTests(TestCase): |
There was a problem hiding this comment.
Could we add docstrings for the test methods?
|
|
||
| return Response(status=status.HTTP_200_OK, data=data) | ||
|
|
||
| @action(methods=["POST"], detail=True, url_path="request_email_change") |
There was a problem hiding this comment.
The standard we've used is hyphens instead of underscors for URLs i. e request-email-change over request_email_change
We also need to add documentation for the new endpoint
|
|
||
| return Response({"sent": True}, status=status.HTTP_200_OK) | ||
|
|
||
| @action(methods=["POST"], detail=True, url_path="confirm_email_change") |
There was a problem hiding this comment.
The standard we've used is hyphens instead of underscors for URLs i. e confirm-email-change over confirm_email_change
We also need to add documentation for the new endpoint
| request = self.factory.post("/", data={"otp": code}, **self.extra) | ||
| response = view_confirm(request, user="bob") | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("new_email", response.data) |
There was a problem hiding this comment.
Can we assert more from the response other than new_email?
673ce0a to
2a3ea7f
Compare
Add a two-step, OTP-verified email change to the profiles API:
- PendingEmailChange model keeps the pending address and a keyed hash
of a short-lived 6-digit code, with attempt caps and a management
command to purge expired rows
- POST profiles/{user}/request-email-change validates the password and
address uniqueness, then emails the code (HTML + text templates);
rate-limited per user via a shared bump_attempts cache primitive
- POST profiles/{user}/confirm-email-change verifies the code, applies
the address, marks it verified and invalidates the cached profile
- normalize emails and enforce case-insensitive uniqueness
- document both endpoints in docs/profiles.rst
2a3ea7f to
908922b
Compare
Changes / Features implemented
Verified (verify-before-apply) email change: an OTP is sent to the new address and the change is applied only on confirmation. Endpoints, throttling and expiry behavior are documented in
docs/profiles.rst.Steps taken to verify this change does what is intended
Side effects of implementing this change
PendingEmailChangetable, with a management command to purge expired rowsBefore submitting this PR for review, please make sure you have:
Closes ONADATA-1411