Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions openedx/core/djangoapps/user_authn/views/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@
}
REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
IS_MARKETABLE = 'is_marketable'

# Name of the UserAttribute that records which flow originated a registration.
REGISTRATION_SOURCE = 'registration_source'
# Known registration sources that should NOT trigger the account activation email.
# Learners registered through these flows are auto-activated because another trusted
# system has already vouched for the email address.
REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL = frozenset({
'enterprise_sponsor_checkout',
})
# used to announce a registration
# providing_args=["user", "registration"]
REGISTER_USER = Signal()
Expand Down Expand Up @@ -241,9 +250,21 @@ def create_account_with_params(request, params): # pylint: disable=too-many-sta
if not preferences_api.has_user_preference(user, LANGUAGE_KEY):
preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())

# Only accept a registration_source value from callers if it is on the
# allowlist. Arbitrary values supplied by clients are dropped so they
# cannot unilaterally suppress the activation email.
raw_registration_source = params.get('registration_source')
registration_source = (
raw_registration_source
if raw_registration_source in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL
else None
)
if registration_source:
UserAttribute.set_user_attribute(user, REGISTRATION_SOURCE, registration_source)

# Check if system is configured to skip activation email for the current user.
skip_email = _skip_activation_email(
user, running_pipeline, third_party_provider,
user, running_pipeline, third_party_provider, registration_source=registration_source,
)

if skip_email:
Expand Down Expand Up @@ -425,7 +446,7 @@ def _track_user_registration(user, profile, params, third_party_provider, regist
)


def _skip_activation_email(user, running_pipeline, third_party_provider):
def _skip_activation_email(user, running_pipeline, third_party_provider, registration_source=None):
"""
Return `True` if activation email should be skipped.

Expand All @@ -435,6 +456,8 @@ def _skip_activation_email(user, running_pipeline, third_party_provider):
3. External auth bypassing activation.
4. Have the platform configured to not require e-mail activation.
5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
6. Registering via a trusted in-product flow that has already vetted the email
(see REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL).

Note that this feature is only tested as a flag set one way or
the other for *new* systems. we need to be careful about
Expand All @@ -445,6 +468,9 @@ def _skip_activation_email(user, running_pipeline, third_party_provider):
user (User): Django User object for the current user.
running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication.
third_party_provider (ProviderConfig): An instance of third party provider configuration.
registration_source (str|None): Identifier of the originating registration flow, if
supplied by the caller. Only values in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL
grant the skip.

Returns:
(bool): `True` if account activation email should be skipped, `False` if account activation email should be
Expand Down Expand Up @@ -475,10 +501,15 @@ def _skip_activation_email(user, running_pipeline, third_party_provider):
getattr(third_party_provider, "identity_provider_type", None)
)

trusted_registration_source = (
registration_source in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL
)

return (
settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or
settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or
(third_party_provider and third_party_provider.skip_email_verification and valid_email)
(third_party_provider and third_party_provider.skip_email_verification and valid_email) or
trusted_registration_source
)


Expand Down
38 changes: 38 additions & 0 deletions openedx/core/djangoapps/user_authn/views/tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from testfixtures import LogCapture

from common.djangoapps.student.helpers import authenticate_new_user
from common.djangoapps.student.models import UserAttribute
from common.djangoapps.student.tests.factories import AccountRecoveryFactory, UserFactory
from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline
from common.djangoapps.third_party_auth.tests.utils import (
Expand Down Expand Up @@ -1586,6 +1587,43 @@ def test_activation_email(self):
f'Action Required: Activate your {settings.PLATFORM_NAME} account'
assert f'high-quality {settings.PLATFORM_NAME} courses' in sent_email.body

def test_trusted_registration_source_skips_activation_email(self):
# Registration through a trusted in-product flow should auto-activate the
# account and suppress the welcome/activation email.
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
"registration_source": "enterprise_sponsor_checkout",
})
self.assertHttpOK(response)

assert mail.outbox == []
user = User.objects.get(username=self.USERNAME)
assert user.is_active
assert UserAttribute.get_user_attribute(user, 'registration_source') == \
'enterprise_sponsor_checkout'

def test_unknown_registration_source_does_not_skip_activation_email(self):
# Unknown registration_source values must not grant the skip. The activation
# email still goes out and the attribute is not recorded.
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
"registration_source": "totally-made-up",
})
self.assertHttpOK(response)

assert len(mail.outbox) == 1
user = User.objects.get(username=self.USERNAME)
assert not user.is_active
assert UserAttribute.get_user_attribute(user, 'registration_source') is None

@ddt.data(
{"email": ""},
{"email": "invalid"},
Expand Down