Skip to content

Conversation

@Akanshu-2u
Copy link
Contributor

@Akanshu-2u Akanshu-2u commented Jun 2, 2025

Description:

This PR enhances the update_email pipeline function with security verification that ensures email updates only occur when usernames match between the authentication provider and the existing user account. It adds detailed logging and monitoring attributes to track username mismatches, helping identify potential security issues or integration problems in the authentication flow.

  1. Added Username Verification:

    • Now validates that the username from the authentication provider matches the user's username before updating email.

    • Uses defensive coding with [details.get('username')] and [getattr(user, 'username', None)].

  2. Added Monitoring and Logging:

    • Sets a custom attribute [update_email.username_mismatch] for all cases to track mismatches.

    • For mismatches only:

       - Logs a detailed warning with both usernames.
      
       - Sets additional custom attributes [update_email.details_username] and [update_email.user_username].
       
       - Stops execution before email update occurs.
      
  3. Security Improvement:

    • Prevents potential account takeover by ensuring emails are only updated when the authentication flow correctly identifies
      the user.

    • Provides visibility into authentication integration issues through the added monitoring.

Unit-Tests

  • Replaced real strategy objects with [MagicMock] for better test isolation and reliability.

  • Added realistic behavior to mocks with [side_effect] to properly test database interactions.

  • Added comprehensive test coverage for success paths, edge cases, and security scenarios.

  • Created specific test for username mismatch scenario to verify security controls.

    Testing Performed

      - Email update succeeds with matching usernames
      - Email update skipped for null email values
      - Email update blocked when usernames don't match
    

JIRA:
(https://2u-internal.atlassian.net/browse/ARCHBOM-2181)

Reviewers:
@robrap

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Thank you.

  1. Please read https://open-edx-proposals.readthedocs.io/en/latest/best-practices/oep-0051-bp-conventional-commits.html for how to format your commit messages. You can use "fix: ...", or "fixup! ..." as you add new commits to the PR (that can be squashed at the end).
  2. You have "... and name update" in the PR title. Is there supposed to be something about this? Also, do we need to look into whether fullname is updated in another step that might have the same issue?
  3. Are there unit tests for update_email you can enhance, or add?

# -r requirements/ci.in
virtualenv==20.30.0
# via tox
edx-django-utils==8.0.0
Copy link
Contributor

Choose a reason for hiding this comment

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

I appreciate you looking into CI failures. Definitely worth looking into before (or in parallel) with a review, as long as you let the reviewer know that you are aware of the failures and are looking into it.

  1. See https://open-edx-proposals.readthedocs.io/en/latest/best-practices/oep-0018-bp-python-dependencies.html for some help. It is a decision doc, and not a how-to, so it isn't as brief as it could be.
  2. This should be moved to base.in without a version.
  3. There is a github action for upgrading: https://github.com/openedx/auth-backends/actions/workflows/upgrade-python-requirements.yml. You might not be able to run this. Do you have it on your fork, and can you run there? If not, you can run make upgrade from a container (which doesn't always work, because it may not match Python exactly, or other small differences).

Copy link
Contributor

Choose a reason for hiding this comment

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

Leaving this unresolved until part 3 is handled. Thanks.

Copy link
Contributor

@robrap robrap Jun 11, 2025

Choose a reason for hiding this comment

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

This can be marked resolved.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Akanshu-2u:

  1. Do you see a "Resolve" button on this conversation in github, and are you able to mark as resolved?
  2. Are you able to give me write access on your private fork, and does that give me the capability to resolve conversations here?
    Thank you.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I am able to see "Resolve" button and can mark it as resolved.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Thank you.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Akanshu-2u: You do not need to do anything on this PR, but when I had written all of the comments like "This can be marked resolved.", it would have helped me if you had actually marked that am as resolved. I thought you couldn't, but I was wrong. We can try this on the new PR. Or, you can give me write access on your fork, and maybe I can resolve myself. Thanks.

Comment on lines 5 to 8
from django.contrib.auth import get_user_model
import logging
from edx_django_utils.monitoring import set_custom_attribute
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing you might have sorting issue here. Not sure if isort is running. I'm not sure if what I added below is correct, but it may be closer.

Suggested change
from django.contrib.auth import get_user_model
import logging
from edx_django_utils.monitoring import set_custom_attribute
import logging
from django.contrib.auth import get_user_model
from edx_django_utils.monitoring import set_custom_attribute

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

Comment on lines 38 to 41
Before updating the email, verify that the username in details matches the user's username.
If usernames don't match, log a warning and don't update the email.
Custom attributes are set to track username mismatches.
Copy link
Contributor

Choose a reason for hiding this comment

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

I appreciate good comment, but maybe these details could just be left to well commented code below. This is a judgement call. What you have is not wrong. I'd personally go lighter here. The more text, the more comments can drift from code.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

user_username = getattr(user, 'username', None)

# Check if usernames match
username_match = details_username and user_username and details_username == user_username
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't this be the same? You already know these have a value, even if None.

Suggested change
username_match = details_username and user_username and details_username == user_username
username_match = details_username == user_username

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

username_match = details_username and user_username and details_username == user_username

# Set custom attribute for all cases
set_custom_attribute('update_email.username_mismatch', not username_match)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please follow openedx/edx-django-utils#328 for annotating/documenting each custom attribute.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

@Akanshu-2u Akanshu-2u changed the title 994 bug in third party authentication email and name update fix: 994 bug in third party authentication email and name update Jun 3, 2025
@Akanshu-2u Akanshu-2u force-pushed the 994-bug-in-third-party-authentication-email-and-name-update branch from 7e5adfa to 68fbf10 Compare June 3, 2025 09:29
@Akanshu-2u Akanshu-2u changed the title fix: 994 bug in third party authentication email and name update fix:bug in third party authentication email and name update Jun 3, 2025
Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Thanks for the updates.

@@ -1,10 +1,13 @@
"""Python-social-auth pipeline functions.
For more info visit http://python-social-auth.readthedocs.org/en/latest/pipeline.html.
Copy link
Contributor

Choose a reason for hiding this comment

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

I noticed that this link is broken, and should be updated to:

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

)
# .. custom_attribute_name: update_email.details_username
# .. custom_attribute_description: Records the username provided in the
# authentication details when a mismatch occurs with the user's username.
Copy link
Contributor

Choose a reason for hiding this comment

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

I got the term "social details" from the doc https://python-social-auth.readthedocs.io/en/latest/pipeline.html I noted elsewhere. I don't want to call this "authentication details", because if the authenticated user (user) is a different user, the authentication details would not be for a different user.

Suggested change
# authentication details when a mismatch occurs with the user's username.
# social details when a mismatch occurs with the user's username.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.


# .. custom_attribute_name: update_email.user_username
# .. custom_attribute_description: Records the actual username of the user
# when a mismatch occurs with the authentication details username.
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, I want to be careful about using the term "authentication" here.

Suggested change
# when a mismatch occurs with the authentication details username.
# when a mismatch occurs with the social details' username.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# Verify email was updated
self.user = User.objects.get(username=self.user.username)
self.assertEqual(self.user.email, updated_email)
self.strategy.storage.user.changed.assert_called_once_with(self.user)
Copy link
Contributor

Choose a reason for hiding this comment

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

This assertion on a mock seems unnecessary, since you've already asserted that the email was updated. Can you explain why this is needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The assertion "self.strategy.storage.user.changed.assert_called_once_with(self.user)" verifies that our function correctly notifies the strategy when a user is changed.

While the email equality check confirms the outcome (email updated), this assertion verifies the mechanism (strategy notification). This is crucial because:

- It ensures the function fulfills its complete contract with the authentication system.
- It prevents refactoring that might bypass the strategy notification while still updating the email.
- It maintains consistency with social-auth-core's expected pipeline behavior.

In short, it tests both what happened and how it happened - both essential for complete test coverage.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# Verify email was not updated
self.user = User.objects.get(username=self.user.username)
self.assertEqual(self.user.email, old_email)
self.strategy.storage.user.changed.assert_not_called()
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, this assertion feels a little like overkill, unless you want to be very clear that the email didn't change and change back or something. I'll leave it up to you.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. You can ignore this. I'll go with your earlier explanation. Thanks.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

six
social-auth-app-django
social-auth-core # Common auth interfaces
edx-django-utils
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. Please alphabetize.
  2. Please set your editor to ensure a blank line at the end of files.
  3. See fix:bug in third party authentication email and name update #382 (comment). You'll still need to run make upgrade from a container to build the *.txt files appropriately. Let me know if this gives you issues.
    Thank you.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. custom_attribute_description: Records the actual username of the user
# when a mismatch occurs with the authentication details username.
set_custom_attribute('update_email.user_username', str(user_username))
return # Exit without updating email
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to add a temporary rollout toggle before this return. It could be a SettingToggle, based on a setting named SKIP_UPDATE_EMAIL_ON_USERNAME_MISMATCH. See docs:

I'm a little nervous about if it is possible to have None for the details' username, but still have a details' email provided. Adding this observability, with the toggle disabled, would allow us to test before enabling the toggle. We could later clean that up.

We might want to add the following to determine if this is ever the case:

set_custom_attribute('update_email.details_has_email', details.get('email') is not None)

If everything looks good from an observability perspective, we can enable the toggle. If things still look good, we can remove the temporary toggle.

Copy link
Contributor

Choose a reason for hiding this comment

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

Please let me know if you have any questions about this. Thank you.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

@robrap
Copy link
Contributor

robrap commented Jun 4, 2025

[request] You'll also want to update the version and changelog to 4.6.0. See example changes needed in this commit.

# .. toggle_description: Determines whether to block email updates when usernames don't match.
# When enabled (True), email updates will be blocked when the username in social auth details
# doesn't match the user's username. When disabled (False), email updates will proceed regardless
# of username mismatches.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
# of username mismatches.
# of username mismatches. This will be used for a temporary rollout.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Thank you.

# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2025-06-05
# .. toggle_target_removal_date: 2026-06-05
# .. toggle_warnings: This is a temporary toggle for a security enhancement rollout.
Copy link
Contributor

Choose a reason for hiding this comment

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

Moved and edited in https://github.com/openedx/auth-backends/pull/382/files#r2141085575.

Suggested change
# .. toggle_warnings: This is a temporary toggle for a security enhancement rollout.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. toggle_creation_date: 2025-06-05
# .. toggle_target_removal_date: 2026-06-05
# .. toggle_warnings: This is a temporary toggle for a security enhancement rollout.
# .. toggle_tickets: ARCHBOM-2181
Copy link
Contributor

Choose a reason for hiding this comment

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

We can't link to private tickets in public repositories. This PR and its description will be enough.

Suggested change
# .. toggle_tickets: ARCHBOM-2181

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. custom_attribute_name: update_email.details_username
# .. custom_attribute_description: Records the username provided in the
# social details when a mismatch occurs with the user's username.
set_custom_attribute('update_email.details_username', str(details_username))
Copy link
Contributor

Choose a reason for hiding this comment

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

Let the observability tool treat None differently from '' if it wishes.

Suggested change
set_custom_attribute('update_email.details_username', str(details_username))
set_custom_attribute('update_email.details_username', details_username)

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. custom_attribute_name: update_email.user_username
# .. custom_attribute_description: Records the actual username of the user
# when a mismatch occurs with the social details username.
set_custom_attribute('update_email.user_username', str(user_username))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
set_custom_attribute('update_email.user_username', str(user_username))
set_custom_attribute('update_email.user_username', user_username)

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# -r requirements/ci.in
virtualenv==20.30.0
# via tox
edx-django-utils==8.0.0
Copy link
Contributor

@robrap robrap Jun 11, 2025

Choose a reason for hiding this comment

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

This can be marked resolved.

@robrap
Copy link
Contributor

robrap commented Jun 12, 2025

@Akanshu-2u: You can ignore all comment threads ending with "This can be marked resolved." Thank you.

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

This is looking good and is very close, despite the fact that I left a bunch of comments. Thank you!

CHANGELOG.rst Outdated
Unreleased
----------

*
Copy link
Contributor

Choose a reason for hiding this comment

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

Switching this to a blank line:

Suggested change
*

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

CHANGELOG.rst Outdated
----------

*
[4.6.0] - 2025-06-09
Copy link
Contributor

Choose a reason for hiding this comment

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

We might update again, but this is closer.

Suggested change
[4.6.0] - 2025-06-09
[4.6.0] - 2025-06-18

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. toggle_description: Determines whether to block email updates when usernames don't match.
# When enabled (True), email updates will be blocked when the username in social auth details
# doesn't match the user's username. When disabled (False), email updates will proceed regardless
# of username mismatches.This will be used for a temporary rollout.
Copy link
Contributor

Choose a reason for hiding this comment

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

Original suggestion had a space.

Suggested change
# of username mismatches.This will be used for a temporary rollout.
# of username mismatches. This will be used for a temporary rollout.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

Comment on lines 21 to 22
# .. toggle_creation_date: 2025-06-05
# .. toggle_target_removal_date: 2026-06-05
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's give a shorter window for follow-up. I also updated the date.

Suggested change
# .. toggle_creation_date: 2025-06-05
# .. toggle_target_removal_date: 2026-06-05
# .. toggle_creation_date: 2025-06-18
# .. toggle_target_removal_date: 2025-08-18

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

details_username = details.get('username')
user_username = getattr(user, 'username', None)
# Check if usernames match
username_match = details_username == user_username
Copy link
Contributor

Choose a reason for hiding this comment

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

If this were username_mismatch (in place of username_match), you would avoid the not in several places below, which would read more like english, and would be more consistent with the temporary toggle name.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# .. custom_attribute_name: update_email.details_has_email
# .. custom_attribute_description: Records whether the details contain an email
# when a username mismatch occurs, to identify potential edge cases.
set_custom_attribute('update_email.details_has_email', details.get('email') is not None)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure when and if you might get an empty email here, so let's use bool instead. You do not need to add another unit test. This is just in case.

Suggested change
set_custom_attribute('update_email.details_has_email', details.get('email') is not None)
set_custom_attribute('update_email.details_has_email', bool(details.get('email')))

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Looks great. Let me know if you want to make this final change.

mock_set_attribute.assert_called_with('update_email.username_mismatch', False)
mock_set_attribute.assert_any_call('update_email.username_mismatch', False)
mock_set_attribute.assert_any_call('update_email.rollout_toggle_enabled', False)
# email_updated should not be called since email wasn't updated
Copy link
Contributor

@robrap robrap Jun 17, 2025

Choose a reason for hiding this comment

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

[optional/non-blocking] The mock has an array of calls args. Consider checking that the custom attribute was not called in place of a comment. Consider adding the same check to the True case to show that the code is appropriately checking, because it would be simple to code up a false positive that isn't really checking what you want.

Please leave a comment if you decide to leave as-is.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

mock_set_attribute.assert_any_call('update_email.rollout_toggle_enabled', False)
# email_updated should not be called since email wasn't updated
# Verify email_updated was not set
for call_args in mock_set_attribute.call_args_list:
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. I would use a list comprehension, because then you could easily assert that it is not found, but also assert that it was found.
  2. If you put this in a helper function, like assert_set_attribute_call(found = False), you could use the method in the earlier unit test, and prove that your code would find it if it could.
    See example code:
>>> call_args = [('a', 1), ('b', 2)]
>>> [x for x in call_args if x[0] == 'a']
[('a', 1)]
>>> len([x for x in call_args if x[0] == 'a']) == 0
False
>>> len([x for x in call_args if x[0] == 'c']) == 0
True

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

# email_updated should not be called since email wasn't updated
# Verify email_updated was not set
for call_args in mock_set_attribute.call_args_list:
self.assertNotEqual(call_args[0][0], 'update_email.email_updated')
Copy link
Contributor

Choose a reason for hiding this comment

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

It's unclear if call_args[0][0] is correct or not. As noted in the other column, if you use the same method to assert elsewhere that this call is found, it would make it easier to trust that it not being found is not a false positive.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be marked resolved.

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

@openedx/committers-auth-backends: [request] I have reviewed and approved. If you are able to approve, squash and release the library, that would be great. Thank you!

We plan to remove temporary toggles once we've proven this safe.

UPDATE: The PR description (which I can't update) reads:

Prevents potential account takeover by ensuring emails are only updated when the authentication flow correctly identifies the user.
This isn't really true. User records are updated with the wrong email, but since this happens in a service other than the identity provider, there is no account takeover. It does cause invalid data.

Copy link

@idegtiarov idegtiarov left a comment

Choose a reason for hiding this comment

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

@Akanshu-2u @robrap thank you guys for the great work you did on that PR.

@idegtiarov idegtiarov merged commit 3444417 into openedx:master Jun 23, 2025
12 checks passed
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.

3 participants