Add third party auth context api (#25497)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
0
openedx/core/djangoapps/user_authn/api/__init__.py
Normal file
0
openedx/core/djangoapps/user_authn/api/__init__.py
Normal file
166
openedx/core/djangoapps/user_authn/api/tests/test_views.py
Normal file
166
openedx/core/djangoapps/user_authn/api/tests/test_views.py
Normal file
@@ -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)
|
||||
13
openedx/core/djangoapps/user_authn/api/urls.py
Normal file
13
openedx/core/djangoapps/user_authn/api/urls.py
Normal file
@@ -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'
|
||||
),
|
||||
]
|
||||
56
openedx/core/djangoapps/user_authn/api/views.py
Normal file
56
openedx/core/djangoapps/user_authn/api/views.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user