Logout link should be displayed only for learner portal Added changed to display only for learner portal Added unit tests check third_party_auth is enabled Changes to extend SSO logout link feature to Oauth providers Fixed quality violations Removed unncessary assert Reviewer feedback changes
912 lines
39 KiB
Python
912 lines
39 KiB
Python
"""Auth pipeline definitions.
|
|
|
|
Auth pipelines handle the process of authenticating a user. They involve a
|
|
consumer system and a provider service. The general pattern is:
|
|
|
|
1. The consumer system exposes a URL endpoint that starts the process.
|
|
2. When a user visits that URL, the client system redirects the user to a
|
|
page served by the provider. The user authenticates with the provider.
|
|
The provider handles authentication failure however it wants.
|
|
3. On success, the provider POSTs to a URL endpoint on the consumer to
|
|
invoke the pipeline. It sends back an arbitrary payload of data about
|
|
the user.
|
|
4. The pipeline begins, executing each function in its stack. The stack is
|
|
defined on django's settings object's SOCIAL_AUTH_PIPELINE. This is done
|
|
in settings._set_global_settings.
|
|
5. Each pipeline function is variadic. Most pipeline functions are part of
|
|
the pythons-social-auth library; our extensions are defined below. The
|
|
pipeline is the same no matter what provider is used.
|
|
6. Pipeline functions can return a dict to add arguments to the function
|
|
invoked next. They can return None if this is not necessary.
|
|
7. Pipeline functions may be decorated with @partial.partial. This pauses
|
|
the pipeline and serializes its state onto the request's session. When
|
|
this is done they may redirect to other edX handlers to execute edX
|
|
account registration/sign in code.
|
|
8. In that code, redirecting to get_complete_url() resumes the pipeline.
|
|
This happens by hitting a handler exposed by the consumer system.
|
|
9. In this way, execution moves between the provider, the pipeline, and
|
|
arbitrary consumer system code.
|
|
|
|
Gotcha alert!:
|
|
|
|
Bear in mind that when pausing and resuming a pipeline function decorated with
|
|
@partial.partial, execution resumes by re-invoking the decorated function
|
|
instead of invoking the next function in the pipeline stack. For example, if
|
|
you have a pipeline of
|
|
|
|
A
|
|
B
|
|
C
|
|
|
|
with an implementation of
|
|
|
|
@partial.partial
|
|
def B(*args, **kwargs):
|
|
[...]
|
|
|
|
B will be invoked twice: once when initially proceeding through the pipeline
|
|
before it is paused, and once when other code finishes and the pipeline
|
|
resumes. Consequently, many decorated functions will first invoke a predicate
|
|
to determine if they are in their first or second execution (usually by
|
|
checking side-effects from the first run).
|
|
|
|
This is surprising but important behavior, since it allows a single function in
|
|
the pipeline to consolidate all the operations needed to establish invariants
|
|
rather than spreading them across two functions in the pipeline.
|
|
|
|
See https://python-social-auth.readthedocs.io/en/latest/pipeline.html for more docs.
|
|
"""
|
|
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
from collections import OrderedDict
|
|
from logging import getLogger
|
|
from smtplib import SMTPException
|
|
from uuid import uuid4
|
|
|
|
import six
|
|
import six.moves.urllib.error # pylint: disable=import-error
|
|
import six.moves.urllib.parse # pylint: disable=import-error
|
|
import six.moves.urllib.request # pylint: disable=import-error
|
|
import social_django
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User
|
|
from django.core.mail.message import EmailMessage
|
|
from django.http import HttpResponseBadRequest
|
|
from django.shortcuts import redirect
|
|
from django.urls import reverse
|
|
from social_core.exceptions import AuthException
|
|
from social_core.pipeline import partial
|
|
from social_core.pipeline.social_auth import associate_by_email
|
|
from social_core.utils import module_member, slugify
|
|
|
|
import third_party_auth
|
|
from edxmako.shortcuts import render_to_string
|
|
from lms.djangoapps.verify_student.models import SSOVerification
|
|
from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date
|
|
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
|
from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies
|
|
from third_party_auth.utils import user_exists
|
|
from track import segment
|
|
from util.json_request import JsonResponse
|
|
|
|
from . import provider
|
|
|
|
# These are the query string params you can pass
|
|
# to the URL that starts the authentication process.
|
|
#
|
|
# `AUTH_ENTRY_KEY` is required and indicates how the user
|
|
# enters the authentication process.
|
|
#
|
|
# `AUTH_REDIRECT_KEY` provides an optional URL to redirect
|
|
# to upon successful authentication
|
|
# (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`)
|
|
AUTH_ENTRY_KEY = 'auth_entry'
|
|
AUTH_REDIRECT_KEY = 'next'
|
|
|
|
|
|
# The following are various possible values for the AUTH_ENTRY_KEY.
|
|
AUTH_ENTRY_LOGIN = 'login'
|
|
AUTH_ENTRY_REGISTER = 'register'
|
|
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
|
|
|
|
# Entry modes into the authentication process by a remote API call (as opposed to a browser session).
|
|
AUTH_ENTRY_LOGIN_API = 'login_api'
|
|
AUTH_ENTRY_REGISTER_API = 'register_api'
|
|
|
|
# AUTH_ENTRY_CUSTOM: Custom auth entry point for post-auth integrations.
|
|
# This should be a dict where the key is a word passed via ?auth_entry=, and the
|
|
# value is a dict with an arbitrary 'secret_key' and a 'url'.
|
|
# This can be used as an extension point to inject custom behavior into the auth
|
|
# process, replacing the registration/login form that would normally be seen
|
|
# immediately after the user has authenticated with the third party provider.
|
|
# If a custom 'auth_entry' query parameter is used, then once the user has
|
|
# authenticated with a specific backend/provider, they will be redirected to the
|
|
# URL specified with this setting, rather than to the built-in
|
|
# registration/login form/logic.
|
|
AUTH_ENTRY_CUSTOM = getattr(settings, 'THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {})
|
|
|
|
|
|
def is_api(auth_entry):
|
|
"""Returns whether the auth entry point is via an API call."""
|
|
return (auth_entry == AUTH_ENTRY_LOGIN_API) or (auth_entry == AUTH_ENTRY_REGISTER_API)
|
|
|
|
# URLs associated with auth entry points
|
|
# These are used to request additional user information
|
|
# (for example, account credentials when logging in),
|
|
# and when the user cancels the auth process
|
|
# (e.g., refusing to grant permission on the provider's login page).
|
|
# We don't use "reverse" here because doing so may cause modules
|
|
# to load that depend on this module.
|
|
AUTH_DISPATCH_URLS = {
|
|
AUTH_ENTRY_LOGIN: '/login',
|
|
AUTH_ENTRY_REGISTER: '/register',
|
|
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
|
|
}
|
|
|
|
_AUTH_ENTRY_CHOICES = frozenset([
|
|
AUTH_ENTRY_LOGIN,
|
|
AUTH_ENTRY_REGISTER,
|
|
AUTH_ENTRY_ACCOUNT_SETTINGS,
|
|
AUTH_ENTRY_LOGIN_API,
|
|
AUTH_ENTRY_REGISTER_API,
|
|
] + list(AUTH_ENTRY_CUSTOM.keys()))
|
|
|
|
USER_FIELDS = ['username', 'email']
|
|
|
|
|
|
logger = getLogger(__name__)
|
|
|
|
|
|
class AuthEntryError(AuthException):
|
|
"""Raised when auth_entry is invalid on URLs.
|
|
|
|
auth_entry tells us whether the auth flow was initiated to register a new
|
|
user (in which case it has the value of AUTH_ENTRY_REGISTER) or log in an
|
|
existing user (in which case it has the value of AUTH_ENTRY_LOGIN).
|
|
|
|
This is necessary because the edX code we hook into the pipeline to
|
|
redirect to the existing auth flows needs to know what case we are in in
|
|
order to format its output correctly (for example, the register code is
|
|
invoked earlier than the login code, and it needs to know if the login flow
|
|
was requested to dispatch correctly).
|
|
"""
|
|
|
|
|
|
class ProviderUserState(object):
|
|
"""Object representing the provider state (attached or not) for a user.
|
|
|
|
This is intended only for use when rendering templates. See for example
|
|
lms/templates/dashboard.html.
|
|
"""
|
|
|
|
def __init__(self, enabled_provider, user, association):
|
|
# Boolean. Whether the user has an account associated with the provider
|
|
self.has_account = association is not None
|
|
if self.has_account:
|
|
# UserSocialAuth row ID
|
|
self.association_id = association.id
|
|
# Identifier of this user according to the remote provider:
|
|
self.remote_id = enabled_provider.get_remote_id_from_social_auth(association)
|
|
else:
|
|
self.association_id = None
|
|
self.remote_id = None
|
|
# provider.BaseProvider child. Callers must verify that the provider is
|
|
# enabled.
|
|
self.provider = enabled_provider
|
|
# django.contrib.auth.models.User.
|
|
self.user = user
|
|
|
|
def get_unlink_form_name(self):
|
|
"""Gets the name used in HTML forms that unlink a provider account."""
|
|
return self.provider.provider_id + '_unlink_form'
|
|
|
|
|
|
def get(request):
|
|
"""Gets the running pipeline's data from the passed request."""
|
|
strategy = social_django.utils.load_strategy(request)
|
|
token = strategy.session_get('partial_pipeline_token')
|
|
|
|
if not token:
|
|
strategy.session_set('partial_pipeline_token', strategy.session_get('partial_pipeline_token_'))
|
|
token = strategy.session_get('partial_pipeline_token')
|
|
|
|
partial_object = strategy.partial_load(token)
|
|
pipeline_data = None
|
|
if partial_object:
|
|
pipeline_data = {'kwargs': partial_object.kwargs, 'backend': partial_object.backend}
|
|
return pipeline_data
|
|
|
|
|
|
def get_idp_logout_url_from_running_pipeline(request):
|
|
"""
|
|
Returns: IdP's logout url associated with running pipeline
|
|
"""
|
|
if third_party_auth.is_enabled():
|
|
running_pipeline = get(request)
|
|
if running_pipeline:
|
|
tpa_provider = provider.Registry.get_from_pipeline(running_pipeline)
|
|
if tpa_provider:
|
|
try:
|
|
return tpa_provider.get_setting('logout_url')
|
|
except KeyError:
|
|
logger.info(u'[THIRD_PARTY_AUTH] idP [%s] logout_url setting not defined', tpa_provider.name)
|
|
|
|
|
|
def get_real_social_auth_object(request):
|
|
"""
|
|
At times, the pipeline will have a "social" kwarg that contains a dictionary
|
|
rather than an actual DB-backed UserSocialAuth object. We need the real thing,
|
|
so this method allows us to get that by passing in the relevant request.
|
|
"""
|
|
running_pipeline = get(request)
|
|
if running_pipeline and 'social' in running_pipeline['kwargs']:
|
|
social = running_pipeline['kwargs']['social']
|
|
if isinstance(social, dict):
|
|
social = social_django.models.UserSocialAuth.objects.get(**social)
|
|
return social
|
|
|
|
|
|
def quarantine_session(request, locations):
|
|
"""
|
|
Set a session variable indicating that the session is restricted
|
|
to being used in views contained in the modules listed by string
|
|
in the `locations` argument.
|
|
|
|
Example: ``quarantine_session(request, ('enterprise.views',))``
|
|
"""
|
|
request.session['third_party_auth_quarantined_modules'] = locations
|
|
|
|
|
|
def lift_quarantine(request):
|
|
"""
|
|
Remove the session quarantine variable.
|
|
"""
|
|
request.session.pop('third_party_auth_quarantined_modules', None)
|
|
|
|
|
|
def get_authenticated_user(auth_provider, username, uid):
|
|
"""Gets a saved user authenticated by a particular backend.
|
|
|
|
Between pipeline steps User objects are not saved. We need to reconstitute
|
|
the user and set its .backend, which is ordinarily monkey-patched on by
|
|
Django during authenticate(), so it will function like a user returned by
|
|
authenticate().
|
|
|
|
Args:
|
|
auth_provider: the third_party_auth provider in use for the current pipeline.
|
|
username: string. Username of user to get.
|
|
uid: string. The user ID according to the third party.
|
|
|
|
Returns:
|
|
User if user is found and has a social auth from the passed
|
|
provider.
|
|
|
|
Raises:
|
|
User.DoesNotExist: if no user matching user is found, or the matching
|
|
user has no social auth associated with the given backend.
|
|
AssertionError: if the user is not authenticated.
|
|
"""
|
|
match = social_django.models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid)
|
|
|
|
if not match or match.user.username != username:
|
|
raise User.DoesNotExist
|
|
|
|
user = match.user
|
|
user.backend = auth_provider.get_authentication_backend()
|
|
return user
|
|
|
|
|
|
def _get_enabled_provider(provider_id):
|
|
"""Gets an enabled provider by its provider_id member or throws."""
|
|
enabled_provider = provider.Registry.get(provider_id)
|
|
|
|
if not enabled_provider:
|
|
raise ValueError(u'Provider %s not enabled' % provider_id)
|
|
|
|
return enabled_provider
|
|
|
|
|
|
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None,
|
|
extra_params=None, url_params=None):
|
|
"""Creates a URL to hook into social auth endpoints."""
|
|
url_params = url_params or {}
|
|
url_params['backend'] = backend_name
|
|
url = reverse(view_name, kwargs=url_params)
|
|
|
|
query_params = OrderedDict()
|
|
if auth_entry:
|
|
query_params[AUTH_ENTRY_KEY] = auth_entry
|
|
|
|
if redirect_url:
|
|
query_params[AUTH_REDIRECT_KEY] = redirect_url
|
|
|
|
if extra_params:
|
|
query_params.update(extra_params)
|
|
|
|
return u"{url}?{params}".format(
|
|
url=url,
|
|
params=six.moves.urllib.parse.urlencode(query_params)
|
|
)
|
|
|
|
|
|
def get_complete_url(backend_name):
|
|
"""Gets URL for the endpoint that returns control to the auth pipeline.
|
|
|
|
Args:
|
|
backend_name: string. Name of the python-social-auth backend from the
|
|
currently-running pipeline.
|
|
|
|
Returns:
|
|
String. URL that finishes the auth pipeline for a provider.
|
|
|
|
Raises:
|
|
ValueError: if no provider is enabled with the given backend_name.
|
|
"""
|
|
if not any(provider.Registry.get_enabled_by_backend_name(backend_name)):
|
|
raise ValueError(u'Provider with backend %s not enabled' % backend_name)
|
|
|
|
return _get_url('social:complete', backend_name)
|
|
|
|
|
|
def get_disconnect_url(provider_id, association_id):
|
|
"""Gets URL for the endpoint that starts the disconnect pipeline.
|
|
|
|
Args:
|
|
provider_id: string identifier of the social_django.models.ProviderConfig child you want
|
|
to disconnect from.
|
|
association_id: int. Optional ID of a specific row in the UserSocialAuth
|
|
table to disconnect (useful if multiple providers use a common backend)
|
|
|
|
Returns:
|
|
String. URL that starts the disconnection pipeline.
|
|
|
|
Raises:
|
|
ValueError: if no provider is enabled with the given ID.
|
|
"""
|
|
backend_name = _get_enabled_provider(provider_id).backend_name
|
|
if association_id:
|
|
return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id})
|
|
else:
|
|
return _get_url('social:disconnect', backend_name)
|
|
|
|
|
|
def get_login_url(provider_id, auth_entry, redirect_url=None):
|
|
"""Gets the login URL for the endpoint that kicks off auth with a provider.
|
|
|
|
Args:
|
|
provider_id: string identifier of the social_django.models.ProviderConfig child you want
|
|
to disconnect from.
|
|
auth_entry: string. Query argument specifying the desired entry point
|
|
for the auth pipeline. Used by the pipeline for later branching.
|
|
Must be one of _AUTH_ENTRY_CHOICES.
|
|
|
|
Keyword Args:
|
|
redirect_url (string): If provided, redirect to this URL at the end
|
|
of the authentication process.
|
|
|
|
Returns:
|
|
String. URL that starts the auth pipeline for a provider.
|
|
|
|
Raises:
|
|
ValueError: if no provider is enabled with the given provider_id.
|
|
"""
|
|
assert auth_entry in _AUTH_ENTRY_CHOICES
|
|
enabled_provider = _get_enabled_provider(provider_id)
|
|
return _get_url(
|
|
'social:begin',
|
|
enabled_provider.backend_name,
|
|
auth_entry=auth_entry,
|
|
redirect_url=redirect_url,
|
|
extra_params=enabled_provider.get_url_params(),
|
|
)
|
|
|
|
|
|
def get_duplicate_provider(messages):
|
|
"""Gets provider from message about social account already in use.
|
|
|
|
python-social-auth's exception middleware uses the messages module to
|
|
record details about duplicate account associations. It records exactly one
|
|
message there is a request to associate a social account S with an edX
|
|
account E if S is already associated with an edX account E'.
|
|
|
|
This messaging approach is stringly-typed and the particular string is
|
|
unfortunately not in a reusable constant.
|
|
|
|
Returns:
|
|
string name of the python-social-auth backend that has the duplicate
|
|
account, or None if there is no duplicate (and hence no error).
|
|
"""
|
|
social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')]
|
|
|
|
if not social_auth_messages:
|
|
return
|
|
|
|
assert len(social_auth_messages) == 1
|
|
backend_name = social_auth_messages[0].extra_tags.split()[1]
|
|
return backend_name
|
|
|
|
|
|
def get_provider_user_states(user):
|
|
"""Gets list of states of provider-user combinations.
|
|
|
|
Args:
|
|
django.contrib.auth.User. The user to get states for.
|
|
|
|
Returns:
|
|
List of ProviderUserState. The list of states of a user's account with
|
|
each enabled provider.
|
|
"""
|
|
states = []
|
|
found_user_auths = list(social_django.models.DjangoStorage.user.get_social_auth_for_user(user))
|
|
|
|
for enabled_provider in provider.Registry.enabled():
|
|
association = None
|
|
for auth in found_user_auths:
|
|
if enabled_provider.match_social_auth(auth):
|
|
association = auth
|
|
break
|
|
if enabled_provider.accepts_logins or association:
|
|
states.append(
|
|
ProviderUserState(enabled_provider, user, association)
|
|
)
|
|
|
|
return states
|
|
|
|
|
|
def running(request):
|
|
"""Returns True iff request is running a third-party auth pipeline."""
|
|
return get(request) is not None # Avoid False for {}.
|
|
|
|
|
|
# Pipeline functions.
|
|
# Signatures are set by python-social-auth; prepending 'unused_' causes
|
|
# TypeError on dispatch to the auth backend's authenticate().
|
|
# pylint: disable=unused-argument
|
|
|
|
|
|
def parse_query_params(strategy, response, *args, **kwargs):
|
|
"""Reads whitelisted query params, transforms them into pipeline args."""
|
|
# If auth_entry is not in the session, we got here by a non-standard workflow.
|
|
# We simply assume 'login' in that case.
|
|
auth_entry = strategy.request.session.get(AUTH_ENTRY_KEY, AUTH_ENTRY_LOGIN)
|
|
if auth_entry not in _AUTH_ENTRY_CHOICES:
|
|
raise AuthEntryError(strategy.request.backend, 'auth_entry invalid')
|
|
return {'auth_entry': auth_entry}
|
|
|
|
|
|
def set_pipeline_timeout(strategy, user, *args, **kwargs):
|
|
"""
|
|
Set a short session timeout while the pipeline runs, to improve security.
|
|
|
|
Consider the following attack:
|
|
1. Attacker on a public computer visits edX and initiates the third-party login flow
|
|
2. Attacker logs into their own third-party account
|
|
3. Attacker closes the window and does not complete the login flow
|
|
4. Victim on the same computer logs into edX with username/password
|
|
5. edX links attacker's third-party account with victim's edX account
|
|
6. Attacker logs into victim's edX account using attacker's own third-party account
|
|
|
|
We have two features of the pipeline designed to prevent this attack:
|
|
* This method shortens the Django session timeout during the pipeline. This should mean that
|
|
if there is a reasonable delay between steps 3 and 4, the session and pipeline will be
|
|
reset, and the attack foiled.
|
|
Configure the timeout with the SOCIAL_AUTH_PIPELINE_TIMEOUT setting (Default: 600 seconds)
|
|
* On step 4, the login page displays an obvious message to the user, saying "You've
|
|
successfully signed into (Google), but your (Google) account isn't linked with an edX
|
|
account. To link your accounts, login now using your edX password.".
|
|
"""
|
|
if strategy.request and not user: # If user is set, we're currently logged in (and/or linked) so it doesn't matter.
|
|
strategy.request.session.set_expiry(strategy.setting('PIPELINE_TIMEOUT', 600))
|
|
# We don't need to reset this timeout later. Because the user is not logged in and this
|
|
# account is not yet linked to an edX account, either the normal 'login' or 'register'
|
|
# code must occur during the subsequent ensure_user_information step, and those methods
|
|
# will change the session timeout to the "normal" value according to the "Remember Me"
|
|
# choice of the user.
|
|
|
|
|
|
def redirect_to_custom_form(request, auth_entry, details, kwargs):
|
|
"""
|
|
If auth_entry is found in AUTH_ENTRY_CUSTOM, this is used to send provider
|
|
data to an external server's registration/login page.
|
|
|
|
The data is sent as a base64-encoded values in a POST request and includes
|
|
a cryptographic checksum in case the integrity of the data is important.
|
|
"""
|
|
backend_name = request.backend.name
|
|
provider_id = provider.Registry.get_from_pipeline({'backend': backend_name, 'kwargs': kwargs}).provider_id
|
|
form_info = AUTH_ENTRY_CUSTOM[auth_entry]
|
|
secret_key = form_info['secret_key']
|
|
if isinstance(secret_key, six.text_type):
|
|
secret_key = secret_key.encode('utf-8')
|
|
custom_form_url = form_info['url']
|
|
data_bytes = json.dumps({
|
|
"auth_entry": auth_entry,
|
|
"backend_name": backend_name,
|
|
"provider_id": provider_id,
|
|
"user_details": details,
|
|
}).encode('utf-8')
|
|
digest = hmac.new(secret_key, msg=data_bytes, digestmod=hashlib.sha256).digest()
|
|
# Store the data in the session temporarily, then redirect to a page that will POST it to
|
|
# the custom login/register page.
|
|
request.session['tpa_custom_auth_entry_data'] = {
|
|
'data': base64.b64encode(data_bytes),
|
|
'hmac': base64.b64encode(digest),
|
|
'post_url': custom_form_url,
|
|
}
|
|
return redirect(reverse('tpa_post_to_custom_auth_form'))
|
|
|
|
|
|
@partial.partial
|
|
def ensure_user_information(strategy, auth_entry, backend=None, user=None, social=None, current_partial=None,
|
|
allow_inactive_user=False, details=None, *args, **kwargs):
|
|
"""
|
|
Ensure that we have the necessary information about a user (either an
|
|
existing account or registration data) to proceed with the pipeline.
|
|
"""
|
|
|
|
# We're deliberately verbose here to make it clear what the intended
|
|
# dispatch behavior is for the various pipeline entry points, given the
|
|
# current state of the pipeline. Keep in mind the pipeline is re-entrant
|
|
# and values will change on repeated invocations (for example, the first
|
|
# time through the login flow the user will be None so we dispatch to the
|
|
# login form; the second time it will have a value so we continue to the
|
|
# next pipeline step directly).
|
|
#
|
|
# It is important that we always execute the entire pipeline. Even if
|
|
# behavior appears correct without executing a step, it means important
|
|
# invariants have been violated and future misbehavior is likely.
|
|
def dispatch_to_login():
|
|
"""Redirects to the login page."""
|
|
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN])
|
|
|
|
def dispatch_to_register():
|
|
"""Redirects to the registration page."""
|
|
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
|
|
|
|
def should_force_account_creation():
|
|
""" For some third party providers, we auto-create user accounts """
|
|
current_provider = provider.Registry.get_from_pipeline({'backend': current_partial.backend, 'kwargs': kwargs})
|
|
return (current_provider and
|
|
(current_provider.skip_email_verification or current_provider.send_to_registration_first))
|
|
|
|
def is_provider_saml():
|
|
""" Verify that the third party provider uses SAML """
|
|
current_provider = provider.Registry.get_from_pipeline({'backend': current_partial.backend, 'kwargs': kwargs})
|
|
saml_providers_list = list(provider.Registry.get_enabled_by_backend_name('tpa-saml'))
|
|
return (current_provider and
|
|
current_provider.slug in [saml_provider.slug for saml_provider in saml_providers_list])
|
|
|
|
if current_partial:
|
|
strategy.session_set('partial_pipeline_token_', current_partial.token)
|
|
strategy.storage.partial.store(current_partial)
|
|
|
|
if not user:
|
|
# Use only email for user existence check in case of saml provider
|
|
if is_provider_saml():
|
|
user_details = {'email': details.get('email')} if details else None
|
|
else:
|
|
user_details = details
|
|
if user_exists(user_details or {}):
|
|
# User has not already authenticated and the details sent over from
|
|
# identity provider belong to an existing user.
|
|
return dispatch_to_login()
|
|
|
|
if is_api(auth_entry):
|
|
return HttpResponseBadRequest()
|
|
elif auth_entry == AUTH_ENTRY_LOGIN:
|
|
# User has authenticated with the third party provider but we don't know which edX
|
|
# account corresponds to them yet, if any.
|
|
if should_force_account_creation():
|
|
return dispatch_to_register()
|
|
return dispatch_to_login()
|
|
elif auth_entry == AUTH_ENTRY_REGISTER:
|
|
# User has authenticated with the third party provider and now wants to finish
|
|
# creating their edX account.
|
|
return dispatch_to_register()
|
|
elif auth_entry == AUTH_ENTRY_ACCOUNT_SETTINGS:
|
|
raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
|
|
elif auth_entry in AUTH_ENTRY_CUSTOM:
|
|
# Pass the username, email, etc. via query params to the custom entry page:
|
|
return redirect_to_custom_form(strategy.request, auth_entry, details or {}, kwargs)
|
|
else:
|
|
raise AuthEntryError(backend, 'auth_entry invalid')
|
|
|
|
if not user.is_active:
|
|
# The user account has not been verified yet.
|
|
if allow_inactive_user:
|
|
# This parameter is used by the auth_exchange app, which always allows users to
|
|
# login, whether or not their account is validated.
|
|
pass
|
|
elif social is None:
|
|
# The user has just registered a new account as part of this pipeline. Their account
|
|
# is inactive but we allow the login to continue, because if we pause again to force
|
|
# the user to activate their account via email, the pipeline may get lost (e.g.
|
|
# email takes too long to arrive, user opens the activation email on a different
|
|
# device, etc.). This is consistent with first party auth and ensures that the
|
|
# pipeline completes fully, which is critical.
|
|
pass
|
|
else:
|
|
# This is an existing account, linked to a third party provider but not activated.
|
|
# Double-check these criteria:
|
|
assert user is not None
|
|
assert social is not None
|
|
# We now also allow them to login again, because if they had entered their email
|
|
# incorrectly then there would be no way for them to recover the account, nor
|
|
# register anew via SSO. See SOL-1324 in JIRA.
|
|
# However, we will log a warning for this case:
|
|
logger.warning(
|
|
u'[THIRD_PARTY_AUTH] User is using third_party_auth to login but has not yet activated their account. '
|
|
u'Username: {username}'.format(username=user.username)
|
|
)
|
|
|
|
|
|
@partial.partial
|
|
def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=None, current_partial=None,
|
|
*args, **kwargs):
|
|
"""This pipeline step sets the "logged in" cookie for authenticated users.
|
|
|
|
Some installations have a marketing site front-end separate from
|
|
edx-platform. Those installations sometimes display different
|
|
information for logged in versus anonymous users (e.g. a link
|
|
to the student dashboard instead of the login page.)
|
|
|
|
Since social auth uses Django's native `login()` method, it bypasses
|
|
our usual login view that sets this cookie. For this reason, we need
|
|
to set the cookie ourselves within the pipeline.
|
|
|
|
The procedure for doing this is a little strange. On the one hand,
|
|
we need to send a response to the user in order to set the cookie.
|
|
On the other hand, we don't want to drop the user out of the pipeline.
|
|
|
|
For this reason, we send a redirect back to the "complete" URL,
|
|
so users immediately re-enter the pipeline. The redirect response
|
|
contains a header that sets the logged in cookie.
|
|
|
|
If the user is not logged in, or the logged in cookie is already set,
|
|
the function returns `None`, indicating that control should pass
|
|
to the next pipeline step.
|
|
|
|
"""
|
|
if not is_api(auth_entry) and user is not None and user.is_authenticated:
|
|
if not user.has_usable_password():
|
|
msg = "Your account is disabled"
|
|
return JsonResponse(msg, status=403)
|
|
request = strategy.request if strategy else None
|
|
# n.b. for new users, user.is_active may be False at this point; set the cookie anyways.
|
|
if request is not None:
|
|
# Check that the cookie isn't already set.
|
|
# This ensures that we allow the user to continue to the next
|
|
# pipeline step once he/she has the cookie set by this step.
|
|
has_cookie = user_authn_cookies.are_logged_in_cookies_set(request)
|
|
if not has_cookie:
|
|
try:
|
|
redirect_url = get_complete_url(current_partial.backend)
|
|
except ValueError:
|
|
# If for some reason we can't get the URL, just skip this step
|
|
# This may be overly paranoid, but it's far more important that
|
|
# the user log in successfully than that the cookie is set.
|
|
pass
|
|
else:
|
|
response = redirect(redirect_url)
|
|
return user_authn_cookies.set_logged_in_cookies(request, response, user)
|
|
|
|
|
|
@partial.partial
|
|
def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs):
|
|
""" Sends login info to Segment """
|
|
|
|
event_name = None
|
|
if auth_entry == AUTH_ENTRY_LOGIN:
|
|
event_name = 'edx.bi.user.account.authenticated'
|
|
elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]:
|
|
event_name = 'edx.bi.user.account.linked'
|
|
|
|
if event_name is not None:
|
|
segment.track(kwargs['user'].id, event_name, {
|
|
'category': "conversion",
|
|
'label': None,
|
|
'provider': kwargs['backend'].name
|
|
})
|
|
|
|
|
|
@partial.partial
|
|
def associate_by_email_if_login_api(auth_entry, backend, details, user, current_partial=None, *args, **kwargs):
|
|
"""
|
|
This pipeline step associates the current social auth with the user with the
|
|
same email address in the database. It defers to the social library's associate_by_email
|
|
implementation, which verifies that only a single database user is associated with the email.
|
|
|
|
This association is done ONLY if the user entered the pipeline through a LOGIN API.
|
|
"""
|
|
if auth_entry == AUTH_ENTRY_LOGIN_API:
|
|
association_response = associate_by_email(backend, details, user, *args, **kwargs)
|
|
if (
|
|
association_response and
|
|
association_response.get('user') and
|
|
association_response['user'].is_active
|
|
):
|
|
# Only return the user matched by email if their email has been activated.
|
|
# Otherwise, an illegitimate user can create an account with another user's
|
|
# email address and the legitimate user would now login to the illegitimate
|
|
# account.
|
|
return association_response
|
|
|
|
|
|
def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **kwargs):
|
|
"""
|
|
Update normally protected user details using data from provider.
|
|
|
|
This step in the pipeline is akin to `social_core.pipeline.user.user_details`, which updates
|
|
the user details but has an unconfigurable protection over updating the username & email, and
|
|
is unable to update information such as the user's full name which isn't on the user model, but
|
|
rather on the user profile model.
|
|
|
|
Additionally, because the email field is normally used to log in, if the email is changed by this
|
|
forced synchronization, we send an email to both the old and new emails, letting the user know.
|
|
|
|
This step is controlled by the `sync_learner_profile_data` flag on the provider's configuration.
|
|
"""
|
|
current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs})
|
|
if user and current_provider.sync_learner_profile_data:
|
|
# Keep track of which incoming values get applied.
|
|
changed = {}
|
|
|
|
# Map each incoming field from the provider to the name on the user model (by default, they always match).
|
|
field_mapping = {field: (user, field) for field in details.keys() if hasattr(user, field)}
|
|
|
|
# This is a special case where the field mapping should go to the user profile object and not the user object,
|
|
# in some cases with differing field names (i.e. 'fullname' vs. 'name').
|
|
field_mapping.update({
|
|
'fullname': (user.profile, 'name'),
|
|
'country': (user.profile, 'country'),
|
|
})
|
|
|
|
# Remove username from list of fields for update
|
|
field_mapping.pop('username', None)
|
|
|
|
# Track any fields that would raise an integrity error if there was a conflict.
|
|
integrity_conflict_fields = {'email': user.email, 'username': user.username}
|
|
|
|
for provider_field, (model, field) in field_mapping.items():
|
|
provider_value = details.get(provider_field)
|
|
current_value = getattr(model, field)
|
|
if provider_value is not None and current_value != provider_value:
|
|
if field in integrity_conflict_fields and User.objects.filter(**{field: provider_value}).exists():
|
|
logger.warning(u'[THIRD_PARTY_AUTH] Profile data synchronization conflict. '
|
|
u'UserId: {user_id}, Provider: {provider}, ConflictField: {conflict_field}, '
|
|
u'ConflictValue: {conflict_value}'.format(
|
|
user_id=user.id,
|
|
provider=current_provider.name,
|
|
conflict_field=field,
|
|
conflict_value=provider_value))
|
|
continue
|
|
changed[provider_field] = current_value
|
|
setattr(model, field, provider_value)
|
|
|
|
if changed:
|
|
logger.info(
|
|
u'[THIRD_PARTY_AUTH] User performed SSO and data was synchronized. '
|
|
u'Username: {username}, Provider: {provider}, UpdatedKeys: {updated_keys}'.format(
|
|
username=user.username,
|
|
provider=current_provider.name,
|
|
updated_keys=list(changed.keys())
|
|
)
|
|
)
|
|
|
|
# Save changes to user and user.profile models.
|
|
strategy.storage.user.changed(user)
|
|
user.profile.save()
|
|
|
|
# Send an email to the old and new email to alert the user that their login email changed.
|
|
if changed.get('email'):
|
|
old_email = changed['email']
|
|
new_email = user.email
|
|
email_context = {'old_email': old_email, 'new_email': new_email}
|
|
# Subjects shouldn't have new lines.
|
|
subject = ''.join(render_to_string(
|
|
'emails/sync_learner_profile_data_email_change_subject.txt',
|
|
email_context
|
|
).splitlines())
|
|
body = render_to_string('emails/sync_learner_profile_data_email_change_body.txt', email_context)
|
|
from_email = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
|
|
|
|
email = EmailMessage(subject=subject, body=body, from_email=from_email, to=[old_email, new_email])
|
|
email.content_subtype = "html"
|
|
try:
|
|
email.send()
|
|
except SMTPException:
|
|
logger.exception('[THIRD_PARTY_AUTH] Error sending IdP learner data sync-initiated email change '
|
|
u'notification email. Username: {username}'.format(username=user.username))
|
|
|
|
|
|
def set_id_verification_status(auth_entry, strategy, details, user=None, *args, **kwargs):
|
|
"""
|
|
Use the user's authentication with the provider, if configured, as evidence of their identity being verified.
|
|
"""
|
|
current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs})
|
|
if user and current_provider.enable_sso_id_verification:
|
|
# Get previous valid, non expired verification attempts for this SSO Provider and user
|
|
verifications = SSOVerification.objects.filter(
|
|
user=user,
|
|
status="approved",
|
|
created_at__gte=earliest_allowed_verification_date(),
|
|
identity_provider_type=current_provider.full_class_name,
|
|
identity_provider_slug=current_provider.slug,
|
|
)
|
|
|
|
# If there is none, create a new approved verification for the user.
|
|
if not verifications:
|
|
verification = SSOVerification.objects.create(
|
|
user=user,
|
|
status="approved",
|
|
name=user.profile.name,
|
|
identity_provider_type=current_provider.full_class_name,
|
|
identity_provider_slug=current_provider.slug,
|
|
)
|
|
# Send a signal so users who have already passed their courses receive credit
|
|
verification.send_approval_signal(current_provider.slug)
|
|
|
|
|
|
def get_username(strategy, details, backend, user=None, *args, **kwargs):
|
|
"""
|
|
Copy of social_core.pipeline.user.get_username with additional logging and case insensitive username checks.
|
|
"""
|
|
if 'username' not in backend.setting('USER_FIELDS', USER_FIELDS):
|
|
return
|
|
storage = strategy.storage
|
|
|
|
if not user:
|
|
email_as_username = strategy.setting('USERNAME_IS_FULL_EMAIL', False)
|
|
uuid_length = strategy.setting('UUID_LENGTH', 16)
|
|
max_length = storage.user.username_max_length()
|
|
do_slugify = strategy.setting('SLUGIFY_USERNAMES', False)
|
|
do_clean = strategy.setting('CLEAN_USERNAMES', True)
|
|
|
|
if do_clean:
|
|
override_clean = strategy.setting('CLEAN_USERNAME_FUNCTION')
|
|
if override_clean:
|
|
clean_func = module_member(override_clean)
|
|
else:
|
|
clean_func = storage.user.clean_username
|
|
else:
|
|
clean_func = lambda val: val
|
|
|
|
if do_slugify:
|
|
override_slug = strategy.setting('SLUGIFY_FUNCTION')
|
|
if override_slug:
|
|
slug_func = module_member(override_slug)
|
|
else:
|
|
slug_func = slugify
|
|
else:
|
|
slug_func = lambda val: val
|
|
|
|
if email_as_username and details.get('email'):
|
|
username = details['email']
|
|
elif details.get('username'):
|
|
username = details['username']
|
|
else:
|
|
username = uuid4().hex
|
|
|
|
short_username = (username[:max_length - uuid_length]
|
|
if max_length is not None
|
|
else username)
|
|
final_username = slug_func(clean_func(username[:max_length]))
|
|
|
|
# Generate a unique username for current user using username
|
|
# as base but adding a unique hash at the end. Original
|
|
# username is cut to avoid any field max_length.
|
|
# The final_username may be empty and will skip the loop.
|
|
# We are using our own version of user_exists to avoid possible case sensitivity issues.
|
|
while not final_username or user_exists({'username': final_username}):
|
|
username = short_username + uuid4().hex[:uuid_length]
|
|
final_username = slug_func(clean_func(username[:max_length]))
|
|
logger.info(u'[THIRD_PARTY_AUTH] New username generated. Username: {username}'.format(
|
|
username=final_username))
|
|
else:
|
|
final_username = storage.user.get_username(user)
|
|
return {'username': final_username}
|