def test_login_url_raises_value_error_if_provider_not_enabled(self): provider_id = 'oa2-not-enabled' assert provider.Registry.get(provider_id) is None with pytest.raises(ValueError): pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN)
def test_for_value_error_if_provider_id_invalid(self): provider_id = 'invalid' # Format is normally "{prefix}-{identifier}" with pytest.raises(ValueError): provider.Registry.get(provider_id) with pytest.raises(ValueError): pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN) with pytest.raises(ValueError): pipeline.get_disconnect_url(provider_id, 1000) with pytest.raises(ValueError): pipeline.get_complete_url(provider_id)
def get(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument """ Return either a redirect to the login page of an identity provider that corresponds to the provider_slug keyword argument or a 404 if the provider_slug does not correspond to an identity provider. Args: request (HttpRequest) Keyword Args: provider_slug (str): a slug corresponding to a configured identity provider Returns: HttpResponse: 302 to a provider's login url if the provider_slug kwarg matches an identity provider HttpResponse: 404 if the provider_slug kwarg does not match an identity provider """ # this gets the url to redirect to after login/registration/third_party_auth # it also handles checking the safety of the redirect url (next query parameter) # it checks against settings.LOGIN_REDIRECT_WHITELIST, so be sure to add the url # to this setting next_destination_url = get_next_url_for_login_page(request) try: url = pipeline.get_login_url(kwargs['provider_slug'], pipeline.AUTH_ENTRY_LOGIN, next_destination_url) return redirect(url) except ValueError: return HttpResponseNotFound()
def test_login_url_returns_expected_format(self): login_url = pipeline.get_login_url(self.enabled_provider.provider_id, pipeline.AUTH_ENTRY_LOGIN) assert login_url.startswith('/auth/login') assert self.enabled_provider.backend_name in login_url assert login_url.endswith(pipeline.AUTH_ENTRY_LOGIN)
def test_custom_form_error(self): """ Use the Google provider to test the custom login/register failure redirects. """ # The pipeline starts by a user GETting /auth/login/google-oauth2/?auth_entry=custom1 # Synthesize that request and check that it redirects to the correct # provider page. auth_entry = 'custom1' # See definition in lms/envs/test.py login_url = pipeline.get_login_url(self.provider.provider_id, auth_entry) login_url += "&next=/misc/final-destination" self.assert_redirect_to_provider_looks_correct( self.client.get(login_url)) def fake_auth_complete_error(_inst, *_args, **_kwargs): """ Mock the backend's auth_complete() method """ raise AuthException("Mock login failed") # Next, the provider makes a request against /auth/complete/<provider>. complete_url = pipeline.get_complete_url(self.provider.backend_name) with patch.object(self.provider.backend_class, 'auth_complete', fake_auth_complete_error): response = self.client.get(complete_url) # This should redirect to the custom error URL assert response.status_code == 302 assert response['Location'] == '/misc/my-custom-sso-error-page'
def test_with_valid_provider_slug(self): endpoint_url = self.get_idp_redirect_url('saml-test') expected_url = pipeline.get_login_url('saml-test', pipeline.AUTH_ENTRY_LOGIN, reverse('dashboard')) response = self.client.get(endpoint_url) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, expected_url)
def test_custom_form(self): """ Use the Google provider to test the custom login/register form feature. """ # The pipeline starts by a user GETting /auth/login/google-oauth2/?auth_entry=custom1 # Synthesize that request and check that it redirects to the correct # provider page. auth_entry = 'custom1' # See definition in lms/envs/test.py login_url = pipeline.get_login_url(self.provider.provider_id, auth_entry) login_url += "&next=/misc/final-destination" self.assert_redirect_to_provider_looks_correct(self.client.get(login_url)) def fake_auth_complete(inst, *args, **kwargs): """ Mock the backend's auth_complete() method """ kwargs.update({'response': self.get_response_data(), 'backend': inst}) return inst.strategy.authenticate(*args, **kwargs) # Next, the provider makes a request against /auth/complete/<provider>. complete_url = pipeline.get_complete_url(self.provider.backend_name) with patch.object(self.provider.backend_class, 'auth_complete', fake_auth_complete): response = self.client.get(complete_url) # This should redirect to the custom login/register form: assert response.status_code == 302 assert response['Location'] == '/auth/custom_auth_entry' response = self.client.get(response['Location']) assert response.status_code == 200 assert 'action="/misc/my-custom-registration-form" method="post"' in response.content.decode('utf-8') data_decoded = base64.b64decode(response.context['data']).decode('utf-8') data_parsed = json.loads(data_decoded) # The user's details get passed to the custom page as a base64 encoded query parameter: assert data_parsed == {'auth_entry': 'custom1', 'backend_name': 'google-oauth2', 'provider_id': 'oa2-google-oauth2', 'user_details': {'username': '******', 'email': '*****@*****.**', 'fullname': 'name_value', 'first_name': 'given_name_value', 'last_name': 'family_name_value'}} # Check the hash that is used to confirm the user's data in the GET parameter is correct secret_key = settings.THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS['custom1']['secret_key'] hmac_expected = hmac.new( secret_key.encode('utf-8'), msg=data_decoded.encode('utf-8'), digestmod=hashlib.sha256 ).digest() assert base64.b64decode(response.context['hmac']) == hmac_expected # Now our custom registration form creates or logs in the user: email, password = data_parsed['user_details']['email'], 'random_password' created_user = UserFactory(email=email, password=password) login_response = self.client.post(reverse('login_api'), {'email': email, 'password': password}) assert login_response.status_code == 200 # Now our custom login/registration page must resume the pipeline: response = self.client.get(complete_url) assert response.status_code == 302 assert response['Location'] == '/misc/final-destination' _, strategy = self.get_request_and_strategy() self.assert_social_auth_exists_for_user(created_user, strategy)
def get(self, request): """ GET /api/third_party_auth/v0/providers/user_status/ **GET Response Values** ``` { "accepts_logins": true, "name": "Google", "disconnect_url": "/auth/disconnect/google-oauth2/?", "connect_url": "/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings", "connected": false, "id": "oa2-google-oauth2" } ``` """ tpa_states = [] for state in pipeline.get_provider_user_states(request.user): # 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. if state.provider.display_for_login or state.has_account: tpa_states.append({ '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), }) return Response(tpa_states)
def get_social_oauth_providers_data(self): """ Get the context data related to the social auth providers Returns: dict: A dictionary containing the data for social oauth SSO providers """ context = {'providers': [], 'is_any_social_auth_connected': False} if third_party_auth.is_enabled(): auth_states = pipeline.get_provider_user_states(self.request.user) for state in auth_states: if state.provider.display_for_login or state.has_account: if state.has_account: context['is_any_social_auth_connected'] = True context['providers'].append({ 'id': state.provider.provider_id, 'name': state.provider.name, 'connected': state.has_account, 'accepts_logins': state.provider.accepts_logins, 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), 'connect_url': pipeline.get_login_url( state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, redirect_url=reverse('adg_account_settings'), ), **ACCOUNT_INFO.get(state.provider.provider_id) }) return context
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 # Note: We check for the existence of login-related cookies in addition to is_authenticated # since Django's SessionAuthentication middleware auto-updates session cookies but not # the other login-related cookies. See ARCH-282. if request.user.is_authenticated and are_logged_in_cookies_set(request): 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: # lint-amnesty, pylint: disable=too-many-nested-blocks try: next_args = six.moves.urllib.parse.parse_qs(six.moves.urllib.parse.urlparse(redirect_to).query) if 'tpa_hint' in next_args: 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(u"Unknown tpa_hint provider: %s", ex) enterprise_customer = enterprise_customer_for_request(request) # Redirect to authn MFE if it is enabled if should_redirect_to_authn_microfrontend() and not enterprise_customer: # This is to handle a case where a logged-in cookie is not present but the user is authenticated. # Note: If we don't handle this learner is redirected to authn MFE and then back to dashboard # instead of the desired redirect URL (e.g. finish_auth) resulting in learners not enrolling # into the courses. if request.user.is_authenticated and redirect_to: return redirect(redirect_to) query_params = request.GET.urlencode() url_path = '/{}{}'.format( initial_mode, '?' + query_params if query_params else '' ) return redirect(settings.AUTHN_MICROFRONTEND_URL + url_path) # 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 ] account_recovery_messages = [ { 'message': message.message, 'tags': message.tags } for message in messages.get_messages(request) if 'account-recovery' 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, 'account_recovery_messages': account_recovery_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)), 'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled(), 'is_multiple_user_enterprises_feature_enabled': is_multiple_user_enterprises_feature_enabled(), 'enterprise_slug_login_url': get_enterprise_slug_login_url(), 'is_require_third_party_auth_enabled': is_require_third_party_auth_enabled(), }, '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'] ), } 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 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, "platformName": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), "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, "skipHintedLogin": enabled.skip_hinted_login_dialog, "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: username = running_pipeline['kwargs'].get( 'username') or user_details.get('username') if username: user_details['username'] = clean_username(username) 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"] = _(str(msg)) # pylint: disable=E7610 break return 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 = [(six.text_type(year), six.text_type(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 = [] beta_language = {} dark_lang_config = DarkLangConfig.current() if dark_lang_config.enable_beta_languages: user_preferences = get_user_preferences(user) pref_language = user_preferences.get('pref-lang') if pref_language in dark_lang_config.beta_languages_list: beta_language['code'] = pref_language beta_language['name'] = settings.LANGUAGE_DICT.get(pref_language) 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, 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(), 'enable_account_deletion': configuration_helpers.get_value( 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False)), 'extended_profile_fields': _get_extended_profile_fields(), 'beta_language': beta_language, } enterprise_customer = enterprise_customer_for_request(request) update_account_settings_context_for_enterprise(context, enterprise_customer, user) 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 test_full_pipeline_succeeds_for_unlinking_testshib_account( self, mock_enterprise_customer_for_request_settings_view, mock_enterprise_customer_for_request, ): # First, create, the request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') request.backend.auth_complete = MagicMock( return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( strategy, '*****@*****.**', 'password', self.get_username()) self.assert_social_auth_exists_for_user(user, strategy) request.user = user # We're already logged in, so simulate that the cookie is set correctly self.set_logged_in_cookies(request) # linking a learner with enterprise customer. enterprise_customer = EnterpriseCustomerFactory() assert EnterpriseCustomerUser.objects.count( ) == 0, "Precondition check: no link records should exist" EnterpriseCustomerUser.objects.link_user(enterprise_customer, user.email) assert (EnterpriseCustomerUser.objects.filter( enterprise_customer=enterprise_customer, user_id=user.id).count() == 1) EnterpriseCustomerIdentityProvider.objects.get_or_create( enterprise_customer=enterprise_customer, provider_id=self.provider.provider_id) enterprise_customer_data = { 'uuid': enterprise_customer.uuid, 'name': enterprise_customer.name, 'identity_provider': 'saml-default', } mock_enterprise_customer_for_request.return_value = enterprise_customer_data mock_enterprise_customer_for_request_settings_view.return_value = enterprise_customer_data # Instrument the pipeline to get to the dashboard with the full expected state. self.client.get( pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete( request.backend, social_views._do_login, # pylint: disable=protected-access request=request) with self._patch_edxmako_current_request(strategy.request): login_user(strategy.request) actions.do_complete( request.backend, social_views._do_login, user=user, # pylint: disable=protected-access request=request) # First we expect that we're in the linked state, with a backend entry. self.assert_account_settings_context_looks_correct( account_settings_context(request), linked=True) self.assert_social_auth_exists_for_user(request.user, strategy) FEATURES_WITH_ENTERPRISE_ENABLED = settings.FEATURES.copy() FEATURES_WITH_ENTERPRISE_ENABLED[ 'ENABLE_ENTERPRISE_INTEGRATION'] = True with patch.dict("django.conf.settings.FEATURES", FEATURES_WITH_ENTERPRISE_ENABLED): # Fire off the disconnect pipeline without the user information. actions.do_disconnect(request.backend, None, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request) assert EnterpriseCustomerUser.objects\ .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() != 0 # Fire off the disconnect pipeline to unlink. self.assert_redirect_after_pipeline_completes( actions.do_disconnect( request.backend, user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request)) # Now we expect to be in the unlinked state, with no backend entry. self.assert_account_settings_context_looks_correct( account_settings_context(request), linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) assert EnterpriseCustomerUser.objects\ .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 0