diff --git a/cms/envs/common.py b/cms/envs/common.py index 64aa72e25a..c67841df4b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2273,6 +2273,7 @@ DISABLE_DEPRECATED_SIGNUP_URL = False ##### LOGISTRATION RATE LIMIT SETTINGS ##### LOGISTRATION_RATELIMIT_RATE = '100/5m' +LOGISTRATION_API_RATELIMIT = '20/m' ##### REGISTRATION RATE LIMIT SETTINGS ##### REGISTRATION_VALIDATION_RATELIMIT = '30/7d' diff --git a/cms/envs/test.py b/cms/envs/test.py index b07ac309ed..126b941834 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -322,5 +322,6 @@ PROCTORING_SETTINGS = {} ##### LOGISTRATION RATE LIMIT SETTINGS ##### LOGISTRATION_RATELIMIT_RATE = '5/5m' +LOGISTRATION_API_RATELIMIT = '5/m' REGISTRATION_VALIDATION_RATELIMIT = '5/minute' diff --git a/lms/envs/common.py b/lms/envs/common.py index a13ff231dd..b0cc63d4c1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3930,6 +3930,7 @@ RATELIMIT_RATE = '120/m' ##### LOGISTRATION RATE LIMIT SETTINGS ##### LOGISTRATION_RATELIMIT_RATE = '100/5m' +LOGISTRATION_API_RATELIMIT = '20/m' ##### PASSWORD RESET RATE LIMIT SETTINGS ##### PASSWORD_RESET_IP_RATE = '1/m' diff --git a/lms/envs/production.py b/lms/envs/production.py index 8a48fcdfc9..f664103932 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -605,6 +605,7 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get( ##### LOGISTRATION RATE LIMIT SETTINGS ##### LOGISTRATION_RATELIMIT_RATE = ENV_TOKENS.get('LOGISTRATION_RATELIMIT_RATE', LOGISTRATION_RATELIMIT_RATE) +LOGISTRATION_API_RATELIMIT = ENV_TOKENS.get('LOGISTRATION_API_RATELIMIT', LOGISTRATION_API_RATELIMIT) ##### REGISTRATION RATE LIMIT SETTINGS ##### REGISTRATION_VALIDATION_RATELIMIT = ENV_TOKENS.get( diff --git a/lms/envs/test.py b/lms/envs/test.py index 99fdf4f70a..c65cfcc921 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -587,5 +587,6 @@ RATELIMIT_RATE = '2/m' ##### LOGISTRATION RATE LIMIT SETTINGS ##### LOGISTRATION_RATELIMIT_RATE = '5/5m' +LOGISTRATION_API_RATELIMIT = '5/m' REGISTRATION_VALIDATION_RATELIMIT = '5/minute' diff --git a/openedx/core/djangoapps/user_authn/api/__init__.py b/openedx/core/djangoapps/user_authn/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_authn/api/tests/__init__.py b/openedx/core/djangoapps/user_authn/api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_views.py b/openedx/core/djangoapps/user_authn/api/tests/test_views.py new file mode 100644 index 0000000000..3634c1f063 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/api/tests/test_views.py @@ -0,0 +1,166 @@ +""" +Logistration API View Tests +""" +import ddt +from django.conf import settings +from django.urls import reverse +from mock import patch +from rest_framework.test import APITestCase +from six.moves.urllib.parse import urlencode + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from third_party_auth import pipeline +from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline + + +@skip_unless_lms +@ddt.ddt +class TPAContextViewTest(ThirdPartyAuthTestMixin, APITestCase): + """ + Third party auth context tests + """ + + def setUp(self): # pylint: disable=arguments-differ + """ + Test Setup + """ + super(TPAContextViewTest, self).setUp() + + self.url = reverse('third_party_auth_context') + self.query_params = {'redirect_to': '/dashboard'} + + # Several third party auth providers are created for these tests: + self.configure_google_provider(enabled=True, visible=True) + self.configure_facebook_provider(enabled=True, visible=True) + + self.hidden_enabled_provider = self.configure_linkedin_provider( + visible=False, + enabled=True, + ) + + def _third_party_login_url(self, backend_name, auth_entry, 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=urlencode(params) + ) + + def get_provider_data(self, params): + """ + Returns the expected provider data based on providers enabled in test setup + """ + return [ + { + '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) + }, + ] + + def get_context(self, params=None, current_provider=None, backend_name=None, add_user_details=False): + """ + Returns the third party auth context + """ + return { + 'currentProvider': current_provider, + 'providers': self.get_provider_data(params) if params else [], + 'secondaryProviders': [], + 'finishAuthUrl': pipeline.get_complete_url(backend_name) if backend_name else None, + 'errorMessage': None, + 'registerFormSubmitButtonText': 'Create Account', + 'syncLearnerProfileData': False, + 'pipeline_user_details': {'email': 'test@test.com'} if add_user_details else {} + } + + def test_missing_arguments(self): + """ + Test that if required arguments are missing, proper status code and message + is returned. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {'message': 'Request missing required parameter: redirect_to'}) + + @patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_AUTH': False}) + def test_no_third_party_auth_providers(self): + """ + Test that if third party auth is enabled, context returned by API contains + the provider information + """ + response = self.client.get(self.url, self.query_params) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.get_context()) + + def test_third_party_auth_providers(self): + """ + Test that api returns details of currently enabled third party auth providers + """ + response = self.client.get(self.url, self.query_params) + params = { + 'next': self.query_params['redirect_to'] + } + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.get_context(params)) + + @ddt.data( + ('google-oauth2', 'Google', False), + ('facebook', 'Facebook', False), + ('google-oauth2', 'Google', True) + ) + @ddt.unpack + def test_running_pipeline(self, current_backend, current_provider, add_user_details): + """ + Test that when third party pipeline is running, the api returns details + of current provider + """ + email = 'test@test.com' if add_user_details else None + params = { + 'next': self.query_params['redirect_to'] + } + + # Simulate a running pipeline + 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(self.url, self.query_params) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.get_context(params, current_provider, current_backend, add_user_details)) + + def test_tpa_hint(self): + """ + Test that if tpa_hint is provided, the context returns the third party auth provider + even if it is not visible on the login page + """ + params = { + 'next': self.query_params['redirect_to'] + } + tpa_hint = self.hidden_enabled_provider.provider_id + self.query_params.update({'tpa_hint': tpa_hint}) + + provider_data = self.get_provider_data(params) + provider_data.append({ + 'id': self.hidden_enabled_provider.provider_id, + 'name': 'LinkedIn', + 'iconClass': 'fa-linkedin', + 'iconImage': None, + 'loginUrl': self._third_party_login_url('linkedin-oauth2', 'login', params), + 'registerUrl': self._third_party_login_url('linkedin-oauth2', 'register', params) + }) + + response = self.client.get(self.url, self.query_params) + self.assertEqual(response.data['providers'], provider_data) diff --git a/openedx/core/djangoapps/user_authn/api/urls.py b/openedx/core/djangoapps/user_authn/api/urls.py new file mode 100644 index 0000000000..fae8ffd77e --- /dev/null +++ b/openedx/core/djangoapps/user_authn/api/urls.py @@ -0,0 +1,13 @@ +""" +Logistration API urls +""" + +from django.conf.urls import url + +from openedx.core.djangoapps.user_authn.api.views import TPAContextView + +urlpatterns = [ + url( + r'^third_party_auth_context$', TPAContextView.as_view(), name='third_party_auth_context' + ), +] diff --git a/openedx/core/djangoapps/user_authn/api/views.py b/openedx/core/djangoapps/user_authn/api/views.py new file mode 100644 index 0000000000..5bc9b953e0 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/api/views.py @@ -0,0 +1,56 @@ +""" +Logistration API Views +""" + +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework.views import APIView + +from openedx.core.djangoapps.user_authn.utils import third_party_auth_context + +REDIRECT_KEY = 'redirect_to' + + +class ThirdPartyAuthContextThrottle(AnonRateThrottle): + """ + Setting rate limit for ThirdPartyAuthContext API + """ + rate = settings.LOGISTRATION_API_RATELIMIT + + +class TPAContextView(APIView): + """ + API to get third party auth providers and the currently running pipeline. + """ + throttle_classes = [ThirdPartyAuthContextThrottle] + + def get(self, request, **kwargs): + """ + Returns the 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 + """ + request_params = request.GET + + if REDIRECT_KEY not in request_params: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={'message': 'Request missing required parameter: {}'.format(REDIRECT_KEY)} + ) + + redirect_to = request_params.get(REDIRECT_KEY) + third_party_auth_hint = request_params.get('tpa_hint') + + context = third_party_auth_context(request, redirect_to, third_party_auth_hint) + return Response( + status=status.HTTP_200_OK, + data=context + ) diff --git a/openedx/core/djangoapps/user_authn/urls.py b/openedx/core/djangoapps/user_authn/urls.py index 61726b9352..3623fd9b14 100644 --- a/openedx/core/djangoapps/user_authn/urls.py +++ b/openedx/core/djangoapps/user_authn/urls.py @@ -1,15 +1,13 @@ """ URLs for User Authentication """ - -from django.conf import settings from django.conf.urls import include, url from .views import login, login_form - urlpatterns = [ # 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'^api/', include('openedx.core.djangoapps.user_authn.api.urls')), url(r'^account/finish_auth$', login.finish_auth, name='finish_auth'), ] diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index fb40374a85..cfc2f5846d 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -2,16 +2,93 @@ Utility functions used during user authentication. """ - import random import string +import six from django.conf import settings +from django.contrib import messages from django.utils import http +from django.utils.translation import ugettext as _ from oauth2_provider.models import Application from six.moves.urllib.parse import urlparse # pylint: disable=import-error +import third_party_auth from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from third_party_auth import pipeline + + +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"] = _(six.text_type(msg)) # pylint: disable=E7610 + break + + return context def is_safe_login_or_logout_redirect(redirect_to, request_host, dot_client_id, require_https): diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py index c5ec46d835..25a9002e21 100644 --- a/openedx/core/djangoapps/user_authn/views/login_form.py +++ b/openedx/core/djangoapps/user_authn/views/login_form.py @@ -24,14 +24,17 @@ from openedx.core.djangoapps.user_api.accounts.utils import ( ) from openedx.core.djangoapps.user_api.helpers import FormDescription from openedx.core.djangoapps.user_authn.cookies import are_logged_in_cookies_set -from openedx.core.djangoapps.user_authn.utils import should_redirect_to_logistration_mircrofrontend +from openedx.core.djangoapps.user_authn.utils import ( + should_redirect_to_logistration_mircrofrontend, + third_party_auth_context +) from openedx.core.djangoapps.user_authn.views.password_reset import get_password_reset_form from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory from openedx.features.enterprise_support.api import enterprise_customer_for_request from openedx.features.enterprise_support.utils import ( - handle_enterprise_cookies_for_logistration, get_enterprise_slug_login_url, - update_logistration_context_for_enterprise, + handle_enterprise_cookies_for_logistration, + update_logistration_context_for_enterprise ) from student.helpers import get_next_url_for_login_page from third_party_auth import pipeline @@ -209,7 +212,7 @@ def login_and_registration_form(request, initial_mode="login"): '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': 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), @@ -268,75 +271,3 @@ def _get_form_descriptions(request): '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"] = _(six.text_type(msg)) # pylint: disable=E7610 - break - - return context