pipeline.py 33.6 KB
Newer Older
1
"""Auth pipeline definitions.
2

3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
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.

57
See http://python-social-auth.readthedocs.io/en/latest/pipeline.html for more docs.
58 59
"""

60 61 62 63
import base64
import hashlib
import hmac
import json
64
import random
65
import string
66
import urllib
67 68
from collections import OrderedDict
from logging import getLogger
69
from smtplib import SMTPException
70

71
import analytics
72
from django.conf import settings
73
from django.contrib.auth.models import User
74
from django.core.mail.message import EmailMessage
75
from django.core.urlresolvers import reverse
76
from django.http import HttpResponseBadRequest
77
from django.shortcuts import redirect
78 79 80 81
import social_django
from social_core.exceptions import AuthException
from social_core.pipeline import partial
from social_core.pipeline.social_auth import associate_by_email
82

83
import student
84
from edxmako.shortcuts import render_to_string
85
from eventtracking import tracker
86
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
Julia Hansbrough committed
87

88 89
from . import provider

90 91 92 93 94 95 96 97 98
# 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`)
99
AUTH_ENTRY_KEY = 'auth_entry'
100 101
AUTH_REDIRECT_KEY = 'next'

102 103

# The following are various possible values for the AUTH_ENTRY_KEY.
104 105
AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register'
106
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
107

108 109 110 111
# 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'

112 113 114 115 116 117 118 119 120 121 122 123
# 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', {})

124 125 126 127

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)
128

129 130 131 132 133 134 135 136
# 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 = {
137 138
    AUTH_ENTRY_LOGIN: '/login',
    AUTH_ENTRY_REGISTER: '/register',
139
    AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
140 141
}

142 143
_AUTH_ENTRY_CHOICES = frozenset([
    AUTH_ENTRY_LOGIN,
144
    AUTH_ENTRY_REGISTER,
145
    AUTH_ENTRY_ACCOUNT_SETTINGS,
146 147
    AUTH_ENTRY_LOGIN_API,
    AUTH_ENTRY_REGISTER_API,
148
] + AUTH_ENTRY_CUSTOM.keys())
149

150 151 152
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
_PASSWORD_CHARSET = string.letters + string.digits

Julia Hansbrough committed
153 154
logger = getLogger(__name__)

155 156

class AuthEntryError(AuthException):
157
    """Raised when auth_entry is invalid on URLs.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177

    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.
    """

178
    def __init__(self, enabled_provider, user, association):
179
        # Boolean. Whether the user has an account associated with the provider
180 181 182 183 184 185 186 187 188
        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
189 190 191 192 193 194 195 196
        # 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."""
197
        return self.provider.provider_id + '_unlink_form'
198 199 200


def get(request):
201 202 203 204 205 206 207 208
    """Gets the running pipeline's data from the passed request."""
    strategy = social_django.utils.load_strategy(request)
    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
209 210


211 212 213 214 215 216 217 218 219 220
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):
221
            social = social_django.models.UserSocialAuth.objects.get(**social)
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242
        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)


243
def get_authenticated_user(auth_provider, username, uid):
244 245 246 247 248 249 250 251
    """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:
252
        auth_provider: the third_party_auth provider in use for the current pipeline.
253
        username: string. Username of user to get.
254
        uid: string. The user ID according to the third party.
255 256 257

    Returns:
        User if user is found and has a social auth from the passed
258
        provider.
259 260 261 262 263 264

    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.
    """
265
    match = social_django.models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid)
266

267
    if not match or match.user.username != username:
268 269
        raise User.DoesNotExist

270 271
    user = match.user
    user.backend = auth_provider.get_authentication_backend()
272 273 274
    return user


275 276 277
def _get_enabled_provider(provider_id):
    """Gets an enabled provider by its provider_id member or throws."""
    enabled_provider = provider.Registry.get(provider_id)
278 279

    if not enabled_provider:
280
        raise ValueError('Provider %s not enabled' % provider_id)
281 282 283 284

    return enabled_provider


285 286
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None,
             extra_params=None, url_params=None):
287
    """Creates a URL to hook into social auth endpoints."""
288 289 290
    url_params = url_params or {}
    url_params['backend'] = backend_name
    url = reverse(view_name, kwargs=url_params)
291

292
    query_params = OrderedDict()
293
    if auth_entry:
294 295 296 297
        query_params[AUTH_ENTRY_KEY] = auth_entry

    if redirect_url:
        query_params[AUTH_REDIRECT_KEY] = redirect_url
298

299 300 301
    if extra_params:
        query_params.update(extra_params)

302 303 304 305
    return u"{url}?{params}".format(
        url=url,
        params=urllib.urlencode(query_params)
    )
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320


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.
    """
321
    if not any(provider.Registry.get_enabled_by_backend_name(backend_name)):
322 323 324 325 326
        raise ValueError('Provider with backend %s not enabled' % backend_name)

    return _get_url('social:complete', backend_name)


327
def get_disconnect_url(provider_id, association_id):
328 329 330
    """Gets URL for the endpoint that starts the disconnect pipeline.

    Args:
331
        provider_id: string identifier of the social_django.models.ProviderConfig child you want
332
            to disconnect from.
333 334
        association_id: int. Optional ID of a specific row in the UserSocialAuth
            table to disconnect (useful if multiple providers use a common backend)
335 336 337 338 339

    Returns:
        String. URL that starts the disconnection pipeline.

    Raises:
340
        ValueError: if no provider is enabled with the given ID.
341
    """
342
    backend_name = _get_enabled_provider(provider_id).backend_name
343 344 345 346
    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)
347 348


349
def get_login_url(provider_id, auth_entry, redirect_url=None):
350 351 352
    """Gets the login URL for the endpoint that kicks off auth with a provider.

    Args:
353
        provider_id: string identifier of the social_django.models.ProviderConfig child you want
354
            to disconnect from.
355 356 357 358
        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.

359 360 361 362
    Keyword Args:
        redirect_url (string): If provided, redirect to this URL at the end
            of the authentication process.

363 364 365 366
    Returns:
        String. URL that starts the auth pipeline for a provider.

    Raises:
367
        ValueError: if no provider is enabled with the given provider_id.
368 369
    """
    assert auth_entry in _AUTH_ENTRY_CHOICES
370
    enabled_provider = _get_enabled_provider(provider_id)
371 372
    return _get_url(
        'social:begin',
373
        enabled_provider.backend_name,
374 375
        auth_entry=auth_entry,
        redirect_url=redirect_url,
376
        extra_params=enabled_provider.get_url_params(),
377
    )
378 379 380 381 382 383 384 385 386 387


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'.

388 389 390
    This messaging approach is stringly-typed and the particular string is
    unfortunately not in a reusable constant.

391
    Returns:
392
        string name of the python-social-auth backend that has the duplicate
393 394
        account, or None if there is no duplicate (and hence no error).
    """
395
    social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')]
396 397 398 399 400

    if not social_auth_messages:
        return

    assert len(social_auth_messages) == 1
401 402
    backend_name = social_auth_messages[0].extra_tags.split()[1]
    return backend_name
403 404 405 406 407 408 409 410 411 412 413 414 415


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 = []
416
    found_user_auths = list(social_django.models.DjangoStorage.user.get_social_auth_for_user(user))
417 418

    for enabled_provider in provider.Registry.enabled():
419
        association = None
420 421
        for auth in found_user_auths:
            if enabled_provider.match_social_auth(auth):
422
                association = auth
423
                break
424
        if enabled_provider.accepts_logins or association:
425
            states.append(
426
                ProviderUserState(enabled_provider, user, association)
427
            )
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454

    return states


def make_random_password(length=None, choice_fn=random.SystemRandom().choice):
    """Makes a random password.

    When a user creates an account via a social provider, we need to create a
    placeholder password for them to satisfy the ORM's consistency and
    validation requirements. Users don't know (and hence cannot sign in with)
    this password; that's OK because they can always use the reset password
    flow to set it to a known value.

    Args:
        choice_fn: function or method. Takes an iterable and returns a random
            element.
        length: int. Number of chars in the returned value. None to use default.

    Returns:
        String. The resulting password.
    """
    length = length if length is not None else _DEFAULT_RANDOM_PASSWORD_LENGTH
    return ''.join(choice_fn(_PASSWORD_CHARSET) for _ in xrange(length))


def running(request):
    """Returns True iff request is running a third-party auth pipeline."""
455
    return get(request) is not None  # Avoid False for {}.
456 457 458 459 460


# Pipeline functions.
# Signatures are set by python-social-auth; prepending 'unused_' causes
# TypeError on dispatch to the auth backend's authenticate().
461
# pylint: disable=unused-argument
462 463 464 465


def parse_query_params(strategy, response, *args, **kwargs):
    """Reads whitelisted query params, transforms them into pipeline args."""
466 467 468 469 470
    # 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')
471
    return {'auth_entry': auth_entry}
472

473

474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503
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.


504
def redirect_to_custom_form(request, auth_entry, kwargs):
505 506 507 508 509 510 511
    """
    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.
    """
512 513
    backend_name = request.backend.name
    provider_id = provider.Registry.get_from_pipeline({'backend': backend_name, 'kwargs': kwargs}).provider_id
514 515 516 517 518 519
    form_info = AUTH_ENTRY_CUSTOM[auth_entry]
    secret_key = form_info['secret_key']
    if isinstance(secret_key, unicode):
        secret_key = secret_key.encode('utf-8')
    custom_form_url = form_info['url']
    data_str = json.dumps({
520 521 522 523
        "auth_entry": auth_entry,
        "backend_name": backend_name,
        "provider_id": provider_id,
        "user_details": kwargs['details'],
524 525 526 527 528 529 530 531 532 533 534 535
    })
    digest = hmac.new(secret_key, msg=data_str, 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_str),
        'hmac': base64.b64encode(digest),
        'post_url': custom_form_url,
    }
    return redirect(reverse('tpa_post_to_custom_auth_form'))


536
@partial.partial
537
def ensure_user_information(strategy, auth_entry, backend=None, user=None, social=None, current_partial=None,
538
                            allow_inactive_user=False, *args, **kwargs):
539 540 541 542
    """
    Ensure that we have the necessary information about a user (either an
    existing account or registration data) to proceed with the pipeline.
    """
543 544

    # We're deliberately verbose here to make it clear what the intended
545
    # dispatch behavior is for the various pipeline entry points, given the
546 547 548 549 550 551 552 553 554
    # 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.
555 556
    def dispatch_to_login():
        """Redirects to the login page."""
557
        return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN])
558

559 560
    def dispatch_to_register():
        """Redirects to the registration page."""
561
        return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
John Cox committed
562

563 564
    def should_force_account_creation():
        """ For some third party providers, we auto-create user accounts """
565
        current_provider = provider.Registry.get_from_pipeline({'backend': current_partial.backend, 'kwargs': kwargs})
566 567
        return (current_provider and
                (current_provider.skip_email_verification or current_provider.send_to_registration_first))
568

569
    if not user:
570
        if is_api(auth_entry):
571
            return HttpResponseBadRequest()
572
        elif auth_entry == AUTH_ENTRY_LOGIN:
573 574
            # User has authenticated with the third party provider but we don't know which edX
            # account corresponds to them yet, if any.
575 576
            if should_force_account_creation():
                return dispatch_to_register()
577
            return dispatch_to_login()
578
        elif auth_entry == AUTH_ENTRY_REGISTER:
579 580
            # User has authenticated with the third party provider and now wants to finish
            # creating their edX account.
581
            return dispatch_to_register()
582 583
        elif auth_entry == AUTH_ENTRY_ACCOUNT_SETTINGS:
            raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
584 585
        elif auth_entry in AUTH_ENTRY_CUSTOM:
            # Pass the username, email, etc. via query params to the custom entry page:
586
            return redirect_to_custom_form(strategy.request, auth_entry, kwargs)
587 588 589 590 591 592 593 594 595
        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
596 597 598 599 600 601 602 603 604 605
        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.
606 607 608
            # Double-check these criteria:
            assert user is not None
            assert social is not None
609 610 611 612 613 614 615 616
            # 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(
                'User "%s" is using third_party_auth to login but has not yet activated their account. ',
                user.username
            )
617

618

Julia Hansbrough committed
619
@partial.partial
620 621
def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=None, current_partial=None,
                          *args, **kwargs):
622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645
    """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.

    """
646
    if not is_api(auth_entry) and user is not None and user.is_authenticated():
647
        request = strategy.request if strategy else None
648
        # n.b. for new users, user.is_active may be False at this point; set the cookie anyways.
649 650 651 652
        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.
Will Daly committed
653
            has_cookie = student.cookies.is_logged_in_cookie_set(request)
654 655
            if not has_cookie:
                try:
656
                    redirect_url = get_complete_url(current_partial.backend)
657 658 659 660 661 662 663
                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)
Will Daly committed
664
                    return student.cookies.set_logged_in_cookies(request, response, user)
665 666


Julia Hansbrough committed
667
@partial.partial
668
def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs):
669
    """ Sends login info to Segment """
Julia Hansbrough committed
670

671
    event_name = None
672
    if auth_entry == AUTH_ENTRY_LOGIN:
673
        event_name = 'edx.bi.user.account.authenticated'
674
    elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]:
675
        event_name = 'edx.bi.user.account.linked'
Julia Hansbrough committed
676

677
    if event_name is not None and hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
Julia Hansbrough committed
678 679 680 681 682 683
        tracking_context = tracker.get_tracker().resolve_context()
        analytics.track(
            kwargs['user'].id,
            event_name,
            {
                'category': "conversion",
684
                'label': None,
685
                'provider': kwargs['backend'].name
Julia Hansbrough committed
686 687
            },
            context={
688
                'ip': tracking_context.get('ip'),
Julia Hansbrough committed
689
                'Google Analytics': {
690
                    'clientId': tracking_context.get('client_id')
Julia Hansbrough committed
691 692 693
                }
            }
        )
Julia Hansbrough committed
694

695

696
@partial.partial
697
def associate_by_email_if_login_api(auth_entry, backend, details, user, current_partial=None, *args, **kwargs):
698 699 700 701 702 703 704 705
    """
    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:
706
        association_response = associate_by_email(backend, details, user, *args, **kwargs)
707 708 709 710 711 712 713 714 715 716
        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
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792


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'),
        })

        # 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('User with ID [%s] tried to synchronize profile data through [%s] '
                                   'but there was a conflict with an existing [%s]: [%s].',
                                   user.id, current_provider.name, field, provider_value)
                    continue
                changed[provider_field] = current_value
                setattr(model, field, provider_value)

        if changed:
            logger.info(
                "User [%s] performed SSO through [%s] who synchronizes profile data, and the "
                "following fields were changed: %s", user.username, current_provider.name, 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('Error sending IdP learner data sync-initiated email change '
                                     'notification email for user [%s].', user.username)