Skip to content

feat(email): verified email-change flow - #3155

Open
FrankApiyo wants to merge 1 commit into
mainfrom
feat/verified-email-change
Open

feat(email): verified email-change flow#3155
FrankApiyo wants to merge 1 commit into
mainfrom
feat/verified-email-change

Conversation

@FrankApiyo

@FrankApiyo FrankApiyo commented Jun 30, 2026

Copy link
Copy Markdown
Member

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

  • Unit tests: OTP TTL / attempt cap / hashing, request throttling, confirm applies + invalidates the cached profile, race-on-confirm and replay both rejected
  • Manual end-to-end from the SPA (request → OTP email → confirm)

Side effects of implementing this change

  • Schema change: new PendingEmailChange table, with a management command to purge expired rows

Before submitting this PR for review, please make sure you have:

  • Included tests
  • Updated documentation

Closes ONADATA-1411

Comment thread onadata/apps/main/models/password_history.py Fixed
@FrankApiyo
FrankApiyo force-pushed the feat/verified-email-change branch from f3822cd to a3a9322 Compare July 2, 2026 06:39
@@ -0,0 +1,32 @@
# Generated by Django 5.2.11 on 2026-06-29 10:32

@kelvin-muchiri kelvin-muchiri Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
        )

@kelvin-muchiri kelvin-muchiri Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I now see we need a place to store the new email.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@FrankApiyo FrankApiyo changed the title feat(email): verified email-change flow (+ login-resilience fixes) feat(email): verified email-change flow Jul 15, 2026
@linear

linear Bot commented Jul 15, 2026

Copy link
Copy Markdown

ONADATA-1411


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this import to the top?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of importing MAX_EMAIL_CHANGE_REQUESTS can we use override_settings decorator?

MAX_EMAIL_CHANGE_REQUESTS,
)

cache.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use override_settings instead of importing from the viewset

MAX_EMAIL_CHANGE_REQUESTS,
)

cache.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

@kelvin-muchiri kelvin-muchiri Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread onadata/libs/utils/email.py Outdated
ttl_seconds = getattr(settings, "EMAIL_CHANGE_OTP_TTL_SECONDS", 300)
ttl_minutes = max(1, ttl_seconds // 60)
subject = "Verify your new email address"
body = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a HTML template like the rest of the emails?

User = get_user_model()


class PendingEmailChangeTests(TestCase):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we assert more from the response other than new_email?

@FrankApiyo
FrankApiyo force-pushed the feat/verified-email-change branch from 673ce0a to 2a3ea7f Compare July 15, 2026 08:27
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
@FrankApiyo
FrankApiyo force-pushed the feat/verified-email-change branch from 2a3ea7f to 908922b Compare July 15, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants