Merge pull request #18917 from edx/arch/user-authn-app
Consolidate user login and authentication code
This commit is contained in:
@@ -316,7 +316,7 @@ class ShibSPTest(CacheIsolationTestCase):
|
||||
'terms_of_service': u'true',
|
||||
'honor_code': u'true'}
|
||||
|
||||
with patch('student.views.management.AUDIT_LOG') as mock_audit_log:
|
||||
with patch('openedx.core.djangoapps.user_authn.views.register.AUDIT_LOG') as mock_audit_log:
|
||||
self.client.post('/create_account', data=postvars)
|
||||
|
||||
mail = identity.get('mail')
|
||||
|
||||
@@ -282,7 +282,9 @@ def _signup(request, eamap, retfun=None):
|
||||
retfun is a function to execute for the return value, if immediate
|
||||
signup is used. That allows @ssl_login_shortcut() to work.
|
||||
"""
|
||||
# save this for use by student.views.create_account
|
||||
from openedx.core.djangoapps.user_authn.views.deprecated import create_account, register_user
|
||||
|
||||
# save this for use by create_account
|
||||
request.session['ExternalAuthMap'] = eamap
|
||||
|
||||
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP', ''):
|
||||
@@ -294,7 +296,7 @@ def _signup(request, eamap, retfun=None):
|
||||
honor_code=u'true',
|
||||
terms_of_service=u'true')
|
||||
log.info(u'doing immediate signup for %s, params=%s', username, post_vars)
|
||||
student.views.create_account(request, post_vars)
|
||||
create_account(request, post_vars)
|
||||
# should check return content for successful completion before
|
||||
if retfun is not None:
|
||||
return retfun()
|
||||
@@ -335,7 +337,7 @@ def _signup(request, eamap, retfun=None):
|
||||
|
||||
log.info(u'EXTAUTH: Doing signup for %s', eamap.external_id)
|
||||
|
||||
return student.views.register_user(request, extra_context=context)
|
||||
return register_user(request, extra_context=context)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
15
openedx/core/djangoapps/oauth_dispatch/api.py
Normal file
15
openedx/core/djangoapps/oauth_dispatch/api.py
Normal file
@@ -0,0 +1,15 @@
|
||||
""" OAuth related Python apis. """
|
||||
from oauth2_provider.models import AccessToken as dot_access_token
|
||||
from oauth2_provider.models import RefreshToken as dot_refresh_token
|
||||
from provider.oauth2.models import AccessToken as dop_access_token
|
||||
from provider.oauth2.models import RefreshToken as dop_refresh_token
|
||||
|
||||
|
||||
def destroy_oauth_tokens(user):
|
||||
"""
|
||||
Destroys ALL OAuth access and refresh tokens for the given user.
|
||||
"""
|
||||
dop_access_token.objects.filter(user=user.id).delete()
|
||||
dop_refresh_token.objects.filter(user=user.id).delete()
|
||||
dot_access_token.objects.filter(user=user.id).delete()
|
||||
dot_refresh_token.objects.filter(user=user.id).delete()
|
||||
241
openedx/core/djangoapps/user_api/accounts/settings_views.py
Normal file
241
openedx/core/djangoapps/user_api/accounts/settings_views.py
Normal file
@@ -0,0 +1,241 @@
|
||||
""" Views related to Account Settings. """
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django_countries import countries
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
|
||||
from lms.djangoapps.commerce.models import CommerceConfiguration
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
|
||||
from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
|
||||
from openedx.features.enterprise_support.api import get_enterprise_customer_for_learner
|
||||
from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise
|
||||
from student.models import UserProfile
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from util.date_utils import strftime_localized
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def account_settings(request):
|
||||
"""Render the current user's account settings page.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the page was sent successfully
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
GET /account/settings
|
||||
|
||||
"""
|
||||
context = account_settings_context(request)
|
||||
return render_to_response('student_account/account_settings.html', context)
|
||||
|
||||
|
||||
def account_settings_context(request):
|
||||
""" Context for the account settings page.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
|
||||
Returns:
|
||||
dict
|
||||
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]
|
||||
try:
|
||||
user_orders = get_user_orders(user)
|
||||
except: # pylint: disable=bare-except
|
||||
log.exception('Error fetching order history from Otto.')
|
||||
# Return empty order list as account settings page expect a list and
|
||||
# it will be broken if exception raised
|
||||
user_orders = []
|
||||
|
||||
context = {
|
||||
'auth': {},
|
||||
'duplicate_provider': None,
|
||||
'nav_hidden': True,
|
||||
'fields': {
|
||||
'country': {
|
||||
'options': list(countries),
|
||||
}, 'gender': {
|
||||
'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES],
|
||||
}, 'language': {
|
||||
'options': released_languages(),
|
||||
}, 'level_of_education': {
|
||||
'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES],
|
||||
}, 'password': {
|
||||
'url': reverse('password_reset'),
|
||||
}, 'year_of_birth': {
|
||||
'options': year_of_birth_options,
|
||||
}, 'preferred_language': {
|
||||
'options': all_languages(),
|
||||
}, 'time_zone': {
|
||||
'options': TIME_ZONE_CHOICES,
|
||||
}
|
||||
},
|
||||
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
|
||||
'password_reset_support_link': configuration_helpers.get_value(
|
||||
'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK
|
||||
) or settings.SUPPORT_SITE_LINK,
|
||||
'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
|
||||
'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
|
||||
'disable_courseware_js': True,
|
||||
'show_program_listing': ProgramsApiConfig.is_enabled(),
|
||||
'show_dashboard_tabs': True,
|
||||
'order_history': user_orders,
|
||||
'enable_account_deletion': configuration_helpers.get_value(
|
||||
'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False)
|
||||
),
|
||||
'extended_profile_fields': _get_extended_profile_fields(),
|
||||
}
|
||||
|
||||
enterprise_customer = get_enterprise_customer_for_learner(site=request.site, user=request.user)
|
||||
update_account_settings_context_for_enterprise(context, enterprise_customer)
|
||||
|
||||
if third_party_auth.is_enabled():
|
||||
# If the account on the third party provider is already connected with another edX account,
|
||||
# we display a message to the user.
|
||||
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
|
||||
|
||||
auth_states = pipeline.get_provider_user_states(user)
|
||||
|
||||
context['auth']['providers'] = [{
|
||||
'id': state.provider.provider_id,
|
||||
'name': state.provider.name, # The name of the provider e.g. Facebook
|
||||
'connected': state.has_account, # Whether the user's edX account is connected with the provider.
|
||||
# If the user is not connected, they should be directed to this page to authenticate
|
||||
# with the particular provider, as long as the provider supports initiating a login.
|
||||
'connect_url': pipeline.get_login_url(
|
||||
state.provider.provider_id,
|
||||
pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
|
||||
# The url the user should be directed to after the auth process has completed.
|
||||
redirect_url=reverse('account_settings'),
|
||||
),
|
||||
'accepts_logins': state.provider.accepts_logins,
|
||||
# If the user is connected, sending a POST request to this url removes the connection
|
||||
# information for this provider from their edX account.
|
||||
'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id),
|
||||
# We only want to include providers if they are either currently available to be logged
|
||||
# in with, or if the user is already authenticated with them.
|
||||
} for state in auth_states if state.provider.display_for_login or state.has_account]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_user_orders(user):
|
||||
"""Given a user, get the detail of all the orders from the Ecommerce service.
|
||||
|
||||
Args:
|
||||
user (User): The user to authenticate as when requesting ecommerce.
|
||||
|
||||
Returns:
|
||||
list of dict, representing orders returned by the Ecommerce service.
|
||||
"""
|
||||
user_orders = []
|
||||
commerce_configuration = CommerceConfiguration.current()
|
||||
user_query = {'username': user.username}
|
||||
|
||||
use_cache = commerce_configuration.is_cache_enabled
|
||||
cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None
|
||||
api = ecommerce_api_client(user)
|
||||
commerce_user_orders = get_edx_api_data(
|
||||
commerce_configuration, 'orders', api=api, querystring=user_query, cache_key=cache_key
|
||||
)
|
||||
|
||||
for order in commerce_user_orders:
|
||||
if order['status'].lower() == 'complete':
|
||||
date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ")
|
||||
order_data = {
|
||||
'number': order['number'],
|
||||
'price': order['total_excl_tax'],
|
||||
'order_date': strftime_localized(date_placed, 'SHORT_DATE'),
|
||||
'receipt_url': EcommerceService().get_receipt_page_url(order['number']),
|
||||
'lines': order['lines'],
|
||||
}
|
||||
user_orders.append(order_data)
|
||||
|
||||
return user_orders
|
||||
|
||||
|
||||
def _get_extended_profile_fields():
|
||||
"""Retrieve the extended profile fields from site configuration to be shown on the
|
||||
Account Settings page
|
||||
|
||||
Returns:
|
||||
A list of dicts. Each dict corresponds to a single field. The keys per field are:
|
||||
"field_name" : name of the field stored in user_profile.meta
|
||||
"field_label" : The label of the field.
|
||||
"field_type" : TextField or ListField
|
||||
"field_options": a list of tuples for options in the dropdown in case of ListField
|
||||
"""
|
||||
|
||||
extended_profile_fields = []
|
||||
fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education',
|
||||
'gender', 'year_of_birth', 'language_proficiencies', 'social_links']
|
||||
|
||||
field_labels_map = {
|
||||
"first_name": _(u"First Name"),
|
||||
"last_name": _(u"Last Name"),
|
||||
"city": _(u"City"),
|
||||
"state": _(u"State/Province/Region"),
|
||||
"company": _(u"Company"),
|
||||
"title": _(u"Title"),
|
||||
"job_title": _(u"Job Title"),
|
||||
"mailing_address": _(u"Mailing address"),
|
||||
"goals": _(u"Tell us why you're interested in {platform_name}").format(
|
||||
platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME)
|
||||
),
|
||||
"profession": _(u"Profession"),
|
||||
"specialty": _(u"Specialty")
|
||||
}
|
||||
|
||||
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
|
||||
for field_to_exclude in fields_already_showing:
|
||||
if field_to_exclude in extended_profile_field_names:
|
||||
extended_profile_field_names.remove(field_to_exclude)
|
||||
|
||||
extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', [])
|
||||
extended_profile_field_option_tuples = {}
|
||||
for field in extended_profile_field_options.keys():
|
||||
field_options = extended_profile_field_options[field]
|
||||
extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options]
|
||||
|
||||
for field in extended_profile_field_names:
|
||||
field_dict = {
|
||||
"field_name": field,
|
||||
"field_label": field_labels_map.get(field, field),
|
||||
}
|
||||
|
||||
field_options = extended_profile_field_option_tuples.get(field)
|
||||
if field_options:
|
||||
field_dict["field_type"] = "ListField"
|
||||
field_dict["field_options"] = field_options
|
||||
else:
|
||||
field_dict["field_type"] = "TextField"
|
||||
extended_profile_fields.append(field_dict)
|
||||
|
||||
return extended_profile_fields
|
||||
@@ -0,0 +1,229 @@
|
||||
""" Tests for views related to account settings. """
|
||||
# -*- coding: utf-8 -*-
|
||||
import mock
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.urls import reverse
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
from edx_rest_api_client import exceptions
|
||||
|
||||
from lms.djangoapps.commerce.models import CommerceConfiguration
|
||||
from lms.djangoapps.commerce.tests import factories
|
||||
from lms.djangoapps.commerce.tests.mocks import mock_get_orders
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin):
|
||||
""" Tests for the account settings view. """
|
||||
|
||||
USERNAME = 'student'
|
||||
PASSWORD = 'password'
|
||||
FIELDS = [
|
||||
'country',
|
||||
'gender',
|
||||
'language',
|
||||
'level_of_education',
|
||||
'password',
|
||||
'year_of_birth',
|
||||
'preferred_language',
|
||||
'time_zone',
|
||||
]
|
||||
|
||||
@mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage')
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(AccountSettingsViewTest, self).setUp()
|
||||
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
|
||||
CommerceConfiguration.objects.create(cache_ttl=10, enabled=True)
|
||||
self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
|
||||
self.request = HttpRequest()
|
||||
self.request.user = self.user
|
||||
|
||||
# For these tests, two third party auth providers are enabled by default:
|
||||
self.configure_google_provider(enabled=True, visible=True)
|
||||
self.configure_facebook_provider(enabled=True, visible=True)
|
||||
|
||||
# Python-social saves auth failure notifcations in Django messages.
|
||||
# See pipeline.get_duplicate_provider() for details.
|
||||
self.request.COOKIES = {}
|
||||
MessageMiddleware().process_request(self.request)
|
||||
messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook')
|
||||
|
||||
@mock.patch('openedx.features.enterprise_support.api.get_enterprise_customer_for_learner')
|
||||
def test_context(self, mock_get_enterprise_customer_for_learner):
|
||||
self.request.site = SiteFactory.create()
|
||||
mock_get_enterprise_customer_for_learner.return_value = {}
|
||||
context = account_settings_context(self.request)
|
||||
|
||||
user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
|
||||
self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url)
|
||||
|
||||
user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username})
|
||||
self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url)
|
||||
|
||||
for attribute in self.FIELDS:
|
||||
self.assertIn(attribute, context['fields'])
|
||||
|
||||
self.assertEqual(
|
||||
context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username})
|
||||
)
|
||||
self.assertEqual(
|
||||
context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username})
|
||||
)
|
||||
|
||||
self.assertEqual(context['duplicate_provider'], 'facebook')
|
||||
self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
|
||||
self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
|
||||
|
||||
self.assertEqual(context['sync_learner_profile_data'], False)
|
||||
self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK)
|
||||
self.assertEqual(context['enterprise_name'], None)
|
||||
self.assertEqual(
|
||||
context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS}
|
||||
)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.get_enterprise_customer_for_learner')
|
||||
@mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get')
|
||||
def test_context_for_enterprise_learner(
|
||||
self, mock_get_auth_provider, mock_get_enterprise_customer_for_learner
|
||||
):
|
||||
dummy_enterprise_customer = {
|
||||
'uuid': 'real-ent-uuid',
|
||||
'name': 'Dummy Enterprise',
|
||||
'identity_provider': 'saml-ubc'
|
||||
}
|
||||
mock_get_enterprise_customer_for_learner.return_value = dummy_enterprise_customer
|
||||
self.request.site = SiteFactory.create()
|
||||
mock_get_auth_provider.return_value.sync_learner_profile_data = True
|
||||
context = account_settings_context(self.request)
|
||||
|
||||
user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
|
||||
self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url)
|
||||
|
||||
user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username})
|
||||
self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url)
|
||||
|
||||
for attribute in self.FIELDS:
|
||||
self.assertIn(attribute, context['fields'])
|
||||
|
||||
self.assertEqual(
|
||||
context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username})
|
||||
)
|
||||
self.assertEqual(
|
||||
context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username})
|
||||
)
|
||||
|
||||
self.assertEqual(context['duplicate_provider'], 'facebook')
|
||||
self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
|
||||
self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
|
||||
|
||||
self.assertEqual(
|
||||
context['sync_learner_profile_data'], mock_get_auth_provider.return_value.sync_learner_profile_data
|
||||
)
|
||||
self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK)
|
||||
self.assertEqual(context['enterprise_name'], dummy_enterprise_customer['name'])
|
||||
self.assertEqual(
|
||||
context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS}
|
||||
)
|
||||
|
||||
def test_view(self):
|
||||
"""
|
||||
Test that all fields are visible
|
||||
"""
|
||||
view_path = reverse('account_settings')
|
||||
response = self.client.get(path=view_path)
|
||||
|
||||
for attribute in self.FIELDS:
|
||||
self.assertIn(attribute, response.content)
|
||||
|
||||
def test_header_with_programs_listing_enabled(self):
|
||||
"""
|
||||
Verify that tabs header will be shown while program listing is enabled.
|
||||
"""
|
||||
self.create_programs_config()
|
||||
view_path = reverse('account_settings')
|
||||
response = self.client.get(path=view_path)
|
||||
|
||||
self.assertContains(response, 'global-header')
|
||||
|
||||
def test_header_with_programs_listing_disabled(self):
|
||||
"""
|
||||
Verify that nav header will be shown while program listing is disabled.
|
||||
"""
|
||||
self.create_programs_config(enabled=False)
|
||||
view_path = reverse('account_settings')
|
||||
response = self.client.get(path=view_path)
|
||||
|
||||
self.assertContains(response, 'global-header')
|
||||
|
||||
def test_commerce_order_detail(self):
|
||||
"""
|
||||
Verify that get_user_orders returns the correct order data.
|
||||
"""
|
||||
with mock_get_orders():
|
||||
order_detail = get_user_orders(self.user)
|
||||
|
||||
for i, order in enumerate(mock_get_orders.default_response['results']):
|
||||
expected = {
|
||||
'number': order['number'],
|
||||
'price': order['total_excl_tax'],
|
||||
'order_date': 'Jan 01, 2016',
|
||||
'receipt_url': '/checkout/receipt/?order_number=' + order['number'],
|
||||
'lines': order['lines'],
|
||||
}
|
||||
self.assertEqual(order_detail[i], expected)
|
||||
|
||||
def test_commerce_order_detail_exception(self):
|
||||
with mock_get_orders(exception=exceptions.HttpNotFoundError):
|
||||
order_detail = get_user_orders(self.user)
|
||||
|
||||
self.assertEqual(order_detail, [])
|
||||
|
||||
def test_incomplete_order_detail(self):
|
||||
response = {
|
||||
'results': [
|
||||
factories.OrderFactory(
|
||||
status='Incomplete',
|
||||
lines=[
|
||||
factories.OrderLineFactory(
|
||||
product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()])
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
with mock_get_orders(response=response):
|
||||
order_detail = get_user_orders(self.user)
|
||||
|
||||
self.assertEqual(order_detail, [])
|
||||
|
||||
def test_order_history_with_no_product(self):
|
||||
response = {
|
||||
'results': [
|
||||
factories.OrderFactory(
|
||||
lines=[
|
||||
factories.OrderLineFactory(
|
||||
product=None
|
||||
),
|
||||
factories.OrderLineFactory(
|
||||
product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory(
|
||||
name='certificate_type',
|
||||
value='verified'
|
||||
)])
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
with mock_get_orders(response=response):
|
||||
order_detail = get_user_orders(self.user)
|
||||
|
||||
self.assertEqual(len(order_detail), 1)
|
||||
@@ -33,6 +33,7 @@ from wiki.models import ArticleRevision
|
||||
from wiki.models.pluginbase import RevisionPluginRevision
|
||||
|
||||
from entitlements.models import CourseEntitlement
|
||||
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
|
||||
from openedx.core.djangoapps.credit.models import CreditRequirementStatus, CreditRequest
|
||||
@@ -51,6 +52,7 @@ from student.models import (
|
||||
PasswordHistory,
|
||||
PendingNameChange,
|
||||
CourseEnrollmentAllowed,
|
||||
LoginFailures,
|
||||
PendingEmailChange,
|
||||
Registration,
|
||||
User,
|
||||
@@ -60,8 +62,6 @@ from student.models import (
|
||||
get_retired_username_by_username,
|
||||
is_username_retired
|
||||
)
|
||||
from student.views.login import AuthFailedError, LoginFailures
|
||||
|
||||
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
|
||||
from ..models import (
|
||||
RetirementState,
|
||||
|
||||
@@ -9,9 +9,6 @@ from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import csrf_exempt, csrf_protect, ensure_csrf_cookie
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx import locator
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import authentication, generics, status, viewsets
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.views import APIView
|
||||
@@ -20,6 +17,9 @@ from six import text_type
|
||||
|
||||
import accounts
|
||||
from django_comment_common.models import Role
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx import locator
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.core.djangoapps.user_api.accounts.api import check_account_exists
|
||||
from openedx.core.djangoapps.user_api.api import (
|
||||
RegistrationFormFactory,
|
||||
@@ -30,10 +30,11 @@ from openedx.core.djangoapps.user_api.helpers import require_post_params, shim_s
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones, update_email_opt_in
|
||||
from openedx.core.djangoapps.user_api.serializers import CountryTimeZoneSerializer, UserPreferenceSerializer, UserSerializer
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
|
||||
from openedx.core.djangoapps.user_authn.views.register import create_account_with_params
|
||||
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
|
||||
from student.cookies import set_logged_in_cookies
|
||||
from student.views import AccountValidationError, create_account_with_params
|
||||
from student.helpers import AccountValidationError
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
|
||||
@@ -82,7 +83,7 @@ class LoginSessionView(APIView):
|
||||
"""
|
||||
# For the initial implementation, shim the existing login view
|
||||
# from the student Django app.
|
||||
from student.views import login_user
|
||||
from openedx.core.djangoapps.user_authn.views.login import login_user
|
||||
return shim_student_view(login_user, check_logged_in=True)(request)
|
||||
|
||||
@method_decorator(sensitive_post_parameters("password"))
|
||||
|
||||
0
openedx/core/djangoapps/user_authn/__init__.py
Normal file
0
openedx/core/djangoapps/user_authn/__init__.py
Normal file
24
openedx/core/djangoapps/user_authn/apps.py
Normal file
24
openedx/core/djangoapps/user_authn/apps.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
User Authentication Configuration
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs
|
||||
|
||||
|
||||
class UserAuthnConfig(AppConfig):
|
||||
"""
|
||||
Application Configuration for User Authentication.
|
||||
"""
|
||||
name = u'openedx.core.djangoapps.user_authn'
|
||||
|
||||
plugin_app = {
|
||||
PluginURLs.CONFIG: {
|
||||
ProjectType.LMS: {
|
||||
PluginURLs.NAMESPACE: u'',
|
||||
PluginURLs.REGEX: u'',
|
||||
PluginURLs.RELATIVE_PATH: u'urls',
|
||||
},
|
||||
},
|
||||
}
|
||||
218
openedx/core/djangoapps/user_authn/cookies.py
Normal file
218
openedx/core/djangoapps/user_authn/cookies.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Utility functions for setting "logged in" cookies used by subdomains.
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.dispatch import Signal
|
||||
from django.utils.http import cookie_date
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response'])
|
||||
|
||||
|
||||
def standard_cookie_settings(request):
|
||||
""" Returns the common cookie settings (e.g. expiration time). """
|
||||
|
||||
if request.session.get_expire_at_browser_close():
|
||||
max_age = None
|
||||
expires = None
|
||||
else:
|
||||
max_age = request.session.get_expiry_age()
|
||||
expires_time = time.time() + max_age
|
||||
expires = cookie_date(expires_time)
|
||||
|
||||
cookie_settings = {
|
||||
'max_age': max_age,
|
||||
'expires': expires,
|
||||
'domain': settings.SESSION_COOKIE_DOMAIN,
|
||||
'path': '/',
|
||||
'httponly': None,
|
||||
}
|
||||
|
||||
return cookie_settings
|
||||
|
||||
|
||||
def set_logged_in_cookies(request, response, user):
|
||||
"""
|
||||
Set cookies indicating that the user is logged in.
|
||||
|
||||
Some installations have an external marketing site configured
|
||||
that displays a different UI when the user is logged in
|
||||
(e.g. a link to the student dashboard instead of to the login page)
|
||||
|
||||
Currently, two cookies are set:
|
||||
|
||||
* EDXMKTG_LOGGED_IN_COOKIE_NAME: Set to 'true' if the user is logged in.
|
||||
* EDXMKTG_USER_INFO_COOKIE_VERSION: JSON-encoded dictionary with user information (see below).
|
||||
|
||||
The user info cookie has the following format:
|
||||
{
|
||||
"version": 1,
|
||||
"username": "test-user",
|
||||
"header_urls": {
|
||||
"account_settings": "https://example.com/account/settings",
|
||||
"resume_block":
|
||||
"https://example.com//courses/org.0/course_0/Run_0/jump_to/i4x://org.0/course_0/vertical/vertical_4"
|
||||
"learner_profile": "https://example.com/u/test-user",
|
||||
"logout": "https://example.com/logout"
|
||||
}
|
||||
}
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest): The request to the view, used to calculate
|
||||
the cookie's expiration date based on the session expiration date.
|
||||
response (HttpResponse): The response on which the cookie will be set.
|
||||
user (User): The currently logged in user.
|
||||
|
||||
Returns:
|
||||
HttpResponse
|
||||
|
||||
"""
|
||||
cookie_settings = standard_cookie_settings(request)
|
||||
|
||||
# Backwards compatibility: set the cookie indicating that the user
|
||||
# is logged in. This is just a boolean value, so it's not very useful.
|
||||
# In the future, we should be able to replace this with the "user info"
|
||||
# cookie set below.
|
||||
response.set_cookie(
|
||||
settings.EDXMKTG_LOGGED_IN_COOKIE_NAME.encode('utf-8'),
|
||||
'true',
|
||||
secure=None,
|
||||
**cookie_settings
|
||||
)
|
||||
|
||||
set_user_info_cookie(response, request)
|
||||
|
||||
# give signal receivers a chance to add cookies
|
||||
CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def set_user_info_cookie(response, request):
|
||||
""" Sets the user info cookie on the response. """
|
||||
cookie_settings = standard_cookie_settings(request)
|
||||
|
||||
# In production, TLS should be enabled so that this cookie is encrypted
|
||||
# when we send it. We also need to set "secure" to True so that the browser
|
||||
# will transmit it only over secure connections.
|
||||
#
|
||||
# In non-production environments (acceptance tests, devstack, and sandboxes),
|
||||
# we still want to set this cookie. However, we do NOT want to set it to "secure"
|
||||
# because the browser won't send it back to us. This can cause an infinite redirect
|
||||
# loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine
|
||||
# whether it needs to set the cookie or continue to the next pipeline stage.
|
||||
user_info_cookie_is_secure = request.is_secure()
|
||||
user_info = get_user_info_cookie_data(request)
|
||||
|
||||
response.set_cookie(
|
||||
settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'),
|
||||
json.dumps(user_info),
|
||||
secure=user_info_cookie_is_secure,
|
||||
**cookie_settings
|
||||
)
|
||||
|
||||
|
||||
def set_experiments_is_enterprise_cookie(request, response, experiments_is_enterprise):
|
||||
""" Sets the experiments_is_enterprise cookie on the response.
|
||||
This cookie can be used for tests or minor features,
|
||||
but should not be used for payment related or other critical work
|
||||
since users can edit their cookies
|
||||
"""
|
||||
cookie_settings = standard_cookie_settings(request)
|
||||
# In production, TLS should be enabled so that this cookie is encrypted
|
||||
# when we send it. We also need to set "secure" to True so that the browser
|
||||
# will transmit it only over secure connections.
|
||||
#
|
||||
# In non-production environments (acceptance tests, devstack, and sandboxes),
|
||||
# we still want to set this cookie. However, we do NOT want to set it to "secure"
|
||||
# because the browser won't send it back to us. This can cause an infinite redirect
|
||||
# loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine
|
||||
# whether it needs to set the cookie or continue to the next pipeline stage.
|
||||
cookie_is_secure = request.is_secure()
|
||||
|
||||
response.set_cookie(
|
||||
'experiments_is_enterprise',
|
||||
json.dumps(experiments_is_enterprise),
|
||||
secure=cookie_is_secure,
|
||||
**cookie_settings
|
||||
)
|
||||
|
||||
|
||||
def get_user_info_cookie_data(request):
|
||||
""" Returns information that wil populate the user info cookie. """
|
||||
user = request.user
|
||||
|
||||
# Set a cookie with user info. This can be used by external sites
|
||||
# to customize content based on user information. Currently,
|
||||
# we include information that's used to customize the "account"
|
||||
# links in the header of subdomain sites (such as the marketing site).
|
||||
header_urls = {'logout': reverse('logout')}
|
||||
|
||||
# Unfortunately, this app is currently used by both the LMS and Studio login pages.
|
||||
# If we're in Studio, we won't be able to reverse the account/profile URLs.
|
||||
# To handle this, we don't add the URLs if we can't reverse them.
|
||||
# External sites will need to have fallback mechanisms to handle this case
|
||||
# (most likely just hiding the links).
|
||||
try:
|
||||
header_urls['account_settings'] = reverse('account_settings')
|
||||
header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username})
|
||||
except NoReverseMatch:
|
||||
pass
|
||||
|
||||
# Add 'resume course' last completed block
|
||||
try:
|
||||
header_urls['resume_block'] = retrieve_last_sitewide_block_completed(user)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Convert relative URL paths to absolute URIs
|
||||
for url_name, url_path in six.iteritems(header_urls):
|
||||
header_urls[url_name] = request.build_absolute_uri(url_path)
|
||||
|
||||
user_info = {
|
||||
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
|
||||
'username': user.username,
|
||||
'header_urls': header_urls,
|
||||
'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(user)
|
||||
}
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
def delete_logged_in_cookies(response):
|
||||
"""
|
||||
Delete cookies indicating that the user is logged in.
|
||||
|
||||
Arguments:
|
||||
response (HttpResponse): The response sent to the client.
|
||||
|
||||
Returns:
|
||||
HttpResponse
|
||||
|
||||
"""
|
||||
for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]:
|
||||
response.delete_cookie(
|
||||
cookie_name.encode('utf-8'),
|
||||
path='/',
|
||||
domain=settings.SESSION_COOKIE_DOMAIN
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def is_logged_in_cookie_set(request):
|
||||
"""Check whether the request has logged in cookies set. """
|
||||
return (
|
||||
settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and
|
||||
settings.EDXMKTG_USER_INFO_COOKIE_NAME in request.COOKIES
|
||||
)
|
||||
22
openedx/core/djangoapps/user_authn/exceptions.py
Normal file
22
openedx/core/djangoapps/user_authn/exceptions.py
Normal file
@@ -0,0 +1,22 @@
|
||||
""" User Authn related Exceptions. """
|
||||
|
||||
|
||||
class AuthFailedError(Exception):
|
||||
"""
|
||||
This is a helper for the login view, allowing the various sub-methods to early out with an appropriate failure
|
||||
message.
|
||||
"""
|
||||
def __init__(self, value=None, redirect=None, redirect_url=None):
|
||||
super(AuthFailedError, self).__init__()
|
||||
self.value = value
|
||||
self.redirect = redirect
|
||||
self.redirect_url = redirect_url
|
||||
|
||||
def get_response(self):
|
||||
""" Returns a dict representation of the error. """
|
||||
resp = {'success': False}
|
||||
for attr in ('value', 'redirect', 'redirect_url'):
|
||||
if self.__getattribute__(attr):
|
||||
resp[attr] = self.__getattribute__(attr)
|
||||
|
||||
return resp
|
||||
59
openedx/core/djangoapps/user_authn/tests/test_cookies.py
Normal file
59
openedx/core/djangoapps/user_authn/tests/test_cookies.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# pylint: disable=missing-docstring
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.test import RequestFactory
|
||||
|
||||
from openedx.core.djangoapps.user_authn.cookies import get_user_info_cookie_data
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class CookieTests(SharedModuleStoreTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CookieTests, cls).setUpClass()
|
||||
cls.course = CourseFactory()
|
||||
|
||||
def setUp(self):
|
||||
super(CookieTests, self).setUp()
|
||||
self.user = UserFactory.create()
|
||||
|
||||
def _get_expected_header_urls(self, request):
|
||||
expected_header_urls = {
|
||||
'logout': reverse('logout'),
|
||||
'resume_block': retrieve_last_sitewide_block_completed(self.user.username)
|
||||
}
|
||||
|
||||
# Studio (CMS) does not have the URLs below
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
expected_header_urls.update({
|
||||
'account_settings': reverse('account_settings'),
|
||||
'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}),
|
||||
})
|
||||
|
||||
# Convert relative URL paths to absolute URIs
|
||||
for url_name, url_path in six.iteritems(expected_header_urls):
|
||||
expected_header_urls[url_name] = request.build_absolute_uri(url_path)
|
||||
|
||||
return expected_header_urls
|
||||
|
||||
def test_get_user_info_cookie_data(self):
|
||||
request = RequestFactory().get('/')
|
||||
request.user = self.user
|
||||
|
||||
actual = get_user_info_cookie_data(request)
|
||||
|
||||
expected = {
|
||||
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
|
||||
'username': self.user.username,
|
||||
'header_urls': self._get_expected_header_urls(request),
|
||||
'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(self.user)
|
||||
}
|
||||
|
||||
self.assertDictEqual(actual, expected)
|
||||
32
openedx/core/djangoapps/user_authn/urls.py
Normal file
32
openedx/core/djangoapps/user_authn/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
""" URLs for User Authentication """
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts import settings_views
|
||||
from .views import login_form, login, deprecated
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# TODO this should really be declared in the user_api app
|
||||
url(r'^account/settings$', settings_views.account_settings, name='account_settings'),
|
||||
|
||||
# TODO move contents of urls_common here once CMS no longer has its own login
|
||||
url(r'', include('openedx.core.djangoapps.user_authn.urls_common')),
|
||||
url(r'^account/finish_auth$', login.finish_auth, name='finish_auth'),
|
||||
]
|
||||
|
||||
|
||||
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
|
||||
# Backwards compatibility with old URL structure, but serve the new views
|
||||
urlpatterns += [
|
||||
url(r'^login$', login_form.login_and_registration_form,
|
||||
{'initial_mode': 'login'}, name='signin_user'),
|
||||
url(r'^register$', login_form.login_and_registration_form,
|
||||
{'initial_mode': 'register'}, name='register_user'),
|
||||
]
|
||||
else:
|
||||
# Serve the old views
|
||||
urlpatterns += [
|
||||
url(r'^login$', deprecated.signin_user, name='signin_user'),
|
||||
url(r'^register$', deprecated.register_user, name='register_user'),
|
||||
]
|
||||
29
openedx/core/djangoapps/user_authn/urls_common.py
Normal file
29
openedx/core/djangoapps/user_authn/urls_common.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Common URLs for User Authentication
|
||||
|
||||
Note: The split between urls.py and urls_common.py is hopefully temporary.
|
||||
For now, this is needed because of difference in CMS and LMS that have
|
||||
not yet been cleaned up.
|
||||
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import auto_auth, login, logout, deprecated
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^create_account$', deprecated.create_account, name='create_account'),
|
||||
url(r'^login_post$', login.login_user, name='login_post'),
|
||||
url(r'^login_ajax$', login.login_user, name="login"),
|
||||
url(r'^login_ajax/(?P<error>[^/]*)$', login.login_user),
|
||||
|
||||
url(r'^logout$', logout.LogoutView.as_view(), name='logout'),
|
||||
]
|
||||
|
||||
|
||||
# enable automatic login
|
||||
if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
|
||||
urlpatterns += [
|
||||
url(r'^auto_auth$', auto_auth.auto_auth),
|
||||
]
|
||||
202
openedx/core/djangoapps/user_authn/views/auto_auth.py
Normal file
202
openedx/core/djangoapps/user_authn/views/auto_auth.py
Normal file
@@ -0,0 +1,202 @@
|
||||
""" Views related to auto auth. """
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login as django_login
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.core.validators import ValidationError
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import redirect
|
||||
from django.template.context_processors import csrf
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_comment_common.models import assign_role
|
||||
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import generate_password
|
||||
from openedx.features.course_experience import course_home_url_name
|
||||
from student.forms import AccountCreationForm
|
||||
from student.helpers import (
|
||||
AccountValidationError,
|
||||
create_or_set_user_attribute_created_on_site,
|
||||
)
|
||||
from student.models import (
|
||||
CourseAccessRole,
|
||||
CourseEnrollment,
|
||||
Registration,
|
||||
UserProfile,
|
||||
anonymous_id_for_user,
|
||||
create_comments_service_user
|
||||
)
|
||||
from student.helpers import authenticate_new_user, do_create_account
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
|
||||
def auto_auth(request): # pylint: disable=too-many-statements
|
||||
"""
|
||||
Create or configure a user account, then log in as that user.
|
||||
|
||||
Enabled only when
|
||||
settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
|
||||
|
||||
Accepts the following querystring parameters:
|
||||
* `username`, `email`, and `password` for the user account
|
||||
* `full_name` for the user profile (the user's full name; defaults to the username)
|
||||
* `staff`: Set to "true" to make the user global staff.
|
||||
* `course_id`: Enroll the student in the course with `course_id`
|
||||
* `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
|
||||
* `no_login`: Define this to create the user but not login
|
||||
* `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or
|
||||
course home page if course_id is defined, otherwise it will redirect to dashboard
|
||||
* `redirect_to`: will redirect to to this url
|
||||
* `is_active` : make/update account with status provided as 'is_active'
|
||||
If username, email, or password are not provided, use
|
||||
randomly generated credentials.
|
||||
"""
|
||||
|
||||
# Generate a unique name to use if none provided
|
||||
generated_username = uuid.uuid4().hex[0:30]
|
||||
generated_password = generate_password()
|
||||
|
||||
# Use the params from the request, otherwise use these defaults
|
||||
username = request.GET.get('username', generated_username)
|
||||
password = request.GET.get('password', generated_password)
|
||||
email = request.GET.get('email', username + "@example.com")
|
||||
full_name = request.GET.get('full_name', username)
|
||||
is_staff = _str2bool(request.GET.get('staff', False))
|
||||
is_superuser = _str2bool(request.GET.get('superuser', False))
|
||||
course_id = request.GET.get('course_id')
|
||||
redirect_to = request.GET.get('redirect_to')
|
||||
is_active = _str2bool(request.GET.get('is_active', True))
|
||||
|
||||
# Valid modes: audit, credit, honor, no-id-professional, professional, verified
|
||||
enrollment_mode = request.GET.get('enrollment_mode', 'honor')
|
||||
|
||||
# Parse roles, stripping whitespace, and filtering out empty strings
|
||||
roles = _clean_roles(request.GET.get('roles', '').split(','))
|
||||
course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(','))
|
||||
|
||||
redirect_when_done = _str2bool(request.GET.get('redirect', '')) or redirect_to
|
||||
login_when_done = 'no_login' not in request.GET
|
||||
|
||||
restricted = settings.FEATURES.get('RESTRICT_AUTOMATIC_AUTH', True)
|
||||
if is_superuser and restricted:
|
||||
return HttpResponseForbidden(_('Superuser creation not allowed'))
|
||||
|
||||
form = AccountCreationForm(
|
||||
data={
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'name': full_name,
|
||||
},
|
||||
tos_required=False
|
||||
)
|
||||
|
||||
# Attempt to create the account.
|
||||
# If successful, this will return a tuple containing
|
||||
# the new user object.
|
||||
try:
|
||||
user, profile, reg = do_create_account(form)
|
||||
except (AccountValidationError, ValidationError):
|
||||
if restricted:
|
||||
return HttpResponseForbidden(_('Account modification not allowed.'))
|
||||
# Attempt to retrieve the existing user.
|
||||
user = User.objects.get(username=username)
|
||||
user.email = email
|
||||
user.set_password(password)
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
profile = UserProfile.objects.get(user=user)
|
||||
reg = Registration.objects.get(user=user)
|
||||
except PermissionDenied:
|
||||
return HttpResponseForbidden(_('Account creation not allowed.'))
|
||||
|
||||
user.is_staff = is_staff
|
||||
user.is_superuser = is_superuser
|
||||
user.save()
|
||||
|
||||
if is_active:
|
||||
reg.activate()
|
||||
reg.save()
|
||||
|
||||
# ensure parental consent threshold is met
|
||||
year = datetime.date.today().year
|
||||
age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT
|
||||
profile.year_of_birth = (year - age_limit) - 1
|
||||
profile.save()
|
||||
|
||||
create_or_set_user_attribute_created_on_site(user, request.site)
|
||||
|
||||
# Enroll the user in a course
|
||||
course_key = None
|
||||
if course_id:
|
||||
course_key = CourseLocator.from_string(course_id)
|
||||
CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
|
||||
|
||||
# Apply the roles
|
||||
for role in roles:
|
||||
assign_role(course_key, user, role)
|
||||
|
||||
for role in course_access_roles:
|
||||
CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role)
|
||||
|
||||
# Log in as the user
|
||||
if login_when_done:
|
||||
user = authenticate_new_user(request, username, password)
|
||||
django_login(request, user)
|
||||
|
||||
create_comments_service_user(user)
|
||||
|
||||
if redirect_when_done:
|
||||
if redirect_to:
|
||||
# Redirect to page specified by the client
|
||||
redirect_url = redirect_to
|
||||
elif course_id:
|
||||
# Redirect to the course homepage (in LMS) or outline page (in Studio)
|
||||
try:
|
||||
redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
|
||||
except NoReverseMatch:
|
||||
redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
|
||||
else:
|
||||
# Redirect to the learner dashboard (in LMS) or homepage (in Studio)
|
||||
try:
|
||||
redirect_url = reverse('dashboard')
|
||||
except NoReverseMatch:
|
||||
redirect_url = reverse('home')
|
||||
|
||||
return redirect(redirect_url)
|
||||
else:
|
||||
response = JsonResponse({
|
||||
'created_status': 'Logged in' if login_when_done else 'Created',
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'user_id': user.id,
|
||||
'anonymous_id': anonymous_id_for_user(user, None),
|
||||
})
|
||||
response.set_cookie('csrftoken', csrf(request)['csrf_token'])
|
||||
return response
|
||||
|
||||
|
||||
def _clean_roles(roles):
|
||||
""" Clean roles.
|
||||
|
||||
Strips whitespace from roles, and removes empty items.
|
||||
|
||||
Args:
|
||||
roles (str[]): List of role names.
|
||||
|
||||
Returns:
|
||||
str[]
|
||||
"""
|
||||
roles = [role.strip() for role in roles]
|
||||
roles = [role for role in roles if role]
|
||||
return roles
|
||||
|
||||
|
||||
def _str2bool(s):
|
||||
s = str(s)
|
||||
return s.lower() in ('yes', 'true', 't', '1')
|
||||
162
openedx/core/djangoapps/user_authn/views/deprecated.py
Normal file
162
openedx/core/djangoapps/user_authn/views/deprecated.py
Normal file
@@ -0,0 +1,162 @@
|
||||
""" User Authn code for deprecated views. """
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
from six import text_type, iteritems
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
|
||||
from openedx.core.djangoapps.user_authn.views.register import create_account_with_params
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
|
||||
from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login
|
||||
from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
|
||||
from student.helpers import (
|
||||
auth_pipeline_urls,
|
||||
get_next_url_for_login_page
|
||||
)
|
||||
from student.helpers import AccountValidationError
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline, provider
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signin_user(request):
|
||||
"""Deprecated. To be replaced by :class:`user_authn.views.login_form.login_and_registration_form`."""
|
||||
external_auth_response = external_auth_login(request)
|
||||
if external_auth_response is not None:
|
||||
return external_auth_response
|
||||
# Determine the URL to redirect to following login:
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
if request.user.is_authenticated:
|
||||
return redirect(redirect_to)
|
||||
|
||||
third_party_auth_error = None
|
||||
for msg in messages.get_messages(request):
|
||||
if msg.extra_tags.split()[0] == "social-auth":
|
||||
# msg may or may not be translated. Try translating [again] in case we are able to:
|
||||
third_party_auth_error = _(text_type(msg))
|
||||
break
|
||||
|
||||
context = {
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
|
||||
# Bool injected into JS to submit form if we're inside a running third-
|
||||
# party auth pipeline; distinct from the actual instance of the running
|
||||
# pipeline, if any.
|
||||
'pipeline_running': 'true' if pipeline.running(request) else 'false',
|
||||
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
|
||||
'platform_name': configuration_helpers.get_value(
|
||||
'platform_name',
|
||||
settings.PLATFORM_NAME
|
||||
),
|
||||
'third_party_auth_error': third_party_auth_error
|
||||
}
|
||||
|
||||
return render_to_response('login.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def register_user(request, extra_context=None):
|
||||
"""
|
||||
Deprecated. To be replaced by :class:`user_authn.views.login_form.login_and_registration_form`.
|
||||
"""
|
||||
# Determine the URL to redirect to following login:
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
if request.user.is_authenticated:
|
||||
return redirect(redirect_to)
|
||||
|
||||
external_auth_response = external_auth_register(request)
|
||||
if external_auth_response is not None:
|
||||
return external_auth_response
|
||||
|
||||
context = {
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
|
||||
'email': '',
|
||||
'name': '',
|
||||
'running_pipeline': None,
|
||||
'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
|
||||
'platform_name': configuration_helpers.get_value(
|
||||
'platform_name',
|
||||
settings.PLATFORM_NAME
|
||||
),
|
||||
'selected_provider': '',
|
||||
'username': '',
|
||||
}
|
||||
|
||||
if extra_context is not None:
|
||||
context.update(extra_context)
|
||||
|
||||
if context.get("extauth_domain", '').startswith(settings.SHIBBOLETH_DOMAIN_PREFIX):
|
||||
return render_to_response('register-shib.html', context)
|
||||
|
||||
# If third-party auth is enabled, prepopulate the form with data from the
|
||||
# selected provider.
|
||||
if third_party_auth.is_enabled() and pipeline.running(request):
|
||||
running_pipeline = pipeline.get(request)
|
||||
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
|
||||
if current_provider is not None:
|
||||
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
|
||||
overrides['running_pipeline'] = running_pipeline
|
||||
overrides['selected_provider'] = current_provider.name
|
||||
context.update(overrides)
|
||||
|
||||
return render_to_response('register.html', context)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@transaction.non_atomic_requests
|
||||
def create_account(request, post_override=None):
|
||||
"""
|
||||
Deprecated. Use RegistrationView instead.
|
||||
JSON call to create new edX account.
|
||||
Used by form in signup_modal.html, which is included into header.html
|
||||
"""
|
||||
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
|
||||
if not configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION',
|
||||
settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
|
||||
):
|
||||
return HttpResponseForbidden(_("Account creation not allowed."))
|
||||
|
||||
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
|
||||
return HttpResponseForbidden(SYSTEM_MAINTENANCE_MSG)
|
||||
|
||||
warnings.warn("Please use RegistrationView instead.", DeprecationWarning)
|
||||
|
||||
try:
|
||||
user = create_account_with_params(request, post_override or request.POST)
|
||||
except AccountValidationError as exc:
|
||||
return JsonResponse({'success': False, 'value': text_type(exc), 'field': exc.field}, status=400)
|
||||
except ValidationError as exc:
|
||||
field, error_list = next(iteritems(exc.message_dict))
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": False,
|
||||
"field": field,
|
||||
"value": error_list[0],
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
||||
redirect_url = None # The AJAX method calling should know the default destination upon success
|
||||
|
||||
# Resume the third-party-auth pipeline if necessary.
|
||||
if third_party_auth.is_enabled() and pipeline.running(request):
|
||||
running_pipeline = pipeline.get(request)
|
||||
redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
|
||||
|
||||
response = JsonResponse({
|
||||
'success': True,
|
||||
'redirect_url': redirect_url,
|
||||
})
|
||||
set_logged_in_cookies(request, response, user)
|
||||
return response
|
||||
395
openedx/core/djangoapps/user_authn/views/login.py
Normal file
395
openedx/core/djangoapps/user_authn/views/login.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Views for login / logout and associated functionality
|
||||
|
||||
Much of this file was broken out from views.py, previous history can be found there.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import analytics
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, login as django_login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from eventtracking import tracker
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
|
||||
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
|
||||
import openedx.core.djangoapps.external_auth.views
|
||||
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
|
||||
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from student.models import (
|
||||
LoginFailures,
|
||||
PasswordHistory,
|
||||
)
|
||||
from student.views import send_reactivation_email_for_user
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline, provider
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
log = logging.getLogger("edx.student")
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
|
||||
|
||||
def _do_third_party_auth(request):
|
||||
"""
|
||||
User is already authenticated via 3rd party, now try to find and return their associated Django user.
|
||||
"""
|
||||
running_pipeline = pipeline.get(request)
|
||||
username = running_pipeline['kwargs'].get('username')
|
||||
backend_name = running_pipeline['backend']
|
||||
third_party_uid = running_pipeline['kwargs']['uid']
|
||||
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
|
||||
platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
|
||||
|
||||
try:
|
||||
return pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
|
||||
except User.DoesNotExist:
|
||||
AUDIT_LOG.info(
|
||||
u"Login failed - user with username {username} has no social auth "
|
||||
"with backend_name {backend_name}".format(
|
||||
username=username, backend_name=backend_name)
|
||||
)
|
||||
message = _(
|
||||
"You've successfully logged into your {provider_name} account, "
|
||||
"but this account isn't linked with an {platform_name} account yet."
|
||||
).format(
|
||||
platform_name=platform_name,
|
||||
provider_name=requested_provider.name,
|
||||
)
|
||||
message += "<br/><br/>"
|
||||
message += _(
|
||||
"Use your {platform_name} username and password to log into {platform_name} below, "
|
||||
"and then link your {platform_name} account with {provider_name} from your dashboard."
|
||||
).format(
|
||||
platform_name=platform_name,
|
||||
provider_name=requested_provider.name,
|
||||
)
|
||||
message += "<br/><br/>"
|
||||
message += _(
|
||||
"If you don't have an {platform_name} account yet, "
|
||||
"click <strong>Register</strong> at the top of the page."
|
||||
).format(
|
||||
platform_name=platform_name
|
||||
)
|
||||
|
||||
raise AuthFailedError(message)
|
||||
|
||||
|
||||
def _get_user_by_email(request):
|
||||
"""
|
||||
Finds a user object in the database based on the given request, ignores all fields except for email.
|
||||
"""
|
||||
if 'email' not in request.POST or 'password' not in request.POST:
|
||||
raise AuthFailedError(_('There was an error receiving your login information. Please email us.'))
|
||||
|
||||
email = request.POST['email']
|
||||
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.warning(u"Login failed - Unknown user email")
|
||||
else:
|
||||
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
|
||||
|
||||
|
||||
def _check_shib_redirect(user):
|
||||
"""
|
||||
See if the user has a linked shibboleth account, if so, redirect the user to shib-login.
|
||||
This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu
|
||||
address into the Gmail login.
|
||||
"""
|
||||
if settings.FEATURES.get('AUTH_USE_SHIB') and user:
|
||||
try:
|
||||
eamap = ExternalAuthMap.objects.get(user=user)
|
||||
if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
|
||||
raise AuthFailedError('', redirect=reverse('shib-login'))
|
||||
except ExternalAuthMap.DoesNotExist:
|
||||
# This is actually the common case, logging in user without external linked login
|
||||
AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
|
||||
|
||||
|
||||
def _check_excessive_login_attempts(user):
|
||||
"""
|
||||
See if account has been locked out due to excessive login failures
|
||||
"""
|
||||
if user and LoginFailures.is_feature_enabled():
|
||||
if LoginFailures.is_user_locked_out(user):
|
||||
raise AuthFailedError(_('This account has been temporarily locked due '
|
||||
'to excessive login failures. Try again later.'))
|
||||
|
||||
|
||||
def _check_forced_password_reset(user):
|
||||
"""
|
||||
See if the user must reset his/her password due to any policy settings
|
||||
"""
|
||||
if user and PasswordHistory.should_user_reset_password_now(user):
|
||||
raise AuthFailedError(_('Your password has expired due to password policy on this account. You must '
|
||||
'reset your password before you can log in again. Please click the '
|
||||
'"Forgot Password" link on this page to reset your password before logging in again.'))
|
||||
|
||||
|
||||
def _enforce_password_policy_compliance(request, user):
|
||||
try:
|
||||
password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password'))
|
||||
except password_policy_compliance.NonCompliantPasswordWarning as e:
|
||||
# Allow login, but warn the user that they will be required to reset their password soon.
|
||||
PageLevelMessages.register_warning_message(request, e.message)
|
||||
except password_policy_compliance.NonCompliantPasswordException as e:
|
||||
# Prevent the login attempt.
|
||||
raise AuthFailedError(e.message)
|
||||
|
||||
|
||||
def _generate_not_activated_message(user):
|
||||
"""
|
||||
Generates the message displayed on the sign-in screen when a learner attempts to access the
|
||||
system with an inactive account.
|
||||
"""
|
||||
|
||||
support_url = configuration_helpers.get_value(
|
||||
'SUPPORT_SITE_LINK',
|
||||
settings.SUPPORT_SITE_LINK
|
||||
)
|
||||
|
||||
platform_name = configuration_helpers.get_value(
|
||||
'PLATFORM_NAME',
|
||||
settings.PLATFORM_NAME
|
||||
)
|
||||
|
||||
not_activated_msg_template = _('In order to sign in, you need to activate your account.<br /><br />'
|
||||
'We just sent an activation link to <strong>{email}</strong>. If '
|
||||
'you do not receive an email, check your spam folders or '
|
||||
'<a href="{support_url}">contact {platform} Support</a>.')
|
||||
|
||||
not_activated_message = not_activated_msg_template.format(
|
||||
email=user.email,
|
||||
support_url=support_url,
|
||||
platform=platform_name
|
||||
)
|
||||
|
||||
return not_activated_message
|
||||
|
||||
|
||||
def _log_and_raise_inactive_user_auth_error(unauthenticated_user):
|
||||
"""
|
||||
Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt
|
||||
by an inactive user, re-sending the activation email, and raising an error with the correct message.
|
||||
"""
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.warning(
|
||||
u"Login failed - Account not active for user.id: {0}, resending activation".format(
|
||||
unauthenticated_user.id)
|
||||
)
|
||||
else:
|
||||
AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(
|
||||
unauthenticated_user.username)
|
||||
)
|
||||
|
||||
send_reactivation_email_for_user(unauthenticated_user)
|
||||
raise AuthFailedError(_generate_not_activated_message(unauthenticated_user))
|
||||
|
||||
|
||||
def _authenticate_first_party(request, unauthenticated_user):
|
||||
"""
|
||||
Use Django authentication on the given request, using rate limiting if configured
|
||||
"""
|
||||
|
||||
# If the user doesn't exist, we want to set the username to an invalid username so that authentication is guaranteed
|
||||
# to fail and we can take advantage of the ratelimited backend
|
||||
username = unauthenticated_user.username if unauthenticated_user else ""
|
||||
|
||||
try:
|
||||
return authenticate(
|
||||
username=username,
|
||||
password=request.POST['password'],
|
||||
request=request)
|
||||
|
||||
# This occurs when there are too many attempts from the same IP address
|
||||
except RateLimitException:
|
||||
raise AuthFailedError(_('Too many failed login attempts. Try again later.'))
|
||||
|
||||
|
||||
def _handle_failed_authentication(user):
|
||||
"""
|
||||
Handles updating the failed login count, inactive user notifications, and logging failed authentications.
|
||||
"""
|
||||
if user:
|
||||
if LoginFailures.is_feature_enabled():
|
||||
LoginFailures.increment_lockout_counter(user)
|
||||
|
||||
if not user.is_active:
|
||||
_log_and_raise_inactive_user_auth_error(user)
|
||||
|
||||
# if we didn't find this username earlier, the account for this email
|
||||
# doesn't exist, and doesn't have a corresponding password
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
loggable_id = user.id if user else "<unknown>"
|
||||
AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id))
|
||||
else:
|
||||
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(user.email))
|
||||
|
||||
raise AuthFailedError(_('Email or password is incorrect.'))
|
||||
|
||||
|
||||
def _handle_successful_authentication_and_login(user, request):
|
||||
"""
|
||||
Handles clearing the failed login counter, login tracking, and setting session timeout.
|
||||
"""
|
||||
if LoginFailures.is_feature_enabled():
|
||||
LoginFailures.clear_lockout_counter(user)
|
||||
|
||||
_track_user_login(user, request)
|
||||
|
||||
try:
|
||||
django_login(request, user)
|
||||
if request.POST.get('remember') == 'true':
|
||||
request.session.set_expiry(604800)
|
||||
log.debug("Setting user session to never expire")
|
||||
else:
|
||||
request.session.set_expiry(0)
|
||||
except Exception as exc:
|
||||
AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
|
||||
log.critical("Login failed - Could not create session. Is memcached running?")
|
||||
log.exception(exc)
|
||||
raise
|
||||
|
||||
|
||||
def _track_user_login(user, request):
|
||||
"""
|
||||
Sends a tracking event for a successful login.
|
||||
"""
|
||||
if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
|
||||
tracking_context = tracker.get_tracker().resolve_context()
|
||||
analytics.identify(
|
||||
user.id,
|
||||
{
|
||||
'email': request.POST['email'],
|
||||
'username': user.username
|
||||
},
|
||||
{
|
||||
# Disable MailChimp because we don't want to update the user's email
|
||||
# and username in MailChimp on every page load. We only need to capture
|
||||
# this data on registration/activation.
|
||||
'MailChimp': False
|
||||
}
|
||||
)
|
||||
|
||||
analytics.track(
|
||||
user.id,
|
||||
"edx.bi.user.account.authenticated",
|
||||
{
|
||||
'category': "conversion",
|
||||
'label': request.POST.get('course_id'),
|
||||
'provider': None
|
||||
},
|
||||
context={
|
||||
'ip': tracking_context.get('ip'),
|
||||
'Google Analytics': {
|
||||
'clientId': tracking_context.get('client_id')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def finish_auth(request): # pylint: disable=unused-argument
|
||||
""" Following logistration (1st or 3rd party), handle any special query string params.
|
||||
|
||||
See FinishAuthView.js for details on the query string params.
|
||||
|
||||
e.g. auto-enroll the user in a course, set email opt-in preference.
|
||||
|
||||
This view just displays a "Please wait" message while AJAX calls are made to enroll the
|
||||
user in the course etc. This view is only used if a parameter like "course_id" is present
|
||||
during login/registration/third_party_auth. Otherwise, there is no need for it.
|
||||
|
||||
Ideally this view will finish and redirect to the next step before the user even sees it.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the page was sent successfully
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
GET /account/finish_auth/?course_id=course-v1:blah&enrollment_action=enroll
|
||||
|
||||
"""
|
||||
return render_to_response('student_account/finish_auth.html', {
|
||||
'disable_courseware_js': True,
|
||||
'disable_footer': True,
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def login_user(request):
|
||||
"""
|
||||
AJAX request to log in the user.
|
||||
"""
|
||||
third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
|
||||
trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
|
||||
was_authenticated_third_party = False
|
||||
|
||||
try:
|
||||
if third_party_auth_requested and not trumped_by_first_party_auth:
|
||||
# The user has already authenticated via third-party auth and has not
|
||||
# asked to do first party auth by supplying a username or password. We
|
||||
# now want to put them through the same logging and cookie calculation
|
||||
# logic as with first-party auth.
|
||||
|
||||
# This nested try is due to us only returning an HttpResponse in this
|
||||
# one case vs. JsonResponse everywhere else.
|
||||
try:
|
||||
email_user = _do_third_party_auth(request)
|
||||
was_authenticated_third_party = True
|
||||
except AuthFailedError as e:
|
||||
return HttpResponse(e.value, content_type="text/plain", status=403)
|
||||
else:
|
||||
email_user = _get_user_by_email(request)
|
||||
|
||||
_check_shib_redirect(email_user)
|
||||
_check_excessive_login_attempts(email_user)
|
||||
_check_forced_password_reset(email_user)
|
||||
|
||||
possibly_authenticated_user = email_user
|
||||
|
||||
if not was_authenticated_third_party:
|
||||
possibly_authenticated_user = _authenticate_first_party(request, email_user)
|
||||
if possibly_authenticated_user and password_policy_compliance.should_enforce_compliance_on_login():
|
||||
# Important: This call must be made AFTER the user was successfully authenticated.
|
||||
_enforce_password_policy_compliance(request, possibly_authenticated_user)
|
||||
|
||||
if possibly_authenticated_user is None or not possibly_authenticated_user.is_active:
|
||||
_handle_failed_authentication(email_user)
|
||||
|
||||
_handle_successful_authentication_and_login(possibly_authenticated_user, request)
|
||||
|
||||
redirect_url = None # The AJAX method calling should know the default destination upon success
|
||||
if was_authenticated_third_party:
|
||||
running_pipeline = pipeline.get(request)
|
||||
redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend'])
|
||||
|
||||
response = JsonResponse({
|
||||
'success': True,
|
||||
'redirect_url': redirect_url,
|
||||
})
|
||||
|
||||
# Ensure that the external marketing site can
|
||||
# detect that the user is logged in.
|
||||
return set_logged_in_cookies(request, response, possibly_authenticated_user)
|
||||
except AuthFailedError as error:
|
||||
return JsonResponse(error.get_response())
|
||||
259
openedx/core/djangoapps/user_authn/views/login_form.py
Normal file
259
openedx/core/djangoapps/user_authn/views/login_form.py
Normal file
@@ -0,0 +1,259 @@
|
||||
""" Login related views """
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import urlparse
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from openedx.core.djangoapps.user_authn.views.deprecated import (
|
||||
register_user as old_register_view, signin_user as old_login_view
|
||||
)
|
||||
from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login
|
||||
from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
|
||||
from openedx.core.djangoapps.user_api.api import (
|
||||
RegistrationFormFactory,
|
||||
get_login_session_form,
|
||||
get_password_reset_form
|
||||
)
|
||||
from openedx.features.enterprise_support.api import enterprise_customer_for_request
|
||||
from openedx.features.enterprise_support.utils import (
|
||||
handle_enterprise_cookies_for_logistration,
|
||||
update_logistration_context_for_enterprise,
|
||||
)
|
||||
from student.helpers import get_next_url_for_login_page
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from third_party_auth.decorators import xframe_allow_whitelisted
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
@ensure_csrf_cookie
|
||||
@xframe_allow_whitelisted
|
||||
def login_and_registration_form(request, initial_mode="login"):
|
||||
"""Render the combined login/registration form, defaulting to login
|
||||
|
||||
This relies on the JS to asynchronously load the actual form from
|
||||
the user_api.
|
||||
|
||||
Keyword Args:
|
||||
initial_mode (string): Either "login" or "register".
|
||||
|
||||
"""
|
||||
# Determine the URL to redirect to following login/registration/third_party_auth
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
# If we're already logged in, redirect to the dashboard
|
||||
if request.user.is_authenticated:
|
||||
return redirect(redirect_to)
|
||||
|
||||
# Retrieve the form descriptions from the user API
|
||||
form_descriptions = _get_form_descriptions(request)
|
||||
|
||||
# Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check.
|
||||
# If present, we display a login page focused on third-party auth with that provider.
|
||||
third_party_auth_hint = None
|
||||
if '?' in redirect_to:
|
||||
try:
|
||||
next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query)
|
||||
provider_id = next_args['tpa_hint'][0]
|
||||
tpa_hint_provider = third_party_auth.provider.Registry.get(provider_id=provider_id)
|
||||
if tpa_hint_provider:
|
||||
if tpa_hint_provider.skip_hinted_login_dialog:
|
||||
# Forward the user directly to the provider's login URL when the provider is configured
|
||||
# to skip the dialog.
|
||||
if initial_mode == "register":
|
||||
auth_entry = pipeline.AUTH_ENTRY_REGISTER
|
||||
else:
|
||||
auth_entry = pipeline.AUTH_ENTRY_LOGIN
|
||||
return redirect(
|
||||
pipeline.get_login_url(provider_id, auth_entry, redirect_url=redirect_to)
|
||||
)
|
||||
third_party_auth_hint = provider_id
|
||||
initial_mode = "hinted_login"
|
||||
except (KeyError, ValueError, IndexError) as ex:
|
||||
log.exception("Unknown tpa_hint provider: %s", ex)
|
||||
|
||||
# If this is a themed site, revert to the old login/registration pages.
|
||||
# We need to do this for now to support existing themes.
|
||||
# Themed sites can use the new logistration page by setting
|
||||
# 'ENABLE_COMBINED_LOGIN_REGISTRATION' in their
|
||||
# configuration settings.
|
||||
if is_request_in_themed_site() and not configuration_helpers.get_value('ENABLE_COMBINED_LOGIN_REGISTRATION', False):
|
||||
if initial_mode == "login":
|
||||
return old_login_view(request)
|
||||
elif initial_mode == "register":
|
||||
return old_register_view(request)
|
||||
|
||||
# Allow external auth to intercept and handle the request
|
||||
ext_auth_response = _external_auth_intercept(request, initial_mode)
|
||||
if ext_auth_response is not None:
|
||||
return ext_auth_response
|
||||
|
||||
# Account activation message
|
||||
account_activation_messages = [
|
||||
{
|
||||
'message': message.message, 'tags': message.tags
|
||||
} for message in messages.get_messages(request) if 'account-activation' in message.tags
|
||||
]
|
||||
|
||||
# Otherwise, render the combined login/registration page
|
||||
context = {
|
||||
'data': {
|
||||
'login_redirect_url': redirect_to,
|
||||
'initial_mode': initial_mode,
|
||||
'third_party_auth': _third_party_auth_context(request, redirect_to, third_party_auth_hint),
|
||||
'third_party_auth_hint': third_party_auth_hint or '',
|
||||
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
|
||||
'support_link': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
|
||||
'password_reset_support_link': configuration_helpers.get_value(
|
||||
'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK
|
||||
) or settings.SUPPORT_SITE_LINK,
|
||||
'account_activation_messages': account_activation_messages,
|
||||
|
||||
# Include form descriptions retrieved from the user API.
|
||||
# We could have the JS client make these requests directly,
|
||||
# but we include them in the initial page load to avoid
|
||||
# the additional round-trip to the server.
|
||||
'login_form_desc': json.loads(form_descriptions['login']),
|
||||
'registration_form_desc': json.loads(form_descriptions['registration']),
|
||||
'password_reset_form_desc': json.loads(form_descriptions['password_reset']),
|
||||
'account_creation_allowed': configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True))
|
||||
},
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header
|
||||
'responsive': True,
|
||||
'allow_iframing': True,
|
||||
'disable_courseware_js': True,
|
||||
'combined_login_and_register': True,
|
||||
'disable_footer': not configuration_helpers.get_value(
|
||||
'ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER',
|
||||
settings.FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER']
|
||||
),
|
||||
}
|
||||
|
||||
enterprise_customer = enterprise_customer_for_request(request)
|
||||
update_logistration_context_for_enterprise(request, context, enterprise_customer)
|
||||
|
||||
response = render_to_response('student_account/login_and_register.html', context)
|
||||
handle_enterprise_cookies_for_logistration(request, response, context)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _get_form_descriptions(request):
|
||||
"""Retrieve form descriptions from the user API.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest): The original request, used to retrieve session info.
|
||||
|
||||
Returns:
|
||||
dict: Keys are 'login', 'registration', and 'password_reset';
|
||||
values are the JSON-serialized form descriptions.
|
||||
|
||||
"""
|
||||
|
||||
return {
|
||||
'password_reset': get_password_reset_form().to_json(),
|
||||
'login': get_login_session_form(request).to_json(),
|
||||
'registration': RegistrationFormFactory().get_registration_form(request).to_json()
|
||||
}
|
||||
|
||||
|
||||
def _third_party_auth_context(request, redirect_to, tpa_hint=None):
|
||||
"""Context for third party auth providers and the currently running pipeline.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest): The request, used to determine if a pipeline
|
||||
is currently running.
|
||||
redirect_to: The URL to send the user to following successful
|
||||
authentication.
|
||||
tpa_hint (string): An override flag that will return a matching provider
|
||||
as long as its configuration has been enabled
|
||||
|
||||
Returns:
|
||||
dict
|
||||
|
||||
"""
|
||||
context = {
|
||||
"currentProvider": None,
|
||||
"providers": [],
|
||||
"secondaryProviders": [],
|
||||
"finishAuthUrl": None,
|
||||
"errorMessage": None,
|
||||
"registerFormSubmitButtonText": _("Create Account"),
|
||||
"syncLearnerProfileData": False,
|
||||
"pipeline_user_details": {}
|
||||
}
|
||||
|
||||
if third_party_auth.is_enabled():
|
||||
for enabled in third_party_auth.provider.Registry.displayed_for_login(tpa_hint=tpa_hint):
|
||||
info = {
|
||||
"id": enabled.provider_id,
|
||||
"name": enabled.name,
|
||||
"iconClass": enabled.icon_class or None,
|
||||
"iconImage": enabled.icon_image.url if enabled.icon_image else None,
|
||||
"loginUrl": pipeline.get_login_url(
|
||||
enabled.provider_id,
|
||||
pipeline.AUTH_ENTRY_LOGIN,
|
||||
redirect_url=redirect_to,
|
||||
),
|
||||
"registerUrl": pipeline.get_login_url(
|
||||
enabled.provider_id,
|
||||
pipeline.AUTH_ENTRY_REGISTER,
|
||||
redirect_url=redirect_to,
|
||||
),
|
||||
}
|
||||
context["providers" if not enabled.secondary else "secondaryProviders"].append(info)
|
||||
|
||||
running_pipeline = pipeline.get(request)
|
||||
if running_pipeline is not None:
|
||||
current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline)
|
||||
user_details = running_pipeline['kwargs']['details']
|
||||
if user_details:
|
||||
context['pipeline_user_details'] = user_details
|
||||
|
||||
if current_provider is not None:
|
||||
context["currentProvider"] = current_provider.name
|
||||
context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name)
|
||||
context["syncLearnerProfileData"] = current_provider.sync_learner_profile_data
|
||||
|
||||
if current_provider.skip_registration_form:
|
||||
# As a reliable way of "skipping" the registration form, we just submit it automatically
|
||||
context["autoSubmitRegForm"] = True
|
||||
|
||||
# Check for any error messages we may want to display:
|
||||
for msg in messages.get_messages(request):
|
||||
if msg.extra_tags.split()[0] == "social-auth":
|
||||
# msg may or may not be translated. Try translating [again] in case we are able to:
|
||||
context['errorMessage'] = _(unicode(msg))
|
||||
break
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _external_auth_intercept(request, mode):
|
||||
"""Allow external auth to intercept a login/registration request.
|
||||
|
||||
Arguments:
|
||||
request (Request): The original request.
|
||||
mode (str): Either "login" or "register"
|
||||
|
||||
Returns:
|
||||
Response or None
|
||||
|
||||
"""
|
||||
if mode == "login":
|
||||
return external_auth_login(request)
|
||||
elif mode == "register":
|
||||
return external_auth_register(request)
|
||||
101
openedx/core/djangoapps/user_authn/views/logout.py
Normal file
101
openedx/core/djangoapps/user_authn/views/logout.py
Normal file
@@ -0,0 +1,101 @@
|
||||
""" Views related to logout. """
|
||||
from urlparse import parse_qs, urlsplit, urlunsplit
|
||||
|
||||
import edx_oauth2_provider
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout
|
||||
from django.urls import reverse_lazy
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.http import is_safe_url, urlencode
|
||||
from django.views.generic import TemplateView
|
||||
from provider.oauth2.models import Client
|
||||
from openedx.core.djangoapps.user_authn.cookies import delete_logged_in_cookies
|
||||
|
||||
|
||||
class LogoutView(TemplateView):
|
||||
"""
|
||||
Logs out user and redirects.
|
||||
|
||||
The template should load iframes to log the user out of OpenID Connect services.
|
||||
See http://openid.net/specs/openid-connect-logout-1_0.html.
|
||||
"""
|
||||
oauth_client_ids = []
|
||||
template_name = 'logout.html'
|
||||
|
||||
# Keep track of the page to which the user should ultimately be redirected.
|
||||
default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/'
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
"""
|
||||
If a redirect_url is specified in the querystring for this request, and the value is a url
|
||||
with the same host, the view will redirect to this page after rendering the template.
|
||||
If it is not specified, we will use the default target url.
|
||||
"""
|
||||
target_url = self.request.GET.get('redirect_url')
|
||||
|
||||
if target_url and is_safe_url(
|
||||
target_url,
|
||||
allowed_hosts={self.request.META.get('HTTP_HOST')},
|
||||
require_https=True,
|
||||
):
|
||||
return target_url
|
||||
else:
|
||||
return self.default_target
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# We do not log here, because we have a handler registered to perform logging on successful logouts.
|
||||
request.is_from_logout = True
|
||||
|
||||
# Get the list of authorized clients before we clear the session.
|
||||
self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, [])
|
||||
|
||||
logout(request)
|
||||
|
||||
# If we don't need to deal with OIDC logouts, just redirect the user.
|
||||
if self.oauth_client_ids:
|
||||
response = super(LogoutView, self).dispatch(request, *args, **kwargs)
|
||||
else:
|
||||
response = redirect(self.target)
|
||||
|
||||
# Clear the cookie used by the edx.org marketing site
|
||||
delete_logged_in_cookies(response)
|
||||
|
||||
return response
|
||||
|
||||
def _build_logout_url(self, url):
|
||||
"""
|
||||
Builds a logout URL with the `no_redirect` query string parameter.
|
||||
|
||||
Args:
|
||||
url (str): IDA logout URL
|
||||
|
||||
Returns:
|
||||
str
|
||||
"""
|
||||
scheme, netloc, path, query_string, fragment = urlsplit(url)
|
||||
query_params = parse_qs(query_string)
|
||||
query_params['no_redirect'] = 1
|
||||
new_query_string = urlencode(query_params, doseq=True)
|
||||
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(LogoutView, self).get_context_data(**kwargs)
|
||||
|
||||
# Create a list of URIs that must be called to log the user out of all of the IDAs.
|
||||
uris = Client.objects.filter(client_id__in=self.oauth_client_ids,
|
||||
logout_uri__isnull=False).values_list('logout_uri', flat=True)
|
||||
|
||||
referrer = self.request.META.get('HTTP_REFERER', '').strip('/')
|
||||
logout_uris = []
|
||||
|
||||
for uri in uris:
|
||||
if not referrer or (referrer and not uri.startswith(referrer)):
|
||||
logout_uris.append(self._build_logout_url(uri))
|
||||
|
||||
context.update({
|
||||
'target': self.target,
|
||||
'logout_uris': logout_uris,
|
||||
})
|
||||
|
||||
return context
|
||||
467
openedx/core/djangoapps/user_authn/views/register.py
Normal file
467
openedx/core/djangoapps/user_authn/views/register.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
Registration related views.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
import analytics
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login as django_login
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.core.validators import ValidationError, validate_email
|
||||
from django.db import transaction
|
||||
from django.dispatch import Signal
|
||||
from django.utils.translation import get_language
|
||||
from django.utils.translation import ugettext as _
|
||||
from eventtracking import tracker
|
||||
# Note that this lives in LMS, so this dependency should be refactored.
|
||||
from notification_prefs.views import enable_notifications
|
||||
from pytz import UTC
|
||||
from requests import HTTPError
|
||||
from six import text_type
|
||||
from social_core.exceptions import AuthAlreadyAssociated, AuthException
|
||||
from social_django import utils as social_utils
|
||||
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.user_api import accounts as accounts_settings
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import generate_password
|
||||
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
|
||||
|
||||
from student.forms import AccountCreationForm, get_registration_extension_form
|
||||
from student.helpers import (
|
||||
authenticate_new_user,
|
||||
create_or_set_user_attribute_created_on_site,
|
||||
do_create_account,
|
||||
)
|
||||
from student.models import (
|
||||
RegistrationCookieConfiguration,
|
||||
UserAttribute,
|
||||
create_comments_service_user,
|
||||
)
|
||||
from student.views import compose_and_send_activation_email
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline, provider
|
||||
from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY
|
||||
from util.db import outer_atomic
|
||||
|
||||
|
||||
log = logging.getLogger("edx.student")
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
|
||||
|
||||
# Used as the name of the user attribute for tracking affiliate registrations
|
||||
REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
|
||||
REGISTRATION_UTM_PARAMETERS = {
|
||||
'utm_source': 'registration_utm_source',
|
||||
'utm_medium': 'registration_utm_medium',
|
||||
'utm_campaign': 'registration_utm_campaign',
|
||||
'utm_term': 'registration_utm_term',
|
||||
'utm_content': 'registration_utm_content',
|
||||
}
|
||||
REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
|
||||
# used to announce a registration
|
||||
REGISTER_USER = Signal(providing_args=["user", "registration"])
|
||||
|
||||
|
||||
@transaction.non_atomic_requests
|
||||
def create_account_with_params(request, params):
|
||||
"""
|
||||
Given a request and a dict of parameters (which may or may not have come
|
||||
from the request), create an account for the requesting user, including
|
||||
creating a comments service user object and sending an activation email.
|
||||
This also takes external/third-party auth into account, updates that as
|
||||
necessary, and authenticates the user for the request's session.
|
||||
|
||||
Does not return anything.
|
||||
|
||||
Raises AccountValidationError if an account with the username or email
|
||||
specified by params already exists, or ValidationError if any of the given
|
||||
parameters is invalid for any other reason.
|
||||
|
||||
Issues with this code:
|
||||
* It is non-transactional except where explicitly wrapped in atomic to
|
||||
alleviate deadlocks and improve performance. This means failures at
|
||||
different places in registration can leave users in inconsistent
|
||||
states.
|
||||
* Third-party auth passwords are not verified. There is a comment that
|
||||
they are unused, but it would be helpful to have a sanity check that
|
||||
they are sane.
|
||||
* The user-facing text is rather unfriendly (e.g. "Username must be a
|
||||
minimum of two characters long" rather than "Please use a username of
|
||||
at least two characters").
|
||||
* Duplicate email raises a ValidationError (rather than the expected
|
||||
AccountValidationError). Duplicate username returns an inconsistent
|
||||
user message (i.e. "An account with the Public Username '{username}'
|
||||
already exists." rather than "It looks like {username} belongs to an
|
||||
existing account. Try again with a different username.") The two checks
|
||||
occur at different places in the code; as a result, registering with
|
||||
both a duplicate username and email raises only a ValidationError for
|
||||
email only.
|
||||
"""
|
||||
# Copy params so we can modify it; we can't just do dict(params) because if
|
||||
# params is request.POST, that results in a dict containing lists of values
|
||||
params = dict(params.items())
|
||||
|
||||
# allow to define custom set of required/optional/hidden fields via configuration
|
||||
extra_fields = configuration_helpers.get_value(
|
||||
'REGISTRATION_EXTRA_FIELDS',
|
||||
getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
|
||||
)
|
||||
# registration via third party (Google, Facebook) using mobile application
|
||||
# doesn't use social auth pipeline (no redirect uri(s) etc involved).
|
||||
# In this case all related info (required for account linking)
|
||||
# is sent in params.
|
||||
# `third_party_auth_credentials_in_api` essentially means 'request
|
||||
# is made from mobile application'
|
||||
third_party_auth_credentials_in_api = 'provider' in params
|
||||
is_third_party_auth_enabled = third_party_auth.is_enabled()
|
||||
|
||||
if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
|
||||
params["password"] = generate_password()
|
||||
|
||||
# in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate
|
||||
# error message
|
||||
if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)):
|
||||
raise ValidationError(
|
||||
{'session_expired': [
|
||||
_(u"Registration using {provider} has timed out.").format(
|
||||
provider=params.get('social_auth_provider'))
|
||||
]}
|
||||
)
|
||||
|
||||
do_external_auth, eamap = pre_account_creation_external_auth(request, params)
|
||||
|
||||
extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', [])
|
||||
enforce_password_policy = not do_external_auth
|
||||
# Can't have terms of service for certain SHIB users, like at Stanford
|
||||
registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
|
||||
tos_required = (
|
||||
registration_fields.get('terms_of_service') != 'hidden' or
|
||||
registration_fields.get('honor_code') != 'hidden'
|
||||
) and (
|
||||
not settings.FEATURES.get("AUTH_USE_SHIB") or
|
||||
not settings.FEATURES.get("SHIB_DISABLE_TOS") or
|
||||
not do_external_auth or
|
||||
not eamap.external_domain.startswith(settings.SHIBBOLETH_DOMAIN_PREFIX)
|
||||
)
|
||||
|
||||
form = AccountCreationForm(
|
||||
data=params,
|
||||
extra_fields=extra_fields,
|
||||
extended_profile_fields=extended_profile_fields,
|
||||
enforce_password_policy=enforce_password_policy,
|
||||
tos_required=tos_required,
|
||||
)
|
||||
custom_form = get_registration_extension_form(data=params)
|
||||
|
||||
# Perform operations within a transaction that are critical to account creation
|
||||
with outer_atomic(read_committed=True):
|
||||
# first, create the account
|
||||
(user, profile, registration) = do_create_account(form, custom_form)
|
||||
|
||||
third_party_provider, running_pipeline = _link_user_to_third_party_provider(
|
||||
is_third_party_auth_enabled, third_party_auth_credentials_in_api, user, request, params,
|
||||
)
|
||||
|
||||
new_user = authenticate_new_user(request, user.username, params['password'])
|
||||
django_login(request, new_user)
|
||||
request.session.set_expiry(0)
|
||||
|
||||
post_account_creation_external_auth(do_external_auth, eamap, new_user)
|
||||
|
||||
# Check if system is configured to skip activation email for the current user.
|
||||
skip_email = _skip_activation_email(
|
||||
user, do_external_auth, running_pipeline, third_party_provider,
|
||||
)
|
||||
|
||||
if skip_email:
|
||||
registration.activate()
|
||||
else:
|
||||
compose_and_send_activation_email(user, profile, registration)
|
||||
|
||||
# Perform operations that are non-critical parts of account creation
|
||||
create_or_set_user_attribute_created_on_site(user, request.site)
|
||||
|
||||
preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())
|
||||
|
||||
if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
|
||||
try:
|
||||
enable_notifications(user)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))
|
||||
|
||||
dog_stats_api.increment("common.student.account_created")
|
||||
|
||||
_track_user_registration(user, profile, params, third_party_provider)
|
||||
|
||||
# Announce registration
|
||||
REGISTER_USER.send(sender=None, user=user, registration=registration)
|
||||
|
||||
create_comments_service_user(user)
|
||||
|
||||
try:
|
||||
_record_registration_attributions(request, new_user)
|
||||
# Don't prevent a user from registering due to attribution errors.
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Error while attributing cookies to user registration.')
|
||||
|
||||
# TODO: there is no error checking here to see that the user actually logged in successfully,
|
||||
# and is not yet an active user.
|
||||
if new_user is not None:
|
||||
AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
def pre_account_creation_external_auth(request, params):
|
||||
"""
|
||||
External auth related setup before account is created.
|
||||
"""
|
||||
# If doing signup for an external authorization, then get email, password, name from the eamap
|
||||
# don't use the ones from the form, since the user could have hacked those
|
||||
# unless originally we didn't get a valid email or name from the external auth
|
||||
# TODO: We do not check whether these values meet all necessary criteria, such as email length
|
||||
do_external_auth = 'ExternalAuthMap' in request.session
|
||||
eamap = None
|
||||
if do_external_auth:
|
||||
eamap = request.session['ExternalAuthMap']
|
||||
try:
|
||||
validate_email(eamap.external_email)
|
||||
params["email"] = eamap.external_email
|
||||
except ValidationError:
|
||||
pass
|
||||
if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH:
|
||||
params["name"] = eamap.external_name
|
||||
params["password"] = eamap.internal_password
|
||||
log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"])
|
||||
|
||||
return do_external_auth, eamap
|
||||
|
||||
|
||||
def post_account_creation_external_auth(do_external_auth, eamap, new_user):
|
||||
"""
|
||||
External auth related updates after account is created.
|
||||
"""
|
||||
if do_external_auth:
|
||||
eamap.user = new_user
|
||||
eamap.dtsignup = datetime.datetime.now(UTC)
|
||||
eamap.save()
|
||||
AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username)
|
||||
AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap)
|
||||
|
||||
if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
|
||||
log.info('bypassing activation email')
|
||||
new_user.is_active = True
|
||||
new_user.save()
|
||||
AUDIT_LOG.info(
|
||||
u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)
|
||||
)
|
||||
|
||||
|
||||
def _link_user_to_third_party_provider(
|
||||
is_third_party_auth_enabled,
|
||||
third_party_auth_credentials_in_api,
|
||||
user,
|
||||
request,
|
||||
params,
|
||||
):
|
||||
"""
|
||||
If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
|
||||
(If the user is using the normal register page, the social auth pipeline does the linking, not this code)
|
||||
|
||||
Note: this is orthogonal to the 3rd party authentication pipeline that occurs
|
||||
when the account is created via the browser and redirect URLs.
|
||||
"""
|
||||
third_party_provider, running_pipeline = None, None
|
||||
if is_third_party_auth_enabled and third_party_auth_credentials_in_api:
|
||||
backend_name = params['provider']
|
||||
request.social_strategy = social_utils.load_strategy(request)
|
||||
redirect_uri = reverse('social:complete', args=(backend_name, ))
|
||||
request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri)
|
||||
social_access_token = params.get('access_token')
|
||||
if not social_access_token:
|
||||
raise ValidationError({
|
||||
'access_token': [
|
||||
_("An access_token is required when passing value ({}) for provider.").format(
|
||||
params['provider']
|
||||
)
|
||||
]
|
||||
})
|
||||
request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API
|
||||
pipeline_user = None
|
||||
error_message = ""
|
||||
try:
|
||||
pipeline_user = request.backend.do_auth(social_access_token, user=user)
|
||||
except AuthAlreadyAssociated:
|
||||
error_message = _("The provided access_token is already associated with another user.")
|
||||
except (HTTPError, AuthException):
|
||||
error_message = _("The provided access_token is not valid.")
|
||||
if not pipeline_user or not isinstance(pipeline_user, User):
|
||||
# Ensure user does not re-enter the pipeline
|
||||
request.social_strategy.clean_partial_pipeline(social_access_token)
|
||||
raise ValidationError({'access_token': [error_message]})
|
||||
|
||||
# If the user is registering via 3rd party auth, track which provider they use
|
||||
if is_third_party_auth_enabled and pipeline.running(request):
|
||||
running_pipeline = pipeline.get(request)
|
||||
third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
|
||||
|
||||
return third_party_provider, running_pipeline
|
||||
|
||||
|
||||
def _track_user_registration(user, profile, params, third_party_provider):
|
||||
""" Track the user's registration. """
|
||||
if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
|
||||
tracking_context = tracker.get_tracker().resolve_context()
|
||||
identity_args = [
|
||||
user.id,
|
||||
{
|
||||
'email': user.email,
|
||||
'username': user.username,
|
||||
'name': profile.name,
|
||||
# Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey.
|
||||
'age': profile.age or -1,
|
||||
'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year,
|
||||
'education': profile.level_of_education_display,
|
||||
'address': profile.mailing_address,
|
||||
'gender': profile.gender_display,
|
||||
'country': text_type(profile.country),
|
||||
}
|
||||
]
|
||||
|
||||
if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'):
|
||||
identity_args.append({
|
||||
"MailChimp": {
|
||||
"listId": settings.MAILCHIMP_NEW_USER_LIST_ID
|
||||
}
|
||||
})
|
||||
|
||||
analytics.identify(*identity_args)
|
||||
|
||||
analytics.track(
|
||||
user.id,
|
||||
"edx.bi.user.account.registered",
|
||||
{
|
||||
'category': 'conversion',
|
||||
'label': params.get('course_id'),
|
||||
'provider': third_party_provider.name if third_party_provider else None
|
||||
},
|
||||
context={
|
||||
'ip': tracking_context.get('ip'),
|
||||
'Google Analytics': {
|
||||
'clientId': tracking_context.get('client_id')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider):
|
||||
"""
|
||||
Return `True` if activation email should be skipped.
|
||||
|
||||
Skip email if we are:
|
||||
1. Doing load testing.
|
||||
2. Random user generation for other forms of testing.
|
||||
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)
|
||||
|
||||
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
|
||||
changing settings on a running system to make sure no users are
|
||||
left in an inconsistent state (or doing a migration if they are).
|
||||
|
||||
Arguments:
|
||||
user (User): Django User object for the current user.
|
||||
do_external_auth (bool): True if external authentication is in progress.
|
||||
running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication.
|
||||
third_party_provider (ProviderConfig): An instance of third party provider configuration.
|
||||
|
||||
Returns:
|
||||
(bool): `True` if account activation email should be skipped, `False` if account activation email should be
|
||||
sent.
|
||||
"""
|
||||
sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email')
|
||||
|
||||
# Email is valid if the SAML assertion email matches the user account email or
|
||||
# no email was provided in the SAML assertion. Some IdP's use a callback
|
||||
# to retrieve additional user account information (including email) after the
|
||||
# initial account creation.
|
||||
valid_email = (
|
||||
sso_pipeline_email == user.email or (
|
||||
sso_pipeline_email is None and
|
||||
third_party_provider and
|
||||
getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY
|
||||
)
|
||||
)
|
||||
|
||||
# log the cases where skip activation email flag is set, but email validity check fails
|
||||
if third_party_provider and third_party_provider.skip_email_verification and not valid_email:
|
||||
log.info(
|
||||
'[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] '
|
||||
'Account activation email sent as user\'s system email differs from SSO email.',
|
||||
user.email,
|
||||
sso_pipeline_email,
|
||||
getattr(third_party_provider, "provider_id", None),
|
||||
getattr(third_party_provider, "identity_provider_type", None)
|
||||
)
|
||||
|
||||
return (
|
||||
settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or
|
||||
settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or
|
||||
(settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or
|
||||
(third_party_provider and third_party_provider.skip_email_verification and valid_email)
|
||||
)
|
||||
|
||||
|
||||
def _record_registration_attributions(request, user):
|
||||
"""
|
||||
Attribute this user's registration based on referrer cookies.
|
||||
"""
|
||||
_record_affiliate_registration_attribution(request, user)
|
||||
_record_utm_registration_attribution(request, user)
|
||||
|
||||
|
||||
def _record_affiliate_registration_attribution(request, user):
|
||||
"""
|
||||
Attribute this user's registration to the referring affiliate, if
|
||||
applicable.
|
||||
"""
|
||||
affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
|
||||
if user and affiliate_id:
|
||||
UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
|
||||
|
||||
|
||||
def _record_utm_registration_attribution(request, user):
|
||||
"""
|
||||
Attribute this user's registration to the latest UTM referrer, if
|
||||
applicable.
|
||||
"""
|
||||
utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name
|
||||
utm_cookie = request.COOKIES.get(utm_cookie_name)
|
||||
if user and utm_cookie:
|
||||
utm = json.loads(utm_cookie)
|
||||
for utm_parameter_name in REGISTRATION_UTM_PARAMETERS:
|
||||
utm_parameter = utm.get(utm_parameter_name)
|
||||
if utm_parameter:
|
||||
UserAttribute.set_user_attribute(
|
||||
user,
|
||||
REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name),
|
||||
utm_parameter
|
||||
)
|
||||
created_at_unixtime = utm.get('created_at')
|
||||
if created_at_unixtime:
|
||||
# We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds.
|
||||
# PYTHON: time.time() => 1475590280.823698
|
||||
# JS: new Date().getTime() => 1475590280823
|
||||
created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC)
|
||||
UserAttribute.set_user_attribute(
|
||||
user,
|
||||
REGISTRATION_UTM_CREATED_AT,
|
||||
created_at_datetime
|
||||
)
|
||||
350
openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
Normal file
350
openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
Normal file
@@ -0,0 +1,350 @@
|
||||
""" Tests for auto auth. """
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from mock import patch, Mock
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from django_comment_common.models import (
|
||||
Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT
|
||||
)
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
from student.models import anonymous_id_for_user, CourseAccessRole, CourseEnrollment, UserProfile
|
||||
from util.testing import UrlResetMixin
|
||||
|
||||
|
||||
class AutoAuthTestCase(UrlResetMixin, TestCase):
|
||||
"""
|
||||
Base class for AutoAuth Tests that properly resets the urls.py
|
||||
"""
|
||||
URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls_common', 'openedx.core.djangoapps.user_authn.urls']
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
"""
|
||||
Tests for the Auto auth view that we have for load testing.
|
||||
"""
|
||||
COURSE_ID_MONGO = 'edX/Test101/2014_Spring'
|
||||
COURSE_ID_SPLIT = 'course-v1:edX+Test101+2014_Spring'
|
||||
COURSE_IDS_DDT = (
|
||||
(COURSE_ID_MONGO, CourseLocator.from_string(COURSE_ID_MONGO)),
|
||||
(COURSE_ID_SPLIT, CourseLocator.from_string(COURSE_ID_SPLIT)),
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True})
|
||||
def setUp(self):
|
||||
# Patching the settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
|
||||
# value affects the contents of urls.py,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
super(AutoAuthEnabledTestCase, self).setUp()
|
||||
self.url = '/auto_auth'
|
||||
self.client = Client()
|
||||
|
||||
def test_create_user(self):
|
||||
"""
|
||||
Test that user gets created when visiting the page.
|
||||
"""
|
||||
self._auto_auth()
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
user = User.objects.all()[0]
|
||||
self.assertTrue(user.is_active)
|
||||
self.assertFalse(user.profile.requires_parental_consent())
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'RESTRICT_AUTOMATIC_AUTH': False})
|
||||
def test_create_same_user(self):
|
||||
self._auto_auth({'username': 'test'})
|
||||
self._auto_auth({'username': 'test'})
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
def test_create_multiple_users(self):
|
||||
"""
|
||||
Test to make sure multiple users are created.
|
||||
"""
|
||||
self._auto_auth()
|
||||
self.client.logout()
|
||||
self._auto_auth()
|
||||
self.assertEqual(User.objects.all().count(), 2)
|
||||
|
||||
def test_create_defined_user(self):
|
||||
"""
|
||||
Test that the user gets created with the correct attributes
|
||||
when they are passed as parameters on the auto-auth page.
|
||||
"""
|
||||
self._auto_auth({
|
||||
'username': 'robot', 'password': 'test',
|
||||
'email': 'robot@edx.org', 'full_name': "Robot Name"
|
||||
})
|
||||
|
||||
# Check that the user has the correct info
|
||||
user = User.objects.get(username='robot')
|
||||
self.assertEqual(user.username, 'robot')
|
||||
self.assertTrue(user.check_password('test'))
|
||||
self.assertEqual(user.email, 'robot@edx.org')
|
||||
|
||||
# Check that the user has a profile
|
||||
user_profile = UserProfile.objects.get(user=user)
|
||||
self.assertEqual(user_profile.name, "Robot Name")
|
||||
|
||||
# By default, the user should not be global staff
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'RESTRICT_AUTOMATIC_AUTH': False})
|
||||
def test_create_staff_user(self):
|
||||
|
||||
# Create a staff user
|
||||
self._auto_auth({'username': 'test', 'staff': 'true'})
|
||||
user = User.objects.get(username='test')
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
# Revoke staff privileges
|
||||
self._auto_auth({'username': 'test', 'staff': 'false'})
|
||||
user = User.objects.get(username='test')
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
@ddt.data(*COURSE_IDS_DDT)
|
||||
@ddt.unpack
|
||||
def test_course_enrollment(self, course_id, course_key):
|
||||
|
||||
# Create a user and enroll in a course
|
||||
self._auto_auth({'username': 'test', 'course_id': course_id})
|
||||
|
||||
# Check that a course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_key)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
@ddt.data(*COURSE_IDS_DDT)
|
||||
@ddt.unpack
|
||||
@patch.dict("django.conf.settings.FEATURES", {'RESTRICT_AUTOMATIC_AUTH': False})
|
||||
def test_double_enrollment(self, course_id, course_key):
|
||||
|
||||
# Create a user and enroll in a course
|
||||
self._auto_auth({'username': 'test', 'course_id': course_id})
|
||||
|
||||
# Make the same call again, re-enrolling the student in the same course
|
||||
self._auto_auth({'username': 'test', 'course_id': course_id})
|
||||
|
||||
# Check that only one course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_key)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
@ddt.data(*COURSE_IDS_DDT)
|
||||
@ddt.unpack
|
||||
def test_set_roles(self, course_id, course_key):
|
||||
seed_permissions_roles(course_key)
|
||||
course_roles = dict((r.name, r) for r in Role.objects.filter(course_id=course_key))
|
||||
self.assertEqual(len(course_roles), 5) # sanity check
|
||||
|
||||
# Student role is assigned by default on course enrollment.
|
||||
self._auto_auth({'username': 'a_student', 'course_id': course_id})
|
||||
user = User.objects.get(username='a_student')
|
||||
user_roles = user.roles.all()
|
||||
self.assertEqual(len(user_roles), 1)
|
||||
self.assertEqual(user_roles[0], course_roles[FORUM_ROLE_STUDENT])
|
||||
|
||||
self.client.logout()
|
||||
self._auto_auth({'username': 'a_moderator', 'course_id': course_id, 'roles': 'Moderator'})
|
||||
user = User.objects.get(username='a_moderator')
|
||||
user_roles = user.roles.all()
|
||||
self.assertEqual(
|
||||
set(user_roles),
|
||||
set([course_roles[FORUM_ROLE_STUDENT],
|
||||
course_roles[FORUM_ROLE_MODERATOR]]))
|
||||
|
||||
# check multiple roles work.
|
||||
self.client.logout()
|
||||
self._auto_auth({
|
||||
'username': 'an_admin', 'course_id': course_id,
|
||||
'roles': '{},{}'.format(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR)
|
||||
})
|
||||
user = User.objects.get(username='an_admin')
|
||||
user_roles = user.roles.all()
|
||||
self.assertEqual(
|
||||
set(user_roles),
|
||||
set([course_roles[FORUM_ROLE_STUDENT],
|
||||
course_roles[FORUM_ROLE_MODERATOR],
|
||||
course_roles[FORUM_ROLE_ADMINISTRATOR]]))
|
||||
|
||||
def test_json_response(self):
|
||||
""" The view should return JSON. """
|
||||
response = self._auto_auth()
|
||||
response_data = json.loads(response.content)
|
||||
for key in ['created_status', 'username', 'email', 'password', 'user_id', 'anonymous_id']:
|
||||
self.assertIn(key, response_data)
|
||||
user = User.objects.get(username=response_data['username'])
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
'created_status': 'Logged in',
|
||||
'anonymous_id': anonymous_id_for_user(user, None),
|
||||
},
|
||||
response_data
|
||||
)
|
||||
|
||||
@ddt.data(*COURSE_IDS_DDT)
|
||||
@ddt.unpack
|
||||
def test_redirect_to_course(self, course_id, course_key):
|
||||
# Create a user and enroll in a course
|
||||
response = self._auto_auth({
|
||||
'username': 'test',
|
||||
'course_id': course_id,
|
||||
'redirect': True,
|
||||
'staff': 'true',
|
||||
}, status_code=302)
|
||||
|
||||
# Check that a course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_key)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
# Check that the redirect was to the course info/outline page
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
url_pattern = '/course/'
|
||||
else:
|
||||
url_pattern = '/course/{}'.format(unicode(course_key))
|
||||
|
||||
self.assertTrue(response.url.endswith(url_pattern))
|
||||
|
||||
def test_redirect_to_main(self):
|
||||
# Create user and redirect to 'home' (cms) or 'dashboard' (lms)
|
||||
response = self._auto_auth({
|
||||
'username': 'test',
|
||||
'redirect': True,
|
||||
'staff': 'true',
|
||||
}, status_code=302)
|
||||
|
||||
# Check that the redirect was to either /dashboard or /home
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
url_pattern = '/dashboard'
|
||||
else:
|
||||
url_pattern = '/home'
|
||||
|
||||
self.assertTrue(response.url.endswith(url_pattern))
|
||||
|
||||
def test_redirect_to_specified(self):
|
||||
# Create user and redirect to specified url
|
||||
url_pattern = '/u/test#about_me'
|
||||
response = self._auto_auth({
|
||||
'username': 'test',
|
||||
'redirect_to': url_pattern,
|
||||
'staff': 'true',
|
||||
}, status_code=302)
|
||||
|
||||
self.assertTrue(response.url.endswith(url_pattern))
|
||||
|
||||
def _auto_auth(self, params=None, status_code=200, **kwargs):
|
||||
"""
|
||||
Make a request to the auto-auth end-point and check
|
||||
that the response is successful.
|
||||
|
||||
Arguments:
|
||||
params (dict): Dict of params to pass to the auto_auth view
|
||||
status_code (int): Expected response status code
|
||||
kwargs: Passed directly to the test client's get method.
|
||||
|
||||
Returns:
|
||||
Response: The response object for the auto_auth page.
|
||||
"""
|
||||
params = params or {}
|
||||
response = self.client.get(self.url, params, **kwargs)
|
||||
|
||||
self.assertEqual(response.status_code, status_code)
|
||||
|
||||
# Check that session and CSRF are set in the response
|
||||
for cookie in ['csrftoken', 'sessionid']:
|
||||
self.assertIn(cookie, response.cookies)
|
||||
self.assertTrue(response.cookies[cookie].value)
|
||||
|
||||
return response
|
||||
|
||||
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", Mock(return_value=False))
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_course_access_roles(self):
|
||||
""" Passing role names via the course_access_roles query string parameter should create CourseAccessRole
|
||||
objects associated with the user.
|
||||
"""
|
||||
expected_roles = ['finance_admin', 'sales_admin']
|
||||
course_key = CourseLocator.from_string(self.COURSE_ID_SPLIT)
|
||||
params = {
|
||||
'course_id': str(course_key),
|
||||
'course_access_roles': ','.join(expected_roles)
|
||||
}
|
||||
response = self._auto_auth(params)
|
||||
user_info = json.loads(response.content)
|
||||
|
||||
for role in expected_roles:
|
||||
self.assertTrue(
|
||||
CourseAccessRole.objects.filter(
|
||||
user__id=user_info['user_id'], course_id=course_key, org=course_key.org, role=role
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class AutoAuthDisabledTestCase(AutoAuthTestCase):
|
||||
"""
|
||||
Test that the page is inaccessible with default settings
|
||||
"""
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": False})
|
||||
def setUp(self):
|
||||
# Patching the settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
|
||||
# value affects the contents of urls.py,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
super(AutoAuthDisabledTestCase, self).setUp()
|
||||
self.url = '/auto_auth'
|
||||
self.client = Client()
|
||||
|
||||
def test_auto_auth_disabled(self):
|
||||
"""
|
||||
Make sure automatic authentication is disabled.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class AutoAuthRestrictedTestCase(AutoAuthTestCase):
|
||||
"""
|
||||
Test that the default security restrictions on automatic authentication
|
||||
work as intended. These restrictions are in place for load tests.
|
||||
"""
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'AUTOMATIC_AUTH_FOR_TESTING': True})
|
||||
def setUp(self):
|
||||
# Patching the settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
|
||||
# value affects the contents of urls.py,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
super(AutoAuthRestrictedTestCase, self).setUp()
|
||||
self.url = '/auto_auth'
|
||||
self.client = Client()
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'RESTRICT_AUTOMATIC_AUTH': True})
|
||||
def test_superuser(self):
|
||||
"""
|
||||
Make sure that superusers cannot be created.
|
||||
"""
|
||||
response = self.client.get(self.url, {'username': 'test', 'superuser': 'true'})
|
||||
assert response.status_code == 403
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'RESTRICT_AUTOMATIC_AUTH': True})
|
||||
def test_modify_user(self):
|
||||
"""
|
||||
Make sure that existing users cannot be modified.
|
||||
"""
|
||||
response = self.client.get(self.url, {'username': 'test'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(self.url, {'username': 'test'})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
613
openedx/core/djangoapps/user_authn/views/tests/test_login.py
Normal file
613
openedx/core/djangoapps/user_authn/views/tests/test_login.py
Normal file
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Tests for student activation and login
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
from mock import patch
|
||||
from six import text_type
|
||||
|
||||
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
|
||||
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle
|
||||
from openedx.core.djangoapps.password_policy.compliance import (
|
||||
NonCompliantPasswordException,
|
||||
NonCompliantPasswordWarning
|
||||
)
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class LoginTest(CacheIsolationTestCase):
|
||||
"""
|
||||
Test login_user() view
|
||||
"""
|
||||
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
def setUp(self):
|
||||
super(LoginTest, self).setUp()
|
||||
# Create one user and save it to the database
|
||||
self.user = UserFactory.build(username='test', email='test@edx.org')
|
||||
self.user.set_password('test_password')
|
||||
self.user.save()
|
||||
|
||||
# Create a registration for the user
|
||||
RegistrationFactory(user=self.user)
|
||||
|
||||
# Create a profile for the user
|
||||
UserProfileFactory(user=self.user)
|
||||
|
||||
# Create the test client
|
||||
self.client = Client()
|
||||
cache.clear()
|
||||
|
||||
# Store the login url
|
||||
try:
|
||||
self.url = reverse('login_post')
|
||||
except NoReverseMatch:
|
||||
self.url = reverse('login')
|
||||
|
||||
def test_login_success(self):
|
||||
response, mock_audit_log = self._login_response('test@edx.org', 'test_password',
|
||||
patched_audit_log='student.models.AUDIT_LOG')
|
||||
self._assert_response(response, success=True)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Login success', u'test@edx.org'])
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_login_success_no_pii(self):
|
||||
response, mock_audit_log = self._login_response('test@edx.org', 'test_password',
|
||||
patched_audit_log='student.models.AUDIT_LOG')
|
||||
self._assert_response(response, success=True)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Login success'])
|
||||
self._assert_not_in_audit_log(mock_audit_log, 'info', [u'test@edx.org'])
|
||||
|
||||
def test_login_success_unicode_email(self):
|
||||
unicode_email = u'test' + unichr(40960) + u'@edx.org'
|
||||
self.user.email = unicode_email
|
||||
self.user.save()
|
||||
|
||||
response, mock_audit_log = self._login_response(unicode_email, 'test_password',
|
||||
patched_audit_log='student.models.AUDIT_LOG')
|
||||
self._assert_response(response, success=True)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Login success', unicode_email])
|
||||
|
||||
def test_last_login_updated(self):
|
||||
old_last_login = self.user.last_login
|
||||
self.test_login_success()
|
||||
self.user.refresh_from_db()
|
||||
assert self.user.last_login > old_last_login
|
||||
|
||||
def test_login_success_prevent_auth_user_writes(self):
|
||||
with waffle().override(PREVENT_AUTH_USER_WRITES, True):
|
||||
old_last_login = self.user.last_login
|
||||
self.test_login_success()
|
||||
self.user.refresh_from_db()
|
||||
assert old_last_login == self.user.last_login
|
||||
|
||||
def test_login_fail_no_user_exists(self):
|
||||
nonexistent_email = u'not_a_user@edx.org'
|
||||
response, mock_audit_log = self._login_response(
|
||||
nonexistent_email,
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value='Email or password is incorrect')
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True})
|
||||
def test_login_fail_incorrect_email_with_advanced_security(self):
|
||||
nonexistent_email = u'not_a_user@edx.org'
|
||||
response, mock_audit_log = self._login_response(
|
||||
nonexistent_email,
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value='Email or password is incorrect')
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_login_fail_no_user_exists_no_pii(self):
|
||||
nonexistent_email = u'not_a_user@edx.org'
|
||||
response, mock_audit_log = self._login_response(
|
||||
nonexistent_email,
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value='Email or password is incorrect')
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email'])
|
||||
self._assert_not_in_audit_log(mock_audit_log, 'warning', [nonexistent_email])
|
||||
|
||||
def test_login_fail_wrong_password(self):
|
||||
response, mock_audit_log = self._login_response(
|
||||
'test@edx.org',
|
||||
'wrong_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value='Email or password is incorrect')
|
||||
self._assert_audit_log(mock_audit_log, 'warning',
|
||||
[u'Login failed', u'password for', u'test@edx.org', u'invalid'])
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_login_fail_wrong_password_no_pii(self):
|
||||
response, mock_audit_log = self._login_response(
|
||||
'test@edx.org',
|
||||
'wrong_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value='Email or password is incorrect')
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'invalid'])
|
||||
self._assert_not_in_audit_log(mock_audit_log, 'warning', [u'test@edx.org'])
|
||||
|
||||
def test_login_not_activated(self):
|
||||
# De-activate the user
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
|
||||
# Should now be unable to login
|
||||
response, mock_audit_log = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value="In order to sign in, you need to activate your account.")
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user'])
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_login_not_activated_no_pii(self):
|
||||
# De-activate the user
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
|
||||
# Should now be unable to login
|
||||
response, mock_audit_log = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False,
|
||||
value="In order to sign in, you need to activate your account.")
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user'])
|
||||
self._assert_not_in_audit_log(mock_audit_log, 'warning', [u'test'])
|
||||
|
||||
def test_login_unicode_email(self):
|
||||
unicode_email = u'test@edx.org' + unichr(40960)
|
||||
response, mock_audit_log = self._login_response(
|
||||
unicode_email,
|
||||
'test_password',
|
||||
)
|
||||
self._assert_response(response, success=False)
|
||||
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email])
|
||||
|
||||
def test_login_unicode_password(self):
|
||||
unicode_password = u'test_password' + unichr(1972)
|
||||
response, mock_audit_log = self._login_response(
|
||||
'test@edx.org',
|
||||
unicode_password,
|
||||
)
|
||||
self._assert_response(response, success=False)
|
||||
self._assert_audit_log(mock_audit_log, 'warning',
|
||||
[u'Login failed', u'password for', u'test@edx.org', u'invalid'])
|
||||
|
||||
def test_logout_logging(self):
|
||||
response, _ = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
logout_url = reverse('logout')
|
||||
with patch('student.models.AUDIT_LOG') as mock_audit_log:
|
||||
response = self.client.post(logout_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
|
||||
|
||||
def test_login_user_info_cookie(self):
|
||||
response, _ = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
# Verify the format of the "user info" cookie set on login
|
||||
cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME]
|
||||
user_info = json.loads(cookie.value)
|
||||
|
||||
self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION)
|
||||
self.assertEqual(user_info["username"], self.user.username)
|
||||
|
||||
# Check that the URLs are absolute
|
||||
for url in user_info["header_urls"].values():
|
||||
self.assertIn("http://testserver/", url)
|
||||
|
||||
def test_logout_deletes_mktg_cookies(self):
|
||||
response, _ = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
# Check that the marketing site cookies have been set
|
||||
self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies)
|
||||
self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
|
||||
|
||||
# Log out
|
||||
logout_url = reverse('logout')
|
||||
response = self.client.post(logout_url)
|
||||
|
||||
# Check that the marketing site cookies have been deleted
|
||||
# (cookies are deleted by setting an expiration date in 1970)
|
||||
for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]:
|
||||
cookie = self.client.cookies[cookie_name]
|
||||
self.assertIn("01-Jan-1970", cookie.get('expires'))
|
||||
|
||||
@override_settings(
|
||||
EDXMKTG_LOGGED_IN_COOKIE_NAME=u"unicode-logged-in",
|
||||
EDXMKTG_USER_INFO_COOKIE_NAME=u"unicode-user-info",
|
||||
)
|
||||
def test_unicode_mktg_cookie_names(self):
|
||||
# When logged in cookie names are loaded from JSON files, they may
|
||||
# have type `unicode` instead of `str`, which can cause errors
|
||||
# when calling Django cookie manipulation functions.
|
||||
response, _ = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
response = self.client.post(reverse('logout'))
|
||||
self.assertRedirects(response, "/")
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_logout_logging_no_pii(self):
|
||||
response, _ = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
logout_url = reverse('logout')
|
||||
with patch('student.models.AUDIT_LOG') as mock_audit_log:
|
||||
response = self.client.post(logout_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Logout'])
|
||||
self._assert_not_in_audit_log(mock_audit_log, 'info', [u'test'])
|
||||
|
||||
def test_login_ratelimited_success(self):
|
||||
# Try (and fail) logging in with fewer attempts than the limit of 30
|
||||
# and verify that you can still successfully log in afterwards.
|
||||
for i in xrange(20):
|
||||
password = u'test_password{0}'.format(i)
|
||||
response, _audit_log = self._login_response('test@edx.org', password)
|
||||
self._assert_response(response, success=False)
|
||||
# now try logging in with a valid password
|
||||
response, _audit_log = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
def test_login_ratelimited(self):
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for i in xrange(30):
|
||||
password = u'test_password{0}'.format(i)
|
||||
self._login_response('test@edx.org', password)
|
||||
# check to see if this response indicates that this was ratelimited
|
||||
response, _audit_log = self._login_response('test@edx.org', 'wrong_password')
|
||||
self._assert_response(response, success=False, value='Too many failed login attempts')
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True})
|
||||
def test_single_session(self):
|
||||
creds = {'email': 'test@edx.org', 'password': 'test_password'}
|
||||
client1 = Client()
|
||||
client2 = Client()
|
||||
|
||||
response = client1.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
# Reload the user from the database
|
||||
self.user = User.objects.get(pk=self.user.pk)
|
||||
|
||||
self.assertEqual(self.user.profile.get_meta()['session_id'], client1.session.session_key)
|
||||
|
||||
# second login should log out the first
|
||||
response = client2.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
try:
|
||||
# this test can be run with either lms or studio settings
|
||||
# since studio does not have a dashboard url, we should
|
||||
# look for another url that is login_required, in that case
|
||||
url = reverse('dashboard')
|
||||
except NoReverseMatch:
|
||||
url = reverse('upload_transcripts')
|
||||
response = client1.get(url)
|
||||
# client1 will be logged out
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True})
|
||||
def test_single_session_with_no_user_profile(self):
|
||||
"""
|
||||
Assert that user login with cas (Central Authentication Service) is
|
||||
redirect to dashboard in case of lms or upload_transcripts in case of
|
||||
cms
|
||||
"""
|
||||
user = UserFactory.build(username='tester', email='tester@edx.org')
|
||||
user.set_password('test_password')
|
||||
user.save()
|
||||
|
||||
# Assert that no profile is created.
|
||||
self.assertFalse(hasattr(user, 'profile'))
|
||||
|
||||
creds = {'email': 'tester@edx.org', 'password': 'test_password'}
|
||||
client1 = Client()
|
||||
client2 = Client()
|
||||
|
||||
response = client1.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
# Reload the user from the database
|
||||
user = User.objects.get(pk=user.pk)
|
||||
|
||||
# Assert that profile is created.
|
||||
self.assertTrue(hasattr(user, 'profile'))
|
||||
|
||||
# second login should log out the first
|
||||
response = client2.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
try:
|
||||
# this test can be run with either lms or studio settings
|
||||
# since studio does not have a dashboard url, we should
|
||||
# look for another url that is login_required, in that case
|
||||
url = reverse('dashboard')
|
||||
except NoReverseMatch:
|
||||
url = reverse('upload_transcripts')
|
||||
response = client1.get(url)
|
||||
# client1 will be logged out
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True})
|
||||
def test_single_session_with_url_not_having_login_required_decorator(self):
|
||||
# accessing logout url as it does not have login-required decorator it will avoid redirect
|
||||
# and go inside the enforce_single_login
|
||||
|
||||
creds = {'email': 'test@edx.org', 'password': 'test_password'}
|
||||
client1 = Client()
|
||||
client2 = Client()
|
||||
|
||||
response = client1.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
# Reload the user from the database
|
||||
self.user = User.objects.get(pk=self.user.pk)
|
||||
|
||||
self.assertEqual(self.user.profile.get_meta()['session_id'], client1.session.session_key)
|
||||
|
||||
# second login should log out the first
|
||||
response = client2.post(self.url, creds)
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
url = reverse('logout')
|
||||
|
||||
response = client1.get(url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_change_enrollment_400(self):
|
||||
"""
|
||||
Tests that a 400 in change_enrollment doesn't lead to a 404
|
||||
and in fact just logs in the user without incident
|
||||
"""
|
||||
# add this post param to trigger a call to change_enrollment
|
||||
extra_post_params = {"enrollment_action": "enroll"}
|
||||
with patch('student.views.change_enrollment') as mock_change_enrollment:
|
||||
mock_change_enrollment.return_value = HttpResponseBadRequest("I am a 400")
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
extra_post_params=extra_post_params,
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertIsNone(response_content["redirect_url"])
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
def test_change_enrollment_200_no_redirect(self):
|
||||
"""
|
||||
Tests "redirect_url" is None if change_enrollment returns a HttpResponse
|
||||
with no content
|
||||
"""
|
||||
# add this post param to trigger a call to change_enrollment
|
||||
extra_post_params = {"enrollment_action": "enroll"}
|
||||
with patch('student.views.change_enrollment') as mock_change_enrollment:
|
||||
mock_change_enrollment.return_value = HttpResponse()
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
extra_post_params=extra_post_params,
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertIsNone(response_content["redirect_url"])
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
@override_settings(PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG={'ENFORCE_COMPLIANCE_ON_LOGIN': True})
|
||||
def test_check_password_policy_compliance(self):
|
||||
"""
|
||||
Tests _enforce_password_policy_compliance succeeds when no exception is thrown
|
||||
"""
|
||||
enforce_compliance_path = 'openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login'
|
||||
with patch(enforce_compliance_path) as mock_check_password_policy_compliance:
|
||||
mock_check_password_policy_compliance.return_value = HttpResponse()
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertTrue(response_content.get('success'))
|
||||
|
||||
@override_settings(PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG={'ENFORCE_COMPLIANCE_ON_LOGIN': True})
|
||||
def test_check_password_policy_compliance_exception(self):
|
||||
"""
|
||||
Tests _enforce_password_policy_compliance fails with an exception thrown
|
||||
"""
|
||||
with patch('openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login') as \
|
||||
mock_enforce_compliance_on_login:
|
||||
mock_enforce_compliance_on_login.side_effect = NonCompliantPasswordException()
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password'
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertFalse(response_content.get('success'))
|
||||
|
||||
@override_settings(PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG={'ENFORCE_COMPLIANCE_ON_LOGIN': True})
|
||||
def test_check_password_policy_compliance_warning(self):
|
||||
"""
|
||||
Tests _enforce_password_policy_compliance succeeds with a warning thrown
|
||||
"""
|
||||
with patch('openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login') as \
|
||||
mock_enforce_compliance_on_login:
|
||||
mock_enforce_compliance_on_login.side_effect = NonCompliantPasswordWarning('Test warning')
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password'
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertIn('Test warning', self.client.session['_messages'])
|
||||
self.assertTrue(response_content.get('success'))
|
||||
|
||||
def _login_response(self, email, password, patched_audit_log=None, extra_post_params=None):
|
||||
"""
|
||||
Post the login info
|
||||
"""
|
||||
if patched_audit_log is None:
|
||||
patched_audit_log = 'openedx.core.djangoapps.user_authn.views.login.AUDIT_LOG'
|
||||
post_params = {'email': email, 'password': password}
|
||||
if extra_post_params is not None:
|
||||
post_params.update(extra_post_params)
|
||||
with patch(patched_audit_log) as mock_audit_log:
|
||||
result = self.client.post(self.url, post_params)
|
||||
return result, mock_audit_log
|
||||
|
||||
def _assert_response(self, response, success=None, value=None):
|
||||
"""
|
||||
Assert that the response had status 200 and returned a valid
|
||||
JSON-parseable dict.
|
||||
|
||||
If success is provided, assert that the response had that
|
||||
value for 'success' in the JSON dict.
|
||||
|
||||
If value is provided, assert that the response contained that
|
||||
value for 'value' in the JSON dict.
|
||||
"""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
try:
|
||||
response_dict = json.loads(response.content)
|
||||
except ValueError:
|
||||
self.fail("Could not parse response content as JSON: %s"
|
||||
% str(response.content))
|
||||
|
||||
if success is not None:
|
||||
self.assertEqual(response_dict['success'], success)
|
||||
|
||||
if value is not None:
|
||||
msg = ("'%s' did not contain '%s'" %
|
||||
(unicode(response_dict['value']), unicode(value)))
|
||||
self.assertIn(value, response_dict['value'], msg)
|
||||
|
||||
def _assert_audit_log(self, mock_audit_log, level, log_strings):
|
||||
"""
|
||||
Check that the audit log has received the expected call as its last call.
|
||||
"""
|
||||
method_calls = mock_audit_log.method_calls
|
||||
name, args, _kwargs = method_calls[-1]
|
||||
self.assertEquals(name, level)
|
||||
self.assertEquals(len(args), 1)
|
||||
format_string = args[0]
|
||||
for log_string in log_strings:
|
||||
self.assertIn(log_string, format_string)
|
||||
|
||||
def _assert_not_in_audit_log(self, mock_audit_log, level, log_strings):
|
||||
"""
|
||||
Check that the audit log has received the expected call as its last call.
|
||||
"""
|
||||
method_calls = mock_audit_log.method_calls
|
||||
name, args, _kwargs = method_calls[-1]
|
||||
self.assertEquals(name, level)
|
||||
self.assertEquals(len(args), 1)
|
||||
format_string = args[0]
|
||||
for log_string in log_strings:
|
||||
self.assertNotIn(log_string, format_string)
|
||||
|
||||
|
||||
class ExternalAuthShibTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests how login_user() interacts with ExternalAuth, in particular Shib
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(ExternalAuthShibTest, self).setUp()
|
||||
self.course = CourseFactory.create(
|
||||
org='Stanford',
|
||||
number='456',
|
||||
display_name='NO SHIB',
|
||||
user_id=self.user.id,
|
||||
)
|
||||
self.shib_course = CourseFactory.create(
|
||||
org='Stanford',
|
||||
number='123',
|
||||
display_name='Shib Only',
|
||||
enrollment_domain='shib:https://idp.stanford.edu/',
|
||||
user_id=self.user.id,
|
||||
)
|
||||
self.user_w_map = UserFactory.create(email='withmap@stanford.edu')
|
||||
self.extauth = ExternalAuthMap(external_id='withmap@stanford.edu',
|
||||
external_email='withmap@stanford.edu',
|
||||
external_domain='shib:https://idp.stanford.edu/',
|
||||
external_credentials="",
|
||||
user=self.user_w_map)
|
||||
self.user_w_map.save()
|
||||
self.extauth.save()
|
||||
self.user_wo_map = UserFactory.create(email='womap@gmail.com')
|
||||
self.user_wo_map.save()
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_login_page_redirect(self):
|
||||
"""
|
||||
Tests that when a shib user types their email address into the login page, they get redirected
|
||||
to the shib login.
|
||||
"""
|
||||
response = self.client.post(reverse('login'), {'email': self.user_w_map.email, 'password': ''})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
obj = json.loads(response.content)
|
||||
self.assertEqual(obj, {
|
||||
'success': False,
|
||||
'redirect': reverse('shib-login'),
|
||||
})
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_login_required_dashboard(self):
|
||||
"""
|
||||
Tests redirects to when @login_required to dashboard, which should always be the normal login,
|
||||
since there is no course context
|
||||
"""
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['Location'], '/login?next=/dashboard')
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_externalauth_login_required_course_context(self):
|
||||
"""
|
||||
Tests the redirects when visiting course-specific URL with @login_required.
|
||||
Should vary by course depending on its enrollment_domain
|
||||
"""
|
||||
target_url = reverse('courseware', args=[text_type(self.course.id)])
|
||||
noshib_response = self.client.get(target_url, follow=True, HTTP_ACCEPT="text/html")
|
||||
self.assertEqual(noshib_response.redirect_chain[-1],
|
||||
('/login?next={url}'.format(url=target_url), 302))
|
||||
self.assertContains(noshib_response, (u"Sign in or Register | {platform_name}"
|
||||
.format(platform_name=settings.PLATFORM_NAME)))
|
||||
self.assertEqual(noshib_response.status_code, 200)
|
||||
|
||||
target_url_shib = reverse('courseware', args=[text_type(self.shib_course.id)])
|
||||
shib_response = self.client.get(**{'path': target_url_shib,
|
||||
'follow': True,
|
||||
'REMOTE_USER': self.extauth.external_id,
|
||||
'Shib-Identity-Provider': 'https://idp.stanford.edu/',
|
||||
'HTTP_ACCEPT': "text/html"})
|
||||
# Test that the shib-login redirect page with ?next= and the desired page are part of the redirect chain
|
||||
# The 'courseware' page actually causes a redirect itself, so it's not the end of the chain and we
|
||||
# won't test its contents
|
||||
self.assertEqual(shib_response.redirect_chain[-3],
|
||||
('/shib-login/?next={url}'.format(url=target_url_shib), 302))
|
||||
self.assertEqual(shib_response.redirect_chain[-2],
|
||||
(target_url_shib, 302))
|
||||
self.assertEqual(shib_response.status_code, 200)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for the login and registration form rendering. """
|
||||
import unittest
|
||||
import urllib
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from mock import patch
|
||||
|
||||
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
|
||||
from util.testing import UrlResetMixin
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
# This relies on third party auth being enabled in the test
|
||||
# settings with the feature flag `ENABLE_THIRD_PARTY_AUTH`
|
||||
THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
|
||||
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
|
||||
|
||||
|
||||
def _third_party_login_url(backend_name, auth_entry, redirect_url=None):
|
||||
"""Construct the login URL to start third party authentication. """
|
||||
params = [("auth_entry", auth_entry)]
|
||||
if redirect_url:
|
||||
params.append(("next", redirect_url))
|
||||
|
||||
return u"{url}?{params}".format(
|
||||
url=reverse("social:begin", kwargs={"backend": backend_name}),
|
||||
params=urllib.urlencode(params)
|
||||
)
|
||||
|
||||
|
||||
def _finish_auth_url(params):
|
||||
""" Construct the URL that follows login/registration if we are doing auto-enrollment """
|
||||
return u"{}?{}".format(reverse('finish_auth'), urllib.urlencode(params))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase):
|
||||
"""Test rendering of the login form. """
|
||||
|
||||
URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(LoginFormTest, cls).setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(LoginFormTest, self).setUp()
|
||||
|
||||
self.url = reverse("signin_user")
|
||||
self.course_id = unicode(self.course.id)
|
||||
self.courseware_url = reverse("courseware", args=[self.course_id])
|
||||
self.configure_google_provider(enabled=True, visible=True)
|
||||
self.configure_facebook_provider(enabled=True, visible=True)
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@ddt.data(THIRD_PARTY_AUTH_PROVIDERS)
|
||||
def test_third_party_auth_disabled(self, provider_name):
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, provider_name)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_no_course_id(self, backend_name):
|
||||
response = self.client.get(self.url)
|
||||
expected_url = _third_party_login_url(backend_name, "login")
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_course_id(self, backend_name):
|
||||
# Provide a course ID to the login page, simulating what happens
|
||||
# when a user tries to enroll in a course without being logged in
|
||||
params = [('course_id', self.course_id)]
|
||||
response = self.client.get(self.url, params)
|
||||
|
||||
# Expect that the course ID is added to the third party auth entry
|
||||
# point, so that the pipeline will enroll the student and
|
||||
# redirect the student to the track selection page.
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_courseware_redirect(self, backend_name):
|
||||
# Try to access courseware while logged out, expecting to be
|
||||
# redirected to the login page.
|
||||
response = self.client.get(self.courseware_url, follow=True, HTTP_ACCEPT="text/html")
|
||||
self.assertRedirects(
|
||||
response,
|
||||
u"{url}?next={redirect_url}".format(
|
||||
url=reverse("signin_user"),
|
||||
redirect_url=self.courseware_url
|
||||
)
|
||||
)
|
||||
|
||||
# Verify that the third party auth URLs include the redirect URL
|
||||
# The third party auth pipeline will redirect to this page
|
||||
# once the user successfully authenticates.
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
redirect_url=self.courseware_url
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_params(self, backend_name):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
response = self.client.get(self.url, params, HTTP_ACCEPT="text/html")
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(None, "true", "false")
|
||||
def test_params(self, opt_in_value):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', opt_in_value),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
# Get the login page
|
||||
response = self.client.get(self.url, params, HTTP_ACCEPT="text/html")
|
||||
|
||||
# Verify that the parameters are sent on to the next page correctly
|
||||
post_login_handler = _finish_auth_url(params)
|
||||
js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
|
||||
self.assertContains(response, js_success_var)
|
||||
|
||||
# Verify that the login link preserves the querystring params
|
||||
login_link = u"{url}?{params}".format(
|
||||
url=reverse('signin_user'),
|
||||
params=urllib.urlencode([('next', post_login_handler)])
|
||||
)
|
||||
self.assertContains(response, login_link)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase):
|
||||
"""Test rendering of the registration form. """
|
||||
|
||||
URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(RegisterFormTest, cls).setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(RegisterFormTest, self).setUp()
|
||||
|
||||
self.url = reverse("register_user")
|
||||
self.course_id = unicode(self.course.id)
|
||||
self.configure_google_provider(enabled=True, visible=True)
|
||||
self.configure_facebook_provider(enabled=True, visible=True)
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)
|
||||
def test_third_party_auth_disabled(self, provider_name):
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, provider_name)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_register_third_party_auth_no_course_id(self, backend_name):
|
||||
response = self.client.get(self.url)
|
||||
expected_url = _third_party_login_url(backend_name, "register")
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_register_third_party_auth_with_params(self, backend_name):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
response = self.client.get(self.url, params, HTTP_ACCEPT="text/html")
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"register",
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(None, "true", "false")
|
||||
def test_params(self, opt_in_value):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', opt_in_value),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
# Get the login page
|
||||
response = self.client.get(self.url, params, HTTP_ACCEPT="text/html")
|
||||
|
||||
# Verify that the parameters are sent on to the next page correctly
|
||||
post_login_handler = _finish_auth_url(params)
|
||||
js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
|
||||
self.assertContains(response, js_success_var)
|
||||
|
||||
# Verify that the login link preserves the querystring params
|
||||
login_link = u"{url}?{params}".format(
|
||||
url=reverse('signin_user'),
|
||||
params=urllib.urlencode([('next', post_login_handler)])
|
||||
)
|
||||
self.assertContains(response, login_link)
|
||||
934
openedx/core/djangoapps/user_authn/views/tests/test_register.py
Normal file
934
openedx/core/djangoapps/user_authn/views/tests/test_register.py
Normal file
@@ -0,0 +1,934 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for account creation"""
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
import ddt
|
||||
import mock
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from django_comment_common.models import ForumsConfig
|
||||
from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from openedx.core.djangoapps.user_authn.views.deprecated import create_account
|
||||
from openedx.core.djangoapps.user_authn.views.register import (
|
||||
REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS,
|
||||
_skip_activation_email,
|
||||
)
|
||||
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
|
||||
from openedx.core.djangoapps.user_api.accounts import (
|
||||
USERNAME_BAD_LENGTH_MSG, USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from student.models import UserAttribute
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.tests import factories as third_party_auth_factory
|
||||
|
||||
TEST_CS_URL = 'https://comments.service.test:123/'
|
||||
|
||||
TEST_USERNAME = 'test_user'
|
||||
TEST_EMAIL = 'test@test.com'
|
||||
|
||||
|
||||
def get_mock_pipeline_data(username=TEST_USERNAME, email=TEST_EMAIL):
|
||||
"""
|
||||
Return mock pipeline data.
|
||||
"""
|
||||
return {
|
||||
'backend': 'tpa-saml',
|
||||
'kwargs': {
|
||||
'username': username,
|
||||
'auth_entry': 'register',
|
||||
'request': {
|
||||
'SAMLResponse': [],
|
||||
'RelayState': [
|
||||
'testshib-openedx'
|
||||
]
|
||||
},
|
||||
'is_new': True,
|
||||
'new_association': True,
|
||||
'user': None,
|
||||
'social': None,
|
||||
'details': {
|
||||
'username': username,
|
||||
'fullname': 'Test Test',
|
||||
'last_name': 'Test',
|
||||
'first_name': 'Test',
|
||||
'email': email,
|
||||
},
|
||||
'response': {},
|
||||
'uid': 'testshib-openedx:{}'.format(username)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_settings(
|
||||
MICROSITE_CONFIGURATION={
|
||||
"microsite": {
|
||||
"domain_prefix": "microsite",
|
||||
"extended_profile_fields": ["extra1", "extra2"],
|
||||
}
|
||||
},
|
||||
REGISTRATION_EXTRA_FIELDS={
|
||||
key: "optional"
|
||||
for key in [
|
||||
"level_of_education", "gender", "mailing_address", "city", "country", "goals",
|
||||
"year_of_birth"
|
||||
]
|
||||
}
|
||||
)
|
||||
class TestCreateAccount(SiteMixin, TestCase):
|
||||
"""Tests for account creation"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestCreateAccount, self).setUp()
|
||||
self.username = "test_user"
|
||||
self.url = reverse("create_account")
|
||||
self.request_factory = RequestFactory()
|
||||
self.params = {
|
||||
"username": self.username,
|
||||
"email": "test@example.org",
|
||||
"password": "testpass",
|
||||
"name": "Test User",
|
||||
"honor_code": "true",
|
||||
"terms_of_service": "true",
|
||||
}
|
||||
|
||||
@ddt.data("en", "eo")
|
||||
def test_default_lang_pref_saved(self, lang):
|
||||
with mock.patch("django.conf.settings.LANGUAGE_CODE", lang):
|
||||
response = self.client.post(self.url, self.params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
user = User.objects.get(username=self.username)
|
||||
self.assertEqual(get_user_preference(user, LANGUAGE_KEY), lang)
|
||||
|
||||
@ddt.data("en", "eo")
|
||||
def test_header_lang_pref_saved(self, lang):
|
||||
response = self.client.post(self.url, self.params, HTTP_ACCEPT_LANGUAGE=lang)
|
||||
user = User.objects.get(username=self.username)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(get_user_preference(user, LANGUAGE_KEY), lang)
|
||||
|
||||
def create_account_and_fetch_profile(self, host='microsite.example.com'):
|
||||
"""
|
||||
Create an account with self.params, assert that the response indicates
|
||||
success, and return the UserProfile object for the newly created user
|
||||
"""
|
||||
response = self.client.post(self.url, self.params, HTTP_HOST=host)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
user = User.objects.get(username=self.username)
|
||||
return user.profile
|
||||
|
||||
def test_marketing_cookie(self):
|
||||
response = self.client.post(self.url, self.params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies)
|
||||
self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
|
||||
|
||||
@unittest.skipUnless(
|
||||
"microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES,
|
||||
"Microsites not implemented in this environment"
|
||||
)
|
||||
def test_profile_saved_no_optional_fields(self):
|
||||
profile = self.create_account_and_fetch_profile()
|
||||
self.assertEqual(profile.name, self.params["name"])
|
||||
self.assertEqual(profile.level_of_education, "")
|
||||
self.assertEqual(profile.gender, "")
|
||||
self.assertEqual(profile.mailing_address, "")
|
||||
self.assertEqual(profile.city, "")
|
||||
self.assertEqual(profile.country, "")
|
||||
self.assertEqual(profile.goals, "")
|
||||
self.assertEqual(
|
||||
profile.get_meta(),
|
||||
{
|
||||
"extra1": "",
|
||||
"extra2": "",
|
||||
}
|
||||
)
|
||||
self.assertIsNone(profile.year_of_birth)
|
||||
|
||||
@unittest.skipUnless(
|
||||
"microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES,
|
||||
"Microsites not implemented in this environment"
|
||||
)
|
||||
@override_settings(LMS_SEGMENT_KEY="testkey")
|
||||
@mock.patch('openedx.core.djangoapps.user_authn.views.register.analytics.track')
|
||||
@mock.patch('openedx.core.djangoapps.user_authn.views.register.analytics.identify')
|
||||
def test_segment_tracking(self, mock_segment_identify, _):
|
||||
year = datetime.now().year
|
||||
year_of_birth = year - 14
|
||||
self.params.update({
|
||||
"level_of_education": "a",
|
||||
"gender": "o",
|
||||
"mailing_address": "123 Example Rd",
|
||||
"city": "Exampleton",
|
||||
"country": "US",
|
||||
"goals": "To test this feature",
|
||||
"year_of_birth": str(year_of_birth),
|
||||
"extra1": "extra_value1",
|
||||
"extra2": "extra_value2",
|
||||
})
|
||||
|
||||
expected_payload = {
|
||||
'email': self.params['email'],
|
||||
'username': self.params['username'],
|
||||
'name': self.params['name'],
|
||||
'age': 13,
|
||||
'yearOfBirth': year_of_birth,
|
||||
'education': 'Associate degree',
|
||||
'address': self.params['mailing_address'],
|
||||
'gender': 'Other/Prefer Not to Say',
|
||||
'country': self.params['country'],
|
||||
}
|
||||
|
||||
profile = self.create_account_and_fetch_profile()
|
||||
|
||||
mock_segment_identify.assert_called_with(profile.user.id, expected_payload)
|
||||
|
||||
@unittest.skipUnless(
|
||||
"microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES,
|
||||
"Microsites not implemented in this environment"
|
||||
)
|
||||
def test_profile_saved_all_optional_fields(self):
|
||||
self.params.update({
|
||||
"level_of_education": "a",
|
||||
"gender": "o",
|
||||
"mailing_address": "123 Example Rd",
|
||||
"city": "Exampleton",
|
||||
"country": "US",
|
||||
"goals": "To test this feature",
|
||||
"year_of_birth": "2015",
|
||||
"extra1": "extra_value1",
|
||||
"extra2": "extra_value2",
|
||||
})
|
||||
profile = self.create_account_and_fetch_profile()
|
||||
self.assertEqual(profile.level_of_education, "a")
|
||||
self.assertEqual(profile.gender, "o")
|
||||
self.assertEqual(profile.mailing_address, "123 Example Rd")
|
||||
self.assertEqual(profile.city, "Exampleton")
|
||||
self.assertEqual(profile.country, "US")
|
||||
self.assertEqual(profile.goals, "To test this feature")
|
||||
self.assertEqual(
|
||||
profile.get_meta(),
|
||||
{
|
||||
"extra1": "extra_value1",
|
||||
"extra2": "extra_value2",
|
||||
}
|
||||
)
|
||||
self.assertEqual(profile.year_of_birth, 2015)
|
||||
|
||||
@unittest.skipUnless(
|
||||
"microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES,
|
||||
"Microsites not implemented in this environment"
|
||||
)
|
||||
def test_profile_saved_empty_optional_fields(self):
|
||||
self.params.update({
|
||||
"level_of_education": "",
|
||||
"gender": "",
|
||||
"mailing_address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"goals": "",
|
||||
"year_of_birth": "",
|
||||
"extra1": "",
|
||||
"extra2": "",
|
||||
})
|
||||
profile = self.create_account_and_fetch_profile()
|
||||
self.assertEqual(profile.level_of_education, "")
|
||||
self.assertEqual(profile.gender, "")
|
||||
self.assertEqual(profile.mailing_address, "")
|
||||
self.assertEqual(profile.city, "")
|
||||
self.assertEqual(profile.country, "")
|
||||
self.assertEqual(profile.goals, "")
|
||||
self.assertEqual(
|
||||
profile.get_meta(),
|
||||
{"extra1": "", "extra2": ""}
|
||||
)
|
||||
self.assertEqual(profile.year_of_birth, None)
|
||||
|
||||
def test_profile_year_of_birth_non_integer(self):
|
||||
self.params["year_of_birth"] = "not_an_integer"
|
||||
profile = self.create_account_and_fetch_profile()
|
||||
self.assertIsNone(profile.year_of_birth)
|
||||
|
||||
def base_extauth_bypass_sending_activation_email(self, bypass_activation_email):
|
||||
"""
|
||||
Tests user creation without sending activation email when
|
||||
doing external auth
|
||||
"""
|
||||
|
||||
request = self.request_factory.post(self.url, self.params)
|
||||
request.site = self.site
|
||||
# now indicate we are doing ext_auth by setting 'ExternalAuthMap' in the session.
|
||||
request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session
|
||||
extauth = ExternalAuthMap(external_id='withmap@stanford.edu',
|
||||
external_email='withmap@stanford.edu',
|
||||
internal_password=self.params['password'],
|
||||
external_domain='shib:https://idp.stanford.edu/')
|
||||
request.session['ExternalAuthMap'] = extauth
|
||||
request.user = AnonymousUser()
|
||||
|
||||
with mock.patch('edxmako.request_context.get_current_request', return_value=request):
|
||||
with mock.patch('django.core.mail.send_mail') as mock_send_mail:
|
||||
create_account(request)
|
||||
|
||||
# check that send_mail is called
|
||||
if bypass_activation_email:
|
||||
self.assertFalse(mock_send_mail.called)
|
||||
else:
|
||||
self.assertTrue(mock_send_mail.called)
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
@mock.patch.dict(settings.FEATURES,
|
||||
{'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'AUTOMATIC_AUTH_FOR_TESTING': False})
|
||||
def test_extauth_bypass_sending_activation_email_with_bypass(self):
|
||||
"""
|
||||
Tests user creation without sending activation email when
|
||||
settings.FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH']=True and doing external auth
|
||||
"""
|
||||
self.base_extauth_bypass_sending_activation_email(True)
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
@mock.patch.dict(settings.FEATURES,
|
||||
{'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False, 'AUTOMATIC_AUTH_FOR_TESTING': False})
|
||||
def test_extauth_bypass_sending_activation_email_without_bypass_1(self):
|
||||
"""
|
||||
Tests user creation without sending activation email when
|
||||
settings.FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH']=False and doing external auth
|
||||
"""
|
||||
self.base_extauth_bypass_sending_activation_email(False)
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
@mock.patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
'AUTOMATIC_AUTH_FOR_TESTING': False, 'SKIP_EMAIL_VALIDATION': True})
|
||||
def test_extauth_bypass_sending_activation_email_without_bypass_2(self):
|
||||
"""
|
||||
Tests user creation without sending activation email when
|
||||
settings.FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH']=False and doing external auth
|
||||
"""
|
||||
self.base_extauth_bypass_sending_activation_email(True)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_discussions_email_digest_pref(self, digest_enabled):
|
||||
with mock.patch.dict("student.models.settings.FEATURES", {"ENABLE_DISCUSSION_EMAIL_DIGEST": digest_enabled}):
|
||||
response = self.client.post(self.url, self.params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
user = User.objects.get(username=self.username)
|
||||
preference = get_user_preference(user, NOTIFICATION_PREF_KEY)
|
||||
if digest_enabled:
|
||||
self.assertIsNotNone(preference)
|
||||
else:
|
||||
self.assertIsNone(preference)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_affiliate_referral_attribution(self):
|
||||
"""
|
||||
Verify that a referral attribution is recorded if an affiliate
|
||||
cookie is present upon a new user's registration.
|
||||
"""
|
||||
affiliate_id = 'test-partner'
|
||||
self.client.cookies[settings.AFFILIATE_COOKIE_NAME] = affiliate_id
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertEqual(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID), affiliate_id)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_utm_referral_attribution(self):
|
||||
"""
|
||||
Verify that a referral attribution is recorded if an affiliate
|
||||
cookie is present upon a new user's registration.
|
||||
"""
|
||||
utm_cookie_name = 'edx.test.utm'
|
||||
with mock.patch('student.models.RegistrationCookieConfiguration.current') as config:
|
||||
instance = config.return_value
|
||||
instance.utm_cookie_name = utm_cookie_name
|
||||
|
||||
timestamp = 1475521816879
|
||||
utm_cookie = {
|
||||
'utm_source': 'test-source',
|
||||
'utm_medium': 'test-medium',
|
||||
'utm_campaign': 'test-campaign',
|
||||
'utm_term': 'test-term',
|
||||
'utm_content': 'test-content',
|
||||
'created_at': timestamp
|
||||
}
|
||||
|
||||
created_at = datetime.fromtimestamp(timestamp / float(1000), tz=pytz.UTC)
|
||||
|
||||
self.client.cookies[utm_cookie_name] = json.dumps(utm_cookie)
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source')),
|
||||
utm_cookie.get('utm_source')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_medium')),
|
||||
utm_cookie.get('utm_medium')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_campaign')),
|
||||
utm_cookie.get('utm_campaign')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_term')),
|
||||
utm_cookie.get('utm_term')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_content')),
|
||||
utm_cookie.get('utm_content')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT),
|
||||
str(created_at)
|
||||
)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_no_referral(self):
|
||||
"""Verify that no referral is recorded when a cookie is not present."""
|
||||
utm_cookie_name = 'edx.test.utm'
|
||||
with mock.patch('student.models.RegistrationCookieConfiguration.current') as config:
|
||||
instance = config.return_value
|
||||
instance.utm_cookie_name = utm_cookie_name
|
||||
|
||||
self.assertIsNone(self.client.cookies.get(settings.AFFILIATE_COOKIE_NAME))
|
||||
self.assertIsNone(self.client.cookies.get(utm_cookie_name))
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_medium')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_campaign')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_term')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_content')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT))
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_incomplete_utm_referral(self):
|
||||
"""Verify that no referral is recorded when a cookie is not present."""
|
||||
utm_cookie_name = 'edx.test.utm'
|
||||
with mock.patch('student.models.RegistrationCookieConfiguration.current') as config:
|
||||
instance = config.return_value
|
||||
instance.utm_cookie_name = utm_cookie_name
|
||||
|
||||
utm_cookie = {
|
||||
'utm_source': 'test-source',
|
||||
'utm_medium': 'test-medium',
|
||||
# No campaign
|
||||
'utm_term': 'test-term',
|
||||
'utm_content': 'test-content',
|
||||
# No created at
|
||||
}
|
||||
|
||||
self.client.cookies[utm_cookie_name] = json.dumps(utm_cookie)
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source')),
|
||||
utm_cookie.get('utm_source')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_medium')),
|
||||
utm_cookie.get('utm_medium')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_term')),
|
||||
utm_cookie.get('utm_term')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_content')),
|
||||
utm_cookie.get('utm_content')
|
||||
)
|
||||
self.assertIsNone(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_campaign'))
|
||||
)
|
||||
self.assertIsNone(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT)
|
||||
)
|
||||
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", mock.Mock(return_value=False))
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_create_account_prevent_auth_user_writes(self):
|
||||
with waffle().override(PREVENT_AUTH_USER_WRITES, True):
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_created_on_site_user_attribute_set(self):
|
||||
profile = self.create_account_and_fetch_profile(host=self.site.domain)
|
||||
self.assertEqual(UserAttribute.get_user_attribute(profile.user, 'created_on_site'), self.site.domain)
|
||||
|
||||
@ddt.data(
|
||||
(
|
||||
False, False, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
},
|
||||
False # Do not skip activation email for normal scenario.
|
||||
),
|
||||
(
|
||||
False, False, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': True, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
},
|
||||
True # Skip activation email when `SKIP_EMAIL_VALIDATION` FEATURE flag is active.
|
||||
),
|
||||
(
|
||||
False, False, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': True,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
},
|
||||
True # Skip activation email when `AUTOMATIC_AUTH_FOR_TESTING` FEATURE flag is active.
|
||||
),
|
||||
(
|
||||
True, False, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True,
|
||||
},
|
||||
True # Skip activation email for external auth scenario.
|
||||
),
|
||||
(
|
||||
False, False, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True,
|
||||
},
|
||||
False # Do not skip activation email when `BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH` feature flag is set
|
||||
# but it is not external auth scenario.
|
||||
),
|
||||
(
|
||||
False, True, get_mock_pipeline_data(),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
},
|
||||
True # Skip activation email if `skip_email_verification` is set for third party authentication.
|
||||
),
|
||||
(
|
||||
False, False, get_mock_pipeline_data(email='invalid@yopmail.com'),
|
||||
{
|
||||
'SKIP_EMAIL_VALIDATION': False, 'AUTOMATIC_AUTH_FOR_TESTING': False,
|
||||
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False,
|
||||
},
|
||||
False # Send activation email when `skip_email_verification` is not set.
|
||||
)
|
||||
)
|
||||
@ddt.unpack
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_should_skip_activation_email(
|
||||
self, do_external_auth, skip_email_verification, running_pipeline, feature_overrides, expected,
|
||||
):
|
||||
"""
|
||||
Test `skip_activation_email` works as expected.
|
||||
"""
|
||||
third_party_provider = third_party_auth_factory.SAMLProviderConfigFactory(
|
||||
skip_email_verification=skip_email_verification,
|
||||
)
|
||||
user = UserFactory(username=TEST_USERNAME, email=TEST_EMAIL)
|
||||
|
||||
with override_settings(FEATURES=dict(settings.FEATURES, **feature_overrides)):
|
||||
result = _skip_activation_email(
|
||||
user=user,
|
||||
do_external_auth=do_external_auth,
|
||||
running_pipeline=running_pipeline,
|
||||
third_party_provider=third_party_provider
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCreateAccountValidation(TestCase):
|
||||
"""
|
||||
Test validation of various parameters in the create_account view
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestCreateAccountValidation, self).setUp()
|
||||
self.url = reverse("create_account")
|
||||
self.minimal_params = {
|
||||
"username": "test_username",
|
||||
"email": "test_email@example.com",
|
||||
"password": "test_password",
|
||||
"name": "Test Name",
|
||||
"honor_code": "true",
|
||||
"terms_of_service": "true",
|
||||
}
|
||||
|
||||
def assert_success(self, params):
|
||||
"""
|
||||
Request account creation with the given params and assert that the
|
||||
response properly indicates success
|
||||
"""
|
||||
response = self.client.post(self.url, params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response_data = json.loads(response.content)
|
||||
self.assertTrue(response_data["success"])
|
||||
|
||||
def assert_error(self, params, expected_field, expected_value):
|
||||
"""
|
||||
Request account creation with the given params and assert that the
|
||||
response properly indicates an error with the given field and value
|
||||
"""
|
||||
response = self.client.post(self.url, params)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response_data = json.loads(response.content)
|
||||
self.assertFalse(response_data["success"])
|
||||
self.assertEqual(response_data["field"], expected_field)
|
||||
self.assertEqual(response_data["value"], expected_value)
|
||||
|
||||
def test_minimal_success(self):
|
||||
self.assert_success(self.minimal_params)
|
||||
|
||||
def test_username(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_username_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "username", expected_error)
|
||||
|
||||
# Missing
|
||||
del params["username"]
|
||||
assert_username_error(USERNAME_BAD_LENGTH_MSG)
|
||||
|
||||
# Empty, too short
|
||||
for username in ["", "a"]:
|
||||
params["username"] = username
|
||||
assert_username_error(USERNAME_BAD_LENGTH_MSG)
|
||||
|
||||
# Too long
|
||||
params["username"] = "this_username_has_31_characters"
|
||||
assert_username_error(USERNAME_BAD_LENGTH_MSG)
|
||||
|
||||
# Invalid
|
||||
params["username"] = "invalid username"
|
||||
assert_username_error(str(USERNAME_INVALID_CHARS_ASCII))
|
||||
|
||||
def test_email(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_email_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "email", expected_error)
|
||||
|
||||
# Missing
|
||||
del params["email"]
|
||||
assert_email_error("A properly formatted e-mail is required")
|
||||
|
||||
# Empty, too short
|
||||
for email in ["", "a"]:
|
||||
params["email"] = email
|
||||
assert_email_error("A properly formatted e-mail is required")
|
||||
|
||||
# Too long
|
||||
params["email"] = '{email}@example.com'.format(
|
||||
email='this_email_address_has_254_characters_in_it_so_it_is_unacceptable' * 4
|
||||
)
|
||||
|
||||
# Assert that we get error when email has more than 254 characters.
|
||||
self.assertGreater(len(params['email']), 254)
|
||||
assert_email_error("Email cannot be more than 254 characters long")
|
||||
|
||||
# Valid Email
|
||||
params["email"] = "student@edx.com"
|
||||
# Assert success on valid email
|
||||
self.assertLess(len(params["email"]), 254)
|
||||
self.assert_success(params)
|
||||
|
||||
# Invalid
|
||||
params["email"] = "not_an_email_address"
|
||||
assert_email_error("A properly formatted e-mail is required")
|
||||
|
||||
@override_settings(
|
||||
REGISTRATION_EMAIL_PATTERNS_ALLOWED=[
|
||||
r'.*@edx.org', # Naive regex omitting '^', '$' and '\.' should still work.
|
||||
r'^.*@(.*\.)?example\.com$',
|
||||
r'^(^\w+\.\w+)@school.tld$',
|
||||
]
|
||||
)
|
||||
@ddt.data(
|
||||
('bob@we-are.bad', False),
|
||||
('bob@edx.org.we-are.bad', False),
|
||||
('staff@edx.org', True),
|
||||
('student@example.com', True),
|
||||
('student@sub.example.com', True),
|
||||
('mr.teacher@school.tld', True),
|
||||
('student1234@school.tld', False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_email_pattern_requirements(self, email, expect_success):
|
||||
"""
|
||||
Test the REGISTRATION_EMAIL_PATTERNS_ALLOWED setting, a feature which
|
||||
can be used to only allow people register if their email matches a
|
||||
against a whitelist of regexs.
|
||||
"""
|
||||
params = dict(self.minimal_params)
|
||||
params["email"] = email
|
||||
if expect_success:
|
||||
self.assert_success(params)
|
||||
else:
|
||||
self.assert_error(params, "email", "Unauthorized email address.")
|
||||
|
||||
def test_password(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_password_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "password", expected_error)
|
||||
|
||||
# Missing
|
||||
del params["password"]
|
||||
assert_password_error("A valid password is required")
|
||||
|
||||
# Empty, too short
|
||||
for password in ["", "a"]:
|
||||
params["password"] = password
|
||||
assert_password_error("A valid password is required")
|
||||
|
||||
# Password policy is tested elsewhere
|
||||
|
||||
# Matching username
|
||||
params["username"] = params["password"] = "test_username_and_password"
|
||||
assert_password_error("Password cannot be the same as the username.")
|
||||
|
||||
def test_name(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_name_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "name", expected_error)
|
||||
|
||||
# Missing
|
||||
del params["name"]
|
||||
assert_name_error("Your legal name must be a minimum of two characters long")
|
||||
|
||||
# Empty, too short
|
||||
for name in ["", "a"]:
|
||||
params["name"] = name
|
||||
assert_name_error("Your legal name must be a minimum of two characters long")
|
||||
|
||||
def test_honor_code(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_honor_code_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "honor_code", expected_error)
|
||||
|
||||
with override_settings(REGISTRATION_EXTRA_FIELDS={"honor_code": "required"}):
|
||||
# Missing
|
||||
del params["honor_code"]
|
||||
assert_honor_code_error("To enroll, you must follow the honor code.")
|
||||
|
||||
# Empty, invalid
|
||||
for honor_code in ["", "false", "not_boolean"]:
|
||||
params["honor_code"] = honor_code
|
||||
assert_honor_code_error("To enroll, you must follow the honor code.")
|
||||
|
||||
# True
|
||||
params["honor_code"] = "tRUe"
|
||||
self.assert_success(params)
|
||||
|
||||
with override_settings(REGISTRATION_EXTRA_FIELDS={"honor_code": "optional"}):
|
||||
# Missing
|
||||
del params["honor_code"]
|
||||
# Need to change username/email because user was created above
|
||||
params["username"] = "another_test_username"
|
||||
params["email"] = "another_test_email@example.com"
|
||||
self.assert_success(params)
|
||||
|
||||
def test_terms_of_service(self):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_terms_of_service_error(expected_error):
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, "terms_of_service", expected_error)
|
||||
|
||||
# Missing
|
||||
del params["terms_of_service"]
|
||||
assert_terms_of_service_error("You must accept the terms of service.")
|
||||
|
||||
# Empty, invalid
|
||||
for terms_of_service in ["", "false", "not_boolean"]:
|
||||
params["terms_of_service"] = terms_of_service
|
||||
assert_terms_of_service_error("You must accept the terms of service.")
|
||||
|
||||
# True
|
||||
params["terms_of_service"] = "tRUe"
|
||||
self.assert_success(params)
|
||||
|
||||
@ddt.data(
|
||||
("level_of_education", 1, "A level of education is required"),
|
||||
("gender", 1, "Your gender is required"),
|
||||
("year_of_birth", 2, "Your year of birth is required"),
|
||||
("mailing_address", 2, "Your mailing address is required"),
|
||||
("goals", 2, "A description of your goals is required"),
|
||||
("city", 2, "A city is required"),
|
||||
("country", 2, "A country is required"),
|
||||
("custom_field", 2, "You are missing one or more required fields")
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_extra_fields(self, field, min_length, expected_error):
|
||||
params = dict(self.minimal_params)
|
||||
|
||||
def assert_extra_field_error():
|
||||
"""
|
||||
Assert that requesting account creation results in the expected
|
||||
error
|
||||
"""
|
||||
self.assert_error(params, field, expected_error)
|
||||
|
||||
with override_settings(REGISTRATION_EXTRA_FIELDS={field: "required"}):
|
||||
# Missing
|
||||
assert_extra_field_error()
|
||||
|
||||
# Empty
|
||||
params[field] = ""
|
||||
assert_extra_field_error()
|
||||
|
||||
# Too short
|
||||
if min_length > 1:
|
||||
params[field] = "a"
|
||||
assert_extra_field_error()
|
||||
|
||||
|
||||
@mock.patch.dict("student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
@mock.patch("lms.lib.comment_client.User.base_url", TEST_CS_URL)
|
||||
@mock.patch("lms.lib.comment_client.utils.requests.request", return_value=mock.Mock(status_code=200, text='{}'))
|
||||
class TestCreateCommentsServiceUser(TransactionTestCase):
|
||||
""" Tests for creating comments service user. """
|
||||
|
||||
def setUp(self):
|
||||
super(TestCreateCommentsServiceUser, self).setUp()
|
||||
self.username = "test_user"
|
||||
self.url = reverse("create_account")
|
||||
self.params = {
|
||||
"username": self.username,
|
||||
"email": "test@example.org",
|
||||
"password": "testpass",
|
||||
"name": "Test User",
|
||||
"honor_code": "true",
|
||||
"terms_of_service": "true",
|
||||
}
|
||||
|
||||
config = ForumsConfig.current()
|
||||
config.enabled = True
|
||||
config.save()
|
||||
|
||||
def test_cs_user_created(self, request):
|
||||
"If user account creation succeeds, we should create a comments service user"
|
||||
response = self.client.post(self.url, self.params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(request.called)
|
||||
args, kwargs = request.call_args
|
||||
self.assertEqual(args[0], 'put')
|
||||
self.assertTrue(args[1].startswith(TEST_CS_URL))
|
||||
self.assertEqual(kwargs['data']['username'], self.params['username'])
|
||||
|
||||
@mock.patch("student.models.Registration.register", side_effect=Exception)
|
||||
def test_cs_user_not_created(self, register, request):
|
||||
"If user account creation fails, we should not create a comments service user"
|
||||
try:
|
||||
self.client.post(self.url, self.params)
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
User.objects.get(username=self.username)
|
||||
self.assertTrue(register.called)
|
||||
self.assertFalse(request.called)
|
||||
|
||||
|
||||
class TestUnicodeUsername(TestCase):
|
||||
"""
|
||||
Test for Unicode usernames which is an optional feature.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestUnicodeUsername, self).setUp()
|
||||
self.url = reverse('create_account')
|
||||
|
||||
# The word below reads "Omar II", in Arabic. It also contains a space and
|
||||
# an Eastern Arabic Number another option is to use the Esperanto fake
|
||||
# language but this was used instead to test non-western letters.
|
||||
self.username = u'عمر ٢'
|
||||
|
||||
self.url_params = {
|
||||
'username': self.username,
|
||||
'email': 'unicode_user@example.com',
|
||||
"password": "testpass",
|
||||
'name': 'unicode_user',
|
||||
'terms_of_service': 'true',
|
||||
'honor_code': 'true',
|
||||
}
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False})
|
||||
def test_with_feature_disabled(self):
|
||||
"""
|
||||
Ensures backward-compatible defaults.
|
||||
"""
|
||||
response = self.client.post(self.url, self.url_params)
|
||||
|
||||
self.assertEquals(response.status_code, 400)
|
||||
obj = json.loads(response.content)
|
||||
self.assertEquals(USERNAME_INVALID_CHARS_ASCII, obj['value'])
|
||||
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
User.objects.get(email=self.url_params['email'])
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True})
|
||||
def test_with_feature_enabled(self):
|
||||
response = self.client.post(self.url, self.url_params)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertTrue(User.objects.get(email=self.url_params['email']))
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True})
|
||||
def test_special_chars_with_feature_enabled(self):
|
||||
"""
|
||||
Ensures that special chars are still prevented.
|
||||
"""
|
||||
|
||||
invalid_params = self.url_params.copy()
|
||||
invalid_params['username'] = '**john**'
|
||||
|
||||
response = self.client.post(self.url, invalid_params)
|
||||
self.assertEquals(response.status_code, 400)
|
||||
|
||||
obj = json.loads(response.content)
|
||||
self.assertEquals(USERNAME_INVALID_CHARS_UNICODE, obj['value'])
|
||||
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
User.objects.get(email=self.url_params['email'])
|
||||
929
openedx/core/djangoapps/user_authn/views/tests/test_views.py
Normal file
929
openedx/core/djangoapps/user_authn/views/tests/test_views.py
Normal file
@@ -0,0 +1,929 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for user authn views. """
|
||||
|
||||
from http.cookies import SimpleCookie
|
||||
import logging
|
||||
import re
|
||||
from unittest import skipUnless
|
||||
from urllib import urlencode
|
||||
|
||||
import ddt
|
||||
import mock
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core import mail
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory
|
||||
from oauth2_provider.models import AccessToken as dot_access_token
|
||||
from oauth2_provider.models import RefreshToken as dot_refresh_token
|
||||
from provider.oauth2.models import AccessToken as dop_access_token
|
||||
from provider.oauth2.models import RefreshToken as dop_refresh_token
|
||||
from testfixtures import LogCapture
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.user_authn.views.login_form import login_and_registration_form
|
||||
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
|
||||
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
|
||||
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
|
||||
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
|
||||
from openedx.core.djangoapps.user_api.errors import UserAPIInternalError
|
||||
from openedx.core.djangolib.js_utils import dump_js_escaped_json
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline
|
||||
from util.testing import UrlResetMixin
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
LOGGER_NAME = 'audit'
|
||||
User = get_user_model() # pylint:disable=invalid-name
|
||||
|
||||
FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL = settings.FEATURES.copy()
|
||||
FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL['ENABLE_PASSWORD_RESET_FAILURE_EMAIL'] = True
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
|
||||
""" Tests for views that update the user's account information. """
|
||||
|
||||
USERNAME = u"heisenberg"
|
||||
ALTERNATE_USERNAME = u"walt"
|
||||
OLD_PASSWORD = u"ḅḷüëṡḳÿ"
|
||||
NEW_PASSWORD = u"🄱🄸🄶🄱🄻🅄🄴"
|
||||
OLD_EMAIL = u"walter@graymattertech.com"
|
||||
NEW_EMAIL = u"walt@savewalterwhite.com"
|
||||
|
||||
INVALID_ATTEMPTS = 100
|
||||
INVALID_KEY = u"123abc"
|
||||
|
||||
URLCONF_MODULES = ['student_accounts.urls']
|
||||
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
def setUp(self):
|
||||
super(UserAccountUpdateTest, self).setUp()
|
||||
|
||||
# Create/activate a new account
|
||||
activation_key = create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
|
||||
activate_account(activation_key)
|
||||
|
||||
# Login
|
||||
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
|
||||
def test_password_change(self):
|
||||
# Request a password change while logged in, simulating
|
||||
# use of the password reset link from the account page
|
||||
response = self._change_password()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that an email was sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
# Retrieve the activation link from the email body
|
||||
email_body = mail.outbox[0].body
|
||||
result = re.search(r'(?P<url>https?://[^\s]+)', email_body)
|
||||
self.assertIsNot(result, None)
|
||||
activation_link = result.group('url')
|
||||
|
||||
# Visit the activation link
|
||||
response = self.client.get(activation_link)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Submit a new password and follow the redirect to the success page
|
||||
response = self.client.post(
|
||||
activation_link,
|
||||
# These keys are from the form on the current password reset confirmation page.
|
||||
{'new_password1': self.NEW_PASSWORD, 'new_password2': self.NEW_PASSWORD},
|
||||
follow=True
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Your password has been reset.")
|
||||
|
||||
# Log the user out to clear session data
|
||||
self.client.logout()
|
||||
|
||||
# Verify that the new password can be used to log in
|
||||
result = self.client.login(username=self.USERNAME, password=self.NEW_PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Try reusing the activation link to change the password again
|
||||
# Visit the activation link again.
|
||||
response = self.client.get(activation_link)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "This password reset link is invalid. It may have been used already.")
|
||||
|
||||
self.client.logout()
|
||||
|
||||
# Verify that the old password cannot be used to log in
|
||||
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify that the new password continues to be valid
|
||||
result = self.client.login(username=self.USERNAME, password=self.NEW_PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_password_change_failure(self):
|
||||
with mock.patch('openedx.core.djangoapps.user_api.accounts.api.request_password_change',
|
||||
side_effect=UserAPIInternalError):
|
||||
self._change_password()
|
||||
self.assertRaises(UserAPIInternalError)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL)
|
||||
def test_password_reset_failure_email(self):
|
||||
"""Test that a password reset failure email notification is sent, when enabled."""
|
||||
# Log the user out
|
||||
self.client.logout()
|
||||
|
||||
bad_email = 'doesnotexist@example.com'
|
||||
response = self._change_password(email=bad_email)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that an email was sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
# Verify that the body contains the failed password reset message
|
||||
sent_message = mail.outbox[0]
|
||||
text_body = sent_message.body
|
||||
html_body = sent_message.alternatives[0][0]
|
||||
|
||||
for email_body in [text_body, html_body]:
|
||||
msg = 'However, there is currently no user account associated with your email address: {email}'.format(
|
||||
email=bad_email
|
||||
)
|
||||
|
||||
assert u'reset for your user account at {}'.format(settings.PLATFORM_NAME) in email_body
|
||||
assert 'password_reset_confirm' not in email_body, 'The link should not be added if user was not found'
|
||||
assert msg in email_body
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_password_change_logged_out(self, send_email):
|
||||
# Log the user out
|
||||
self.client.logout()
|
||||
|
||||
# Request a password change while logged out, simulating
|
||||
# use of the password reset link from the login page
|
||||
if send_email:
|
||||
response = self._change_password(email=self.OLD_EMAIL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
else:
|
||||
# Don't send an email in the POST data, simulating
|
||||
# its (potentially accidental) omission in the POST
|
||||
# data sent from the login page
|
||||
response = self._change_password()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_access_token_invalidation_logged_out(self):
|
||||
self.client.logout()
|
||||
user = User.objects.get(email=self.OLD_EMAIL)
|
||||
self._create_dop_tokens(user)
|
||||
self._create_dot_tokens(user)
|
||||
response = self._change_password(email=self.OLD_EMAIL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assert_access_token_destroyed(user)
|
||||
|
||||
def test_access_token_invalidation_logged_in(self):
|
||||
user = User.objects.get(email=self.OLD_EMAIL)
|
||||
self._create_dop_tokens(user)
|
||||
self._create_dot_tokens(user)
|
||||
response = self._change_password()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assert_access_token_destroyed(user)
|
||||
|
||||
def test_password_change_inactive_user(self):
|
||||
# Log out the user created during test setup
|
||||
self.client.logout()
|
||||
|
||||
# Create a second user, but do not activate it
|
||||
create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
|
||||
|
||||
# Send the view the email address tied to the inactive user
|
||||
response = self._change_password(email=self.NEW_EMAIL)
|
||||
|
||||
# Expect that the activation email is still sent,
|
||||
# since the user may have lost the original activation email.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_password_change_no_user(self):
|
||||
# Log out the user created during test setup
|
||||
self.client.logout()
|
||||
|
||||
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
|
||||
# Send the view an email address not tied to any user
|
||||
response = self._change_password(email=self.NEW_EMAIL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
logger.check((LOGGER_NAME, 'INFO', 'Invalid password reset attempt'))
|
||||
|
||||
def test_password_change_rate_limited(self):
|
||||
# Log out the user created during test setup, to prevent the view from
|
||||
# selecting the logged-in user's email address over the email provided
|
||||
# in the POST data
|
||||
self.client.logout()
|
||||
|
||||
# Make many consecutive bad requests in an attempt to trigger the rate limiter
|
||||
for __ in xrange(self.INVALID_ATTEMPTS):
|
||||
self._change_password(email=self.NEW_EMAIL)
|
||||
|
||||
response = self._change_password(email=self.NEW_EMAIL)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ddt.data(
|
||||
('post', 'password_change_request', []),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_require_http_method(self, correct_method, url_name, args):
|
||||
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
|
||||
url = reverse(url_name, args=args)
|
||||
|
||||
for method in wrong_methods:
|
||||
response = getattr(self.client, method)(url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def _change_password(self, email=None):
|
||||
"""Request to change the user's password. """
|
||||
data = {}
|
||||
|
||||
if email:
|
||||
data['email'] = email
|
||||
|
||||
return self.client.post(path=reverse('password_change_request'), data=data)
|
||||
|
||||
def _create_dop_tokens(self, user=None):
|
||||
"""Create dop access token for given user if user provided else for default user."""
|
||||
if not user:
|
||||
user = User.objects.get(email=self.OLD_EMAIL)
|
||||
|
||||
client = ClientFactory()
|
||||
access_token = AccessTokenFactory(user=user, client=client)
|
||||
RefreshTokenFactory(user=user, client=client, access_token=access_token)
|
||||
|
||||
def _create_dot_tokens(self, user=None):
|
||||
"""Create dop access token for given user if user provided else for default user."""
|
||||
if not user:
|
||||
user = User.objects.get(email=self.OLD_EMAIL)
|
||||
|
||||
application = dot_factories.ApplicationFactory(user=user)
|
||||
access_token = dot_factories.AccessTokenFactory(user=user, application=application)
|
||||
dot_factories.RefreshTokenFactory(user=user, application=application, access_token=access_token)
|
||||
|
||||
def assert_access_token_destroyed(self, user):
|
||||
"""Assert all access tokens are destroyed."""
|
||||
self.assertFalse(dot_access_token.objects.filter(user=user).exists())
|
||||
self.assertFalse(dot_refresh_token.objects.filter(user=user).exists())
|
||||
self.assertFalse(dop_access_token.objects.filter(user=user).exists())
|
||||
self.assertFalse(dop_refresh_token.objects.filter(user=user).exists())
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
class LoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
|
||||
""" Tests for the student account views that update the user's account information. """
|
||||
shard = 7
|
||||
USERNAME = "bob"
|
||||
EMAIL = "bob@example.com"
|
||||
PASSWORD = "password"
|
||||
|
||||
URLCONF_MODULES = ['openedx.core.djangoapps.embargo']
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(LoginAndRegistrationTest, self).setUp()
|
||||
|
||||
# Several third party auth providers are created for these tests:
|
||||
self.google_provider = self.configure_google_provider(enabled=True, visible=True)
|
||||
self.configure_facebook_provider(enabled=True, visible=True)
|
||||
self.configure_dummy_provider(
|
||||
visible=True,
|
||||
enabled=True,
|
||||
icon_class='',
|
||||
icon_image=SimpleUploadedFile('icon.svg', '<svg><rect width="50" height="100"/></svg>'),
|
||||
)
|
||||
self.hidden_enabled_provider = self.configure_linkedin_provider(
|
||||
visible=False,
|
||||
enabled=True,
|
||||
)
|
||||
self.hidden_disabled_provider = self.configure_azure_ad_provider()
|
||||
|
||||
@ddt.data(
|
||||
("signin_user", "login"),
|
||||
("register_user", "register"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_login_and_registration_form(self, url_name, initial_mode):
|
||||
response = self.client.get(reverse(url_name))
|
||||
expected_data = '"initial_mode": "{mode}"'.format(mode=initial_mode)
|
||||
self.assertContains(response, expected_data)
|
||||
|
||||
@ddt.data("signin_user", "register_user")
|
||||
def test_login_and_registration_form_already_authenticated(self, url_name):
|
||||
# Create/activate a new account and log in
|
||||
activation_key = create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
activate_account(activation_key)
|
||||
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify that we're redirected to the dashboard
|
||||
response = self.client.get(reverse(url_name))
|
||||
self.assertRedirects(response, reverse("dashboard"))
|
||||
|
||||
@ddt.data(
|
||||
(None, "signin_user"),
|
||||
(None, "register_user"),
|
||||
("edx.org", "signin_user"),
|
||||
("edx.org", "register_user"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_login_and_registration_form_signin_not_preserves_params(self, theme, url_name):
|
||||
params = [
|
||||
('course_id', 'edX/DemoX/Demo_Course'),
|
||||
('enrollment_action', 'enroll'),
|
||||
]
|
||||
|
||||
# The response should not have a "Sign In" button with the URL
|
||||
# that preserves the querystring params
|
||||
with with_comprehensive_theme_context(theme):
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
|
||||
expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')]))
|
||||
self.assertNotContains(response, expected_url)
|
||||
|
||||
# Add additional parameters:
|
||||
params = [
|
||||
('course_id', 'edX/DemoX/Demo_Course'),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', CourseMode.DEFAULT_MODE_SLUG),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination')
|
||||
]
|
||||
|
||||
# Verify that this parameter is also preserved
|
||||
with with_comprehensive_theme_context(theme):
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
|
||||
expected_url = '/login?{}'.format(self._finish_auth_url_param(params))
|
||||
self.assertNotContains(response, expected_url)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@ddt.data("signin_user", "register_user")
|
||||
def test_third_party_auth_disabled(self, url_name):
|
||||
response = self.client.get(reverse(url_name))
|
||||
self._assert_third_party_auth_data(response, None, None, [], None)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.api.enterprise_customer_for_request')
|
||||
@ddt.data(
|
||||
("signin_user", None, None, None, False),
|
||||
("register_user", None, None, None, False),
|
||||
("signin_user", "google-oauth2", "Google", None, False),
|
||||
("register_user", "google-oauth2", "Google", None, False),
|
||||
("signin_user", "facebook", "Facebook", None, False),
|
||||
("register_user", "facebook", "Facebook", None, False),
|
||||
("signin_user", "dummy", "Dummy", None, False),
|
||||
("register_user", "dummy", "Dummy", None, False),
|
||||
(
|
||||
"signin_user",
|
||||
"google-oauth2",
|
||||
"Google",
|
||||
{
|
||||
'name': 'FakeName',
|
||||
'logo': 'https://host.com/logo.jpg',
|
||||
'welcome_msg': 'No message'
|
||||
},
|
||||
True
|
||||
)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_third_party_auth(
|
||||
self,
|
||||
url_name,
|
||||
current_backend,
|
||||
current_provider,
|
||||
expected_enterprise_customer_mock_attrs,
|
||||
add_user_details,
|
||||
enterprise_customer_mock_1,
|
||||
enterprise_customer_mock_2
|
||||
):
|
||||
params = [
|
||||
('course_id', 'course-v1:Org+Course+Run'),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', CourseMode.DEFAULT_MODE_SLUG),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
if expected_enterprise_customer_mock_attrs:
|
||||
expected_ec = {
|
||||
'name': expected_enterprise_customer_mock_attrs['name'],
|
||||
'branding_configuration': {
|
||||
'logo': 'https://host.com/logo.jpg',
|
||||
'welcome_message': expected_enterprise_customer_mock_attrs['welcome_msg']
|
||||
}
|
||||
}
|
||||
else:
|
||||
expected_ec = None
|
||||
|
||||
email = None
|
||||
if add_user_details:
|
||||
email = 'test@test.com'
|
||||
enterprise_customer_mock_1.return_value = expected_ec
|
||||
enterprise_customer_mock_2.return_value = expected_ec
|
||||
|
||||
# Simulate a running pipeline
|
||||
if current_backend is not None:
|
||||
pipeline_target = "openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline"
|
||||
with simulate_running_pipeline(pipeline_target, current_backend, email=email):
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
|
||||
# Do NOT simulate a running pipeline
|
||||
else:
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
|
||||
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
|
||||
expected_providers = [
|
||||
{
|
||||
"id": "oa2-dummy",
|
||||
"name": "Dummy",
|
||||
"iconClass": None,
|
||||
"iconImage": settings.MEDIA_URL + "icon.svg",
|
||||
"loginUrl": self._third_party_login_url("dummy", "login", params),
|
||||
"registerUrl": self._third_party_login_url("dummy", "register", params)
|
||||
},
|
||||
{
|
||||
"id": "oa2-facebook",
|
||||
"name": "Facebook",
|
||||
"iconClass": "fa-facebook",
|
||||
"iconImage": None,
|
||||
"loginUrl": self._third_party_login_url("facebook", "login", params),
|
||||
"registerUrl": self._third_party_login_url("facebook", "register", params)
|
||||
},
|
||||
{
|
||||
"id": "oa2-google-oauth2",
|
||||
"name": "Google",
|
||||
"iconClass": "fa-google-plus",
|
||||
"iconImage": None,
|
||||
"loginUrl": self._third_party_login_url("google-oauth2", "login", params),
|
||||
"registerUrl": self._third_party_login_url("google-oauth2", "register", params)
|
||||
},
|
||||
]
|
||||
self._assert_third_party_auth_data(
|
||||
response,
|
||||
current_backend,
|
||||
current_provider,
|
||||
expected_providers,
|
||||
expected_ec,
|
||||
add_user_details
|
||||
)
|
||||
|
||||
def _configure_testshib_provider(self, provider_name, idp_slug):
|
||||
"""
|
||||
Enable and configure the TestShib SAML IdP as a third_party_auth provider.
|
||||
"""
|
||||
kwargs = {}
|
||||
kwargs.setdefault('name', provider_name)
|
||||
kwargs.setdefault('enabled', True)
|
||||
kwargs.setdefault('visible', True)
|
||||
kwargs.setdefault('slug', idp_slug)
|
||||
kwargs.setdefault('entity_id', 'https://idp.testshib.org/idp/shibboleth')
|
||||
kwargs.setdefault('metadata_source', 'https://mock.testshib.org/metadata/testshib-providers.xml')
|
||||
kwargs.setdefault('icon_class', 'fa-university')
|
||||
kwargs.setdefault('attr_email', 'dummy-email-attr')
|
||||
kwargs.setdefault('max_session_length', None)
|
||||
self.configure_saml_provider(**kwargs)
|
||||
|
||||
@mock.patch('django.conf.settings.MESSAGE_STORAGE', 'django.contrib.messages.storage.cookie.CookieStorage')
|
||||
@mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request')
|
||||
@ddt.data(
|
||||
(
|
||||
'signin_user',
|
||||
'tpa-saml',
|
||||
'TestShib',
|
||||
)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_saml_auth_with_error(
|
||||
self,
|
||||
url_name,
|
||||
current_backend,
|
||||
current_provider,
|
||||
enterprise_customer_mock,
|
||||
):
|
||||
params = []
|
||||
request = RequestFactory().get(reverse(url_name), params, HTTP_ACCEPT='text/html')
|
||||
SessionMiddleware().process_request(request)
|
||||
request.user = AnonymousUser()
|
||||
|
||||
self.enable_saml()
|
||||
dummy_idp = 'testshib'
|
||||
self._configure_testshib_provider(current_provider, dummy_idp)
|
||||
enterprise_customer_data = {
|
||||
'uuid': '72416e52-8c77-4860-9584-15e5b06220fb',
|
||||
'name': 'Dummy Enterprise',
|
||||
'identity_provider': dummy_idp,
|
||||
}
|
||||
enterprise_customer_mock.return_value = enterprise_customer_data
|
||||
dummy_error_message = 'Authentication failed: SAML login failed ' \
|
||||
'["invalid_response"] [SAML Response must contain 1 assertion]'
|
||||
|
||||
# Add error message for error in auth pipeline
|
||||
MessageMiddleware().process_request(request)
|
||||
messages.error(request, dummy_error_message, extra_tags='social-auth')
|
||||
|
||||
# Simulate a running pipeline
|
||||
pipeline_response = {
|
||||
'response': {
|
||||
'idp_name': dummy_idp
|
||||
}
|
||||
}
|
||||
pipeline_target = 'openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline'
|
||||
with simulate_running_pipeline(pipeline_target, current_backend, **pipeline_response):
|
||||
with mock.patch('edxmako.request_context.get_current_request', return_value=request):
|
||||
response = login_and_registration_form(request)
|
||||
|
||||
expected_error_message = Text(_(
|
||||
u'We are sorry, you are not authorized to access {platform_name} via this channel. '
|
||||
u'Please contact your learning administrator or manager in order to access {platform_name}.'
|
||||
u'{line_break}{line_break}'
|
||||
u'Error Details:{line_break}{error_message}')
|
||||
).format(
|
||||
platform_name=settings.PLATFORM_NAME,
|
||||
error_message=dummy_error_message,
|
||||
line_break=HTML('<br/>')
|
||||
)
|
||||
self._assert_saml_auth_data_with_error(
|
||||
response,
|
||||
current_backend,
|
||||
current_provider,
|
||||
expected_error_message
|
||||
)
|
||||
|
||||
def test_hinted_login(self):
|
||||
params = [("next", "/courses/something/?tpa_hint=oa2-google-oauth2")]
|
||||
response = self.client.get(reverse('signin_user'), params, HTTP_ACCEPT="text/html")
|
||||
self.assertContains(response, '"third_party_auth_hint": "oa2-google-oauth2"')
|
||||
|
||||
tpa_hint = self.hidden_enabled_provider.provider_id
|
||||
params = [("next", "/courses/something/?tpa_hint={0}".format(tpa_hint))]
|
||||
response = self.client.get(reverse('signin_user'), params, HTTP_ACCEPT="text/html")
|
||||
self.assertContains(response, '"third_party_auth_hint": "{0}"'.format(tpa_hint))
|
||||
|
||||
tpa_hint = self.hidden_disabled_provider.provider_id
|
||||
params = [("next", "/courses/something/?tpa_hint={0}".format(tpa_hint))]
|
||||
response = self.client.get(reverse('signin_user'), params, HTTP_ACCEPT="text/html")
|
||||
self.assertNotIn(response.content, tpa_hint)
|
||||
|
||||
@ddt.data(
|
||||
('signin_user', 'login'),
|
||||
('register_user', 'register'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_hinted_login_dialog_disabled(self, url_name, auth_entry):
|
||||
"""Test that the dialog doesn't show up for hinted logins when disabled. """
|
||||
self.google_provider.skip_hinted_login_dialog = True
|
||||
self.google_provider.save()
|
||||
params = [("next", "/courses/something/?tpa_hint=oa2-google-oauth2")]
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
expected_url = '/auth/login/google-oauth2/?auth_entry={}&next=%2Fcourses'\
|
||||
'%2Fsomething%2F%3Ftpa_hint%3Doa2-google-oauth2'.format(auth_entry)
|
||||
self.assertRedirects(
|
||||
response,
|
||||
expected_url,
|
||||
target_status_code=302
|
||||
)
|
||||
|
||||
@override_settings(FEATURES=dict(settings.FEATURES, THIRD_PARTY_AUTH_HINT='oa2-google-oauth2'))
|
||||
@ddt.data(
|
||||
'signin_user',
|
||||
'register_user',
|
||||
)
|
||||
def test_settings_tpa_hinted_login(self, url_name):
|
||||
"""
|
||||
Ensure that settings.FEATURES['THIRD_PARTY_AUTH_HINT'] can set third_party_auth_hint.
|
||||
"""
|
||||
params = [("next", "/courses/something/")]
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
self.assertContains(response, '"third_party_auth_hint": "oa2-google-oauth2"')
|
||||
|
||||
# THIRD_PARTY_AUTH_HINT can be overridden via the query string
|
||||
tpa_hint = self.hidden_enabled_provider.provider_id
|
||||
params = [("next", "/courses/something/?tpa_hint={0}".format(tpa_hint))]
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
self.assertContains(response, '"third_party_auth_hint": "{0}"'.format(tpa_hint))
|
||||
|
||||
# Even disabled providers in the query string will override THIRD_PARTY_AUTH_HINT
|
||||
tpa_hint = self.hidden_disabled_provider.provider_id
|
||||
params = [("next", "/courses/something/?tpa_hint={0}".format(tpa_hint))]
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
self.assertNotIn(response.content, tpa_hint)
|
||||
|
||||
@override_settings(FEATURES=dict(settings.FEATURES, THIRD_PARTY_AUTH_HINT='oa2-google-oauth2'))
|
||||
@ddt.data(
|
||||
('signin_user', 'login'),
|
||||
('register_user', 'register'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_settings_tpa_hinted_login_dialog_disabled(self, url_name, auth_entry):
|
||||
"""Test that the dialog doesn't show up for hinted logins when disabled via settings.THIRD_PARTY_AUTH_HINT. """
|
||||
self.google_provider.skip_hinted_login_dialog = True
|
||||
self.google_provider.save()
|
||||
params = [("next", "/courses/something/")]
|
||||
response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html")
|
||||
expected_url = '/auth/login/google-oauth2/?auth_entry={}&next=%2Fcourses'\
|
||||
'%2Fsomething%2F%3Ftpa_hint%3Doa2-google-oauth2'.format(auth_entry)
|
||||
self.assertRedirects(
|
||||
response,
|
||||
expected_url,
|
||||
target_status_code=302
|
||||
)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request')
|
||||
@ddt.data(
|
||||
('signin_user', False, None, None),
|
||||
('register_user', False, None, None),
|
||||
('signin_user', True, 'Fake EC', 'http://logo.com/logo.jpg'),
|
||||
('register_user', True, 'Fake EC', 'http://logo.com/logo.jpg'),
|
||||
('signin_user', True, 'Fake EC', None),
|
||||
('register_user', True, 'Fake EC', None),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enterprise_register(self, url_name, ec_present, ec_name, logo_url, mock_get_ec):
|
||||
"""
|
||||
Verify that when an EnterpriseCustomer is received on the login and register views,
|
||||
the appropriate sidebar is rendered.
|
||||
"""
|
||||
if ec_present:
|
||||
mock_get_ec.return_value = {
|
||||
'name': ec_name,
|
||||
'branding_configuration': {'logo': logo_url}
|
||||
}
|
||||
else:
|
||||
mock_get_ec.return_value = None
|
||||
|
||||
response = self.client.get(reverse(url_name), HTTP_ACCEPT="text/html")
|
||||
|
||||
enterprise_sidebar_div_id = u'enterprise-content-container'
|
||||
|
||||
if not ec_present:
|
||||
self.assertNotContains(response, text=enterprise_sidebar_div_id)
|
||||
else:
|
||||
self.assertContains(response, text=enterprise_sidebar_div_id)
|
||||
welcome_message = settings.ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE
|
||||
expected_message = Text(welcome_message).format(
|
||||
start_bold=HTML('<b>'),
|
||||
end_bold=HTML('</b>'),
|
||||
line_break=HTML('<br/>'),
|
||||
enterprise_name=ec_name,
|
||||
platform_name=settings.PLATFORM_NAME,
|
||||
privacy_policy_link_start=HTML("<a href='{pp_url}' target='_blank'>").format(
|
||||
pp_url=settings.MKTG_URLS.get('PRIVACY', 'https://www.edx.org/edx-privacy-policy')
|
||||
),
|
||||
privacy_policy_link_end=HTML("</a>"),
|
||||
)
|
||||
self.assertContains(response, expected_message)
|
||||
if logo_url:
|
||||
self.assertContains(response, logo_url)
|
||||
|
||||
def test_enterprise_cookie_delete(self):
|
||||
"""
|
||||
Test that enterprise cookies are deleted in login/registration views.
|
||||
|
||||
Cookies must be deleted in login/registration views so that *default* login/registration branding
|
||||
is displayed to subsequent requests from non-enterprise customers.
|
||||
"""
|
||||
cookies = SimpleCookie()
|
||||
cookies[settings.ENTERPRISE_CUSTOMER_COOKIE_NAME] = 'test-enterprise-customer'
|
||||
response = self.client.get(reverse('signin_user'), HTTP_ACCEPT="text/html", cookies=cookies)
|
||||
|
||||
self.assertIn(settings.ENTERPRISE_CUSTOMER_COOKIE_NAME, response.cookies)
|
||||
enterprise_cookie = response.cookies[settings.ENTERPRISE_CUSTOMER_COOKIE_NAME]
|
||||
|
||||
self.assertEqual(enterprise_cookie['domain'], settings.BASE_COOKIE_DOMAIN)
|
||||
self.assertEqual(enterprise_cookie.value, '')
|
||||
|
||||
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
|
||||
def test_microsite_uses_old_login_page(self):
|
||||
# Retrieve the login page from a microsite domain
|
||||
# and verify that we're served the old page.
|
||||
resp = self.client.get(
|
||||
reverse("signin_user"),
|
||||
HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME
|
||||
)
|
||||
self.assertContains(resp, "Log into your Test Site Account")
|
||||
self.assertContains(resp, "login-form")
|
||||
|
||||
def test_microsite_uses_old_register_page(self):
|
||||
# Retrieve the register page from a microsite domain
|
||||
# and verify that we're served the old page.
|
||||
resp = self.client.get(
|
||||
reverse("register_user"),
|
||||
HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME
|
||||
)
|
||||
self.assertContains(resp, "Register for Test Site")
|
||||
self.assertContains(resp, "register-form")
|
||||
|
||||
def test_login_registration_xframe_protected(self):
|
||||
resp = self.client.get(
|
||||
reverse("register_user"),
|
||||
{},
|
||||
HTTP_REFERER="http://localhost/iframe"
|
||||
)
|
||||
|
||||
self.assertEqual(resp['X-Frame-Options'], 'DENY')
|
||||
|
||||
self.configure_lti_provider(name='Test', lti_hostname='localhost', lti_consumer_key='test_key', enabled=True)
|
||||
|
||||
resp = self.client.get(
|
||||
reverse("register_user"),
|
||||
HTTP_REFERER="http://localhost/iframe"
|
||||
)
|
||||
|
||||
self.assertEqual(resp['X-Frame-Options'], 'ALLOW')
|
||||
|
||||
def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers, expected_ec,
|
||||
add_user_details=False):
|
||||
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """
|
||||
finish_auth_url = None
|
||||
if current_backend:
|
||||
finish_auth_url = reverse("social:complete", kwargs={"backend": current_backend}) + "?"
|
||||
|
||||
auth_info = {
|
||||
"currentProvider": current_provider,
|
||||
"providers": providers,
|
||||
"secondaryProviders": [],
|
||||
"finishAuthUrl": finish_auth_url,
|
||||
"errorMessage": None,
|
||||
"registerFormSubmitButtonText": "Create Account",
|
||||
"syncLearnerProfileData": False,
|
||||
"pipeline_user_details": {"email": "test@test.com"} if add_user_details else {}
|
||||
}
|
||||
if expected_ec is not None:
|
||||
# If we set an EnterpriseCustomer, third-party auth providers ought to be hidden.
|
||||
auth_info['providers'] = []
|
||||
auth_info = dump_js_escaped_json(auth_info)
|
||||
|
||||
expected_data = '"third_party_auth": {auth_info}'.format(
|
||||
auth_info=auth_info
|
||||
)
|
||||
self.assertContains(response, expected_data)
|
||||
|
||||
def _assert_saml_auth_data_with_error(
|
||||
self, response, current_backend, current_provider, expected_error_message
|
||||
):
|
||||
"""
|
||||
Verify that third party auth info is rendered correctly in a DOM data attribute.
|
||||
"""
|
||||
finish_auth_url = None
|
||||
if current_backend:
|
||||
finish_auth_url = reverse('social:complete', kwargs={'backend': current_backend}) + '?'
|
||||
|
||||
auth_info = {
|
||||
'currentProvider': current_provider,
|
||||
'providers': [],
|
||||
'secondaryProviders': [],
|
||||
'finishAuthUrl': finish_auth_url,
|
||||
'errorMessage': expected_error_message,
|
||||
'registerFormSubmitButtonText': 'Create Account',
|
||||
'syncLearnerProfileData': False,
|
||||
'pipeline_user_details': {'response': {'idp_name': 'testshib'}}
|
||||
}
|
||||
auth_info = dump_js_escaped_json(auth_info)
|
||||
|
||||
expected_data = '"third_party_auth": {auth_info}'.format(
|
||||
auth_info=auth_info
|
||||
)
|
||||
self.assertContains(response, expected_data)
|
||||
|
||||
def _third_party_login_url(self, backend_name, auth_entry, login_params):
|
||||
"""Construct the login URL to start third party authentication. """
|
||||
return u"{url}?auth_entry={auth_entry}&{param_str}".format(
|
||||
url=reverse("social:begin", kwargs={"backend": backend_name}),
|
||||
auth_entry=auth_entry,
|
||||
param_str=self._finish_auth_url_param(login_params),
|
||||
)
|
||||
|
||||
def _finish_auth_url_param(self, params):
|
||||
"""
|
||||
Make the next=... URL parameter that indicates where the user should go next.
|
||||
|
||||
>>> _finish_auth_url_param([('next', '/dashboard')])
|
||||
'/account/finish_auth?next=%2Fdashboard'
|
||||
"""
|
||||
return urlencode({
|
||||
'next': '/account/finish_auth?{}'.format(urlencode(params))
|
||||
})
|
||||
|
||||
def test_english_by_default(self):
|
||||
response = self.client.get(reverse('signin_user'), [], HTTP_ACCEPT="text/html")
|
||||
|
||||
self.assertEqual(response['Content-Language'], 'en')
|
||||
|
||||
def test_unsupported_language(self):
|
||||
response = self.client.get(reverse('signin_user'), [], HTTP_ACCEPT="text/html", HTTP_ACCEPT_LANGUAGE="ts-zx")
|
||||
|
||||
self.assertEqual(response['Content-Language'], 'en')
|
||||
|
||||
def test_browser_language(self):
|
||||
response = self.client.get(reverse('signin_user'), [], HTTP_ACCEPT="text/html", HTTP_ACCEPT_LANGUAGE="es")
|
||||
|
||||
self.assertEqual(response['Content-Language'], 'es-419')
|
||||
|
||||
def test_browser_language_dialent(self):
|
||||
response = self.client.get(reverse('signin_user'), [], HTTP_ACCEPT="text/html", HTTP_ACCEPT_LANGUAGE="es-es")
|
||||
|
||||
self.assertEqual(response['Content-Language'], 'es-es')
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@override_settings(SITE_NAME=settings.MICROSITE_LOGISTRATION_HOSTNAME)
|
||||
class MicrositeLogistrationTests(TestCase):
|
||||
"""
|
||||
Test to validate that microsites can display the logistration page
|
||||
"""
|
||||
|
||||
def test_login_page(self):
|
||||
"""
|
||||
Make sure that we get the expected logistration page on our specialized
|
||||
microsite
|
||||
"""
|
||||
|
||||
resp = self.client.get(
|
||||
reverse('signin_user'),
|
||||
HTTP_HOST=settings.MICROSITE_LOGISTRATION_HOSTNAME
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
def test_registration_page(self):
|
||||
"""
|
||||
Make sure that we get the expected logistration page on our specialized
|
||||
microsite
|
||||
"""
|
||||
|
||||
resp = self.client.get(
|
||||
reverse('register_user'),
|
||||
HTTP_HOST=settings.MICROSITE_LOGISTRATION_HOSTNAME
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
|
||||
def test_no_override(self):
|
||||
"""
|
||||
Make sure we get the old style login/registration if we don't override
|
||||
"""
|
||||
|
||||
resp = self.client.get(
|
||||
reverse('signin_user'),
|
||||
HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertNotIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
resp = self.client.get(
|
||||
reverse('register_user'),
|
||||
HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertNotIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class AccountCreationTestCaseWithSiteOverrides(SiteMixin, TestCase):
|
||||
"""
|
||||
Test cases for Feature flag ALLOW_PUBLIC_ACCOUNT_CREATION which when
|
||||
turned off disables the account creation options in lms
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up the tests"""
|
||||
super(AccountCreationTestCaseWithSiteOverrides, self).setUp()
|
||||
|
||||
# Set the feature flag ALLOW_PUBLIC_ACCOUNT_CREATION to False
|
||||
self.site_configuration_values = {
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION': False
|
||||
}
|
||||
self.site_domain = 'testserver1.com'
|
||||
self.set_up_site(self.site_domain, self.site_configuration_values)
|
||||
|
||||
def test_register_option_login_page(self):
|
||||
"""
|
||||
Navigate to the login page and check the Register option is hidden when
|
||||
ALLOW_PUBLIC_ACCOUNT_CREATION flag is turned off
|
||||
"""
|
||||
response = self.client.get(reverse('signin_user'))
|
||||
self.assertNotIn('<a class="btn-neutral" href="/register?next=%2Fdashboard">Register</a>',
|
||||
response.content)
|
||||
@@ -8,8 +8,8 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from student.cookies import set_experiments_is_enterprise_cookie
|
||||
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_experiments_is_enterprise_cookie
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user