diff --git a/AUTHORS b/AUTHORS index e2ec83c3bf63039b2c1adb6fa1dc18297e6480d0..3f6af3d3758afab815a0c5a9c9563d76ba4a1288 100644 --- a/AUTHORS +++ b/AUTHORS @@ -141,3 +141,4 @@ William Desloge <william.desloge@ionis-group.com> Marco Re <mrc.re@tiscali.it> Jonas Jelten <jelten@in.tum.de> Christine Lytwynec <clytwynec@edx.org> +John Cox <johncox@google.com> diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d2e8b32d6b25567a0bb6d86915993527df814bf5..34c30c45035afba565fff00a4994892a067fd7a8 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -16,6 +16,7 @@ from django.contrib.auth import logout, authenticate, login from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.decorators import login_required from django.contrib.auth.views import password_reset_confirm +from django.contrib import messages from django.core.cache import cache from django.core.context_processors import csrf from django.core.mail import send_mail @@ -85,6 +86,8 @@ from util.password_policy_validators import ( validate_password_dictionary ) +from third_party_auth import pipeline, provider + log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -363,11 +366,16 @@ def signin_user(request): context = { 'course_id': request.GET.get('course_id'), 'enrollment_action': request.GET.get('enrollment_action'), + # Bool injected into JS to submit form if we're inside a running third- + # party auth pipeline; distinct from the actual instance of the running + # pipeline, if any. + 'pipeline_running': 'true' if pipeline.running(request) else 'false', 'platform_name': microsite.get_value( 'platform_name', settings.PLATFORM_NAME ), } + return render_to_response('login.html', context) @@ -385,17 +393,34 @@ def register_user(request, extra_context=None): context = { 'course_id': request.GET.get('course_id'), + 'email': '', 'enrollment_action': request.GET.get('enrollment_action'), + 'name': '', + 'running_pipeline': None, 'platform_name': microsite.get_value( 'platform_name', settings.PLATFORM_NAME ), + 'selected_provider': '', + 'username': '', } + if extra_context is not None: context.update(extra_context) if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): return render_to_response('register-shib.html', context) + + # If third-party auth is enabled, prepopulate the form with data from the + # selected provider. + if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request): + running_pipeline = pipeline.get(request) + current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) + overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) + overrides['running_pipeline'] = running_pipeline + overrides['selected_provider'] = current_provider.NAME + context.update(overrides) + return render_to_response('register.html', context) @@ -532,8 +557,17 @@ def dashboard(request): 'language_options': language_options, 'current_language': current_language, 'current_language_code': cur_lang_code, + 'user': user, + 'duplicate_provider': None, + 'logout_url': reverse(logout_user), + 'platform_name': settings.PLATFORM_NAME, + 'provider_states': [], } + if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) + context['provider_user_states'] = pipeline.get_provider_user_states(user) + return render_to_response('dashboard.html', context) @@ -690,6 +724,7 @@ def accounts_login(request): return external_auth.views.course_specific_login(request, course_id) context = { + 'pipeline_running': 'false', 'platform_name': settings.PLATFORM_NAME, } return render_to_response('login.html', context) @@ -697,24 +732,62 @@ def accounts_login(request): # Need different levels of logging @ensure_csrf_cookie -def login_user(request, error=""): +def login_user(request, error=""): # pylint: disable-msg=too-many-statements,unused-argument """AJAX request to log in the user.""" - if 'email' not in request.POST or 'password' not in request.POST: - return JsonResponse({ - "success": False, - "value": _('There was an error receiving your login information. Please email us.'), # TODO: User error message - }) # TODO: this should be status code 400 # pylint: disable=fixme - email = request.POST['email'] - password = request.POST['password'] - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"Login failed - Unknown user email") - else: - AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) - user = None + backend_name = None + email = None + password = None + redirect_url = None + response = None + running_pipeline = None + third_party_auth_requested = settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request) + third_party_auth_successful = False + trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) + user = None + + if third_party_auth_requested and not trumped_by_first_party_auth: + # The user has already authenticated via third-party auth and has not + # asked to do first party auth by supplying a username or password. We + # now want to put them through the same logging and cookie calculation + # logic as with first-party auth. + running_pipeline = pipeline.get(request) + username = running_pipeline['kwargs'].get('username') + backend_name = running_pipeline['backend'] + requested_provider = provider.Registry.get_by_backend_name(backend_name) + + try: + user = pipeline.get_authenticated_user(username, backend_name) + third_party_auth_successful = True + except User.DoesNotExist: + AUDIT_LOG.warning( + u'Login failed - user with username {username} has no social auth with backend_name {backend_name}'.format( + username=username, backend_name=backend_name)) + return JsonResponse({ + "success": False, + # Translators: provider_name is the name of an external, third-party user authentication service (like + # Google or LinkedIn). + "value": _('There is no {platform_name} account associated with your {provider_name} account. Please use your {platform_name} credentials or pick another provider.').format( + platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME) + }) # TODO: this should be a status code 401 # pylint: disable=fixme + + else: + + if 'email' not in request.POST or 'password' not in request.POST: + return JsonResponse({ + "success": False, + "value": _('There was an error receiving your login information. Please email us.'), # TODO: User error message + }) # TODO: this should be status code 400 # pylint: disable=fixme + + email = request.POST['email'] + password = request.POST['password'] + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"Login failed - Unknown user email") + else: + AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) # check if the user has a linked shibboleth account, if so, redirect the user to shib-login # This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu @@ -753,14 +826,17 @@ def login_user(request, error=""): # username so that authentication is guaranteed to fail and we can take # advantage of the ratelimited backend username = user.username if user else "" - try: - user = authenticate(username=username, password=password, request=request) - # this occurs when there are too many attempts from the same IP address - except RateLimitException: - return JsonResponse({ - "success": False, - "value": _('Too many failed login attempts. Try again later.'), - }) # TODO: this should be status code 429 # pylint: disable=fixme + + if not third_party_auth_successful: + try: + user = authenticate(username=username, password=password, request=request) + # this occurs when there are too many attempts from the same IP address + except RateLimitException: + return JsonResponse({ + "success": False, + "value": _('Too many failed login attempts. Try again later.'), + }) # TODO: this should be status code 429 # pylint: disable=fixme + if user is None: # tick the failed login counters if the user exists in the database if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): @@ -801,7 +877,9 @@ def login_user(request, error=""): redirect_url = try_change_enrollment(request) - dog_stats_api.increment("common.student.successful_login") + if third_party_auth_successful: + redirect_url = pipeline.get_complete_url(backend_name) + response = JsonResponse({ "success": True, "redirect_url": redirect_url, @@ -1027,16 +1105,20 @@ def _do_create_account(post_vars): @ensure_csrf_cookie -def create_account(request, post_override=None): +def create_account(request, post_override=None): # pylint: disable-msg=too-many-statements """ JSON call to create new edX account. Used by form in signup_modal.html, which is included into navigation.html """ - js = {'success': False} + js = {'success': False} # pylint: disable-msg=invalid-name post_vars = post_override if post_override else request.POST extra_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request): + post_vars = dict(post_vars.items()) + post_vars.update({'password': pipeline.make_random_password()}) + # if doing signup for an external authorization, then get email, password, name from the eamap # don't use the ones from the form, since the user could have hacked those # unless originally we didn't get a valid email or name from the external auth @@ -1234,9 +1316,17 @@ def create_account(request, post_override=None): login_user.save() AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(login_user.username, login_user.email)) + dog_stats_api.increment("common.student.account_created") + redirect_url = try_change_enrollment(request) + + # Resume the third-party-auth pipeline if necessary. + if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request): + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(running_pipeline['backend']) + response = JsonResponse({ 'success': True, - 'redirect_url': try_change_enrollment(request), + 'redirect_url': redirect_url, }) # set the login cookie for the edx marketing site diff --git a/common/djangoapps/third_party_auth/middleware.py b/common/djangoapps/third_party_auth/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..677534d0fc1a412ed61c03644c8cac2428850d01 --- /dev/null +++ b/common/djangoapps/third_party_auth/middleware.py @@ -0,0 +1,18 @@ +"""Middleware classes for third_party_auth.""" + +from social.apps.django_app.middleware import SocialAuthExceptionMiddleware + +from . import pipeline + + +class ExceptionMiddleware(SocialAuthExceptionMiddleware): + """Custom middleware that handles conditional redirection.""" + + def get_redirect_uri(self, request, exception): + # Safe because it's already been validated by + # pipeline.parse_query_params. If that pipeline step ever moves later + # in the pipeline stack, we'd need to validate this value because it + # would be an injection point for attacker data. + auth_entry = request.session.get(pipeline.AUTH_ENTRY_KEY) + # Fall back to django settings's SOCIAL_AUTH_LOGIN_ERROR_URL. + return '/' + auth_entry if auth_entry else super(ExceptionMiddleware, self).get_redirect_uri(request, exception) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 1f9c56906c192f257f6156493b8c4f11b0bfdd7e..49da12c660bdaeded859adcc4401dc0deb6e93c6 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -1,9 +1,355 @@ -"""Auth pipeline definitions.""" +"""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 http://psa.matiasaguirre.net/docs/pipeline.html for more docs. +""" + +import random +import string # pylint: disable-msg=deprecated-module + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.shortcuts import redirect +from social.apps.django_app.default import models +from social.exceptions import AuthException from social.pipeline import partial +from . import provider + + +AUTH_ENTRY_KEY = 'auth_entry' +AUTH_ENTRY_DASHBOARD = 'dashboard' +AUTH_ENTRY_LOGIN = 'login' +AUTH_ENTRY_REGISTER = 'register' +_AUTH_ENTRY_CHOICES = frozenset([ + AUTH_ENTRY_DASHBOARD, + AUTH_ENTRY_LOGIN, + AUTH_ENTRY_REGISTER +]) +_DEFAULT_RANDOM_PASSWORD_LENGTH = 12 +_PASSWORD_CHARSET = string.letters + string.digits + + +class AuthEntryError(AuthException): + """Raised when auth_entry is missing or 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, state): + # Boolean. Whether the user has an account associated with the provider + self.has_account = state + # 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.NAME + '_unlink_form' + + +def get(request): + """Gets the running pipeline from the passed request.""" + return request.session.get('partial_pipeline') + + +def get_authenticated_user(username, backend_name): + """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: + username: string. Username of user to get. + backend_name: string. The name of the third-party auth backend from + the running pipeline. + + Returns: + User if user is found and has a social auth from the passed + backend_name. + + 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. + """ + user = models.DjangoStorage.user.user_model().objects.get(username=username) + match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name) + + if not match: + raise User.DoesNotExist + + user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend() + return user + + +def _get_enabled_provider_by_name(provider_name): + """Gets an enabled provider by its NAME member or throws.""" + enabled_provider = provider.Registry.get(provider_name) + + if not enabled_provider: + raise ValueError('Provider %s not enabled' % provider_name) + + return enabled_provider + + +def _get_url(view_name, backend_name, auth_entry=None): + """Creates a URL to hook into social auth endpoints.""" + kwargs = {'backend': backend_name} + url = reverse(view_name, kwargs=kwargs) + + if auth_entry: + url += '?%s=%s' % (AUTH_ENTRY_KEY, auth_entry) + + return url + + +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. + """ + enabled_provider = provider.Registry.get_by_backend_name(backend_name) + + if not enabled_provider: + raise ValueError('Provider with backend %s not enabled' % backend_name) + + return _get_url('social:complete', backend_name) + + +def get_disconnect_url(provider_name): + """Gets URL for the endpoint that starts the disconnect pipeline. + + Args: + provider_name: string. Name of the provider.BaseProvider child you want + to disconnect from. + + Returns: + String. URL that starts the disconnection pipeline. + + Raises: + ValueError: if no provider is enabled with the given backend_name. + """ + enabled_provider = _get_enabled_provider_by_name(provider_name) + return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name) + + +def get_login_url(provider_name, auth_entry): + """Gets the login URL for the endpoint that kicks off auth with a provider. + + Args: + provider_name: string. The name of the provider.Provider that has been + enabled. + 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. + + Returns: + String. URL that starts the auth pipeline for a provider. + + Raises: + ValueError: if no provider is enabled with the given provider_name. + """ + assert auth_entry in _AUTH_ENTRY_CHOICES + enabled_provider = _get_enabled_provider_by_name(provider_name) + return _get_url('social:begin', enabled_provider.BACKEND_CLASS.name, auth_entry=auth_entry) + + +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'. + + Returns: + provider.BaseProvider child instance. The provider of the duplicate + account, or None if there is no duplicate (and hence no error). + """ + social_auth_messages = [m for m in messages if m.extra_tags.startswith('social-auth')] + + if not social_auth_messages: + return + + assert len(social_auth_messages) == 1 + return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1]) + + +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_backends = [ + social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user) + ] + + for enabled_provider in provider.Registry.enabled(): + states.append( + ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends) + ) + + 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.""" + return request.session.get('partial_pipeline') 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-msg=unused-argument + + +def parse_query_params(strategy, response, *args, **kwargs): + """Reads whitelisted query params, transforms them into pipeline args.""" + auth_entry = strategy.session.get(AUTH_ENTRY_KEY) + + if not (auth_entry and auth_entry in _AUTH_ENTRY_CHOICES): + raise AuthEntryError(strategy.backend, 'auth_entry missing or invalid') + + return { + # Whether the auth pipeline entered from /dashboard. + 'is_dashboard': auth_entry == AUTH_ENTRY_DASHBOARD, + # Whether the auth pipeline entered from /login. + 'is_login': auth_entry == AUTH_ENTRY_LOGIN, + # Whether the auth pipeline entered from /register. + 'is_register': auth_entry == AUTH_ENTRY_REGISTER, + } + @partial.partial -def step(*args, **kwargs): - """Fake pipeline step; just throws loudly for now.""" - raise NotImplementedError('%s, %s' % (args, kwargs)) +def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_register=None, user=None, *args, **kwargs): + """Dispatches user to views outside the pipeline if necessary.""" + + # We're deliberately verbose here to make it clear what the intended + # dispatch behavior is for the three 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. + + if is_dashboard: + return + + if is_login and user is None: + return redirect('/login', name='signin_user') + + if is_register and user is None: + return redirect('/register', name='register_user') diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 1b1e17796d436fe28f524ca79c38072de5e56126..4067db832f5ec6a17a2ca7bac5999340b8bd57f7 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -4,6 +4,10 @@ Loaded by Django's settings mechanism. Consequently, this module must not invoke the Django armature. """ +from social.backends import google, linkedin + +_DEFAULT_ICON_CLASS = 'icon-signin' + class BaseProvider(object): """Abstract base class for third-party auth providers. @@ -12,22 +16,96 @@ class BaseProvider(object): in the provider Registry. """ - # String. Dot-delimited module.Class. The name of the backend - # implementation to load. - AUTHENTICATION_BACKEND = None + # Class. The provider's backing social.backends.base.BaseAuth child. + BACKEND_CLASS = None + # String. Name of the FontAwesome glyph to use for sign in buttons (or the + # name of a user-supplied custom glyph that is present at runtime). + ICON_CLASS = _DEFAULT_ICON_CLASS # String. User-facing name of the provider. Must be unique across all - # enabled providers. + # enabled providers. Will be presented in the UI. NAME = None - # Dict of string -> object. Settings that will be merged into Django's # settings instance. In most cases the value will be None, since real # values are merged from .json files (foo.auth.json; foo.env.json) onto the # settings instance during application initialization. SETTINGS = {} + @classmethod + def get_authentication_backend(cls): + """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" + return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__) + + @classmethod + def get_email(cls, unused_provider_details): + """Gets user's email address. + + Provider responses can contain arbitrary data. This method can be + overridden to extract an email address from the provider details + extracted by the social_details pipeline step. + + Args: + unused_provider_details: dict of string -> string. Data about the + user passed back by the provider. + + Returns: + String or None. The user's email address, if any. + """ + return None + + @classmethod + def get_name(cls, unused_provider_details): + """Gets user's name. + + Provider responses can contain arbitrary data. This method can be + overridden to extract a full name for a user from the provider details + extracted by the social_details pipeline step. + + Args: + unused_provider_details: dict of string -> string. Data about the + user passed back by the provider. + + Returns: + String or None. The user's full name, if any. + """ + return None + + @classmethod + def get_register_form_data(cls, pipeline_kwargs): + """Gets dict of data to display on the register form. + + common.djangoapps.student.views.register_user uses this to populate the + new account creation form with values supplied by the user's chosen + provider, preventing duplicate data entry. + + Args: + pipeline_kwargs: dict of string -> object. Keyword arguments + accumulated by the pipeline thus far. + + Returns: + Dict of string -> string. Keys are names of form fields; values are + values for that field. Where there is no value, the empty string + must be used. + """ + # Details about the user sent back from the provider. + details = pipeline_kwargs.get('details') + + # Get the username separately to take advantage of the de-duping logic + # built into the pipeline. The provider cannot de-dupe because it can't + # check the state of taken usernames in our system. Note that there is + # technically a data race between the creation of this value and the + # creation of the user object, so it is still possible for users to get + # an error on submit. + suggested_username = pipeline_kwargs.get('username') + + return { + 'email': cls.get_email(details) or '', + 'name': cls.get_name(details) or '', + 'username': suggested_username, + } + @classmethod def merge_onto(cls, settings): - """Merge class-level settings onto a django `settings` module.""" + """Merge class-level settings onto a django settings module.""" for key, value in cls.SETTINGS.iteritems(): setattr(settings, key, value) @@ -35,30 +113,41 @@ class BaseProvider(object): class GoogleOauth2(BaseProvider): """Provider for Google's Oauth2 auth system.""" - AUTHENTICATION_BACKEND = 'social.backends.google.GoogleOAuth2' + BACKEND_CLASS = google.GoogleOAuth2 + ICON_CLASS = 'icon-google-plus' NAME = 'Google' SETTINGS = { 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None, 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None, } + @classmethod + def get_email(cls, provider_details): + return provider_details.get('email') + + @classmethod + def get_name(cls, provider_details): + return provider_details.get('fullname') + class LinkedInOauth2(BaseProvider): """Provider for LinkedIn's Oauth2 auth system.""" - AUTHENTICATION_BACKEND = 'social.backends.linkedin.LinkedinOAuth2' + BACKEND_CLASS = linkedin.LinkedinOAuth2 + ICON_CLASS = 'icon-linkedin' NAME = 'LinkedIn' SETTINGS = { 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None, 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None, } + @classmethod + def get_email(cls, provider_details): + return provider_details.get('email') -class MozillaPersona(BaseProvider): - """Provider for Mozilla's Persona auth system.""" - - AUTHENTICATION_BACKEND = 'social.backends.persona.PersonaAuth' - NAME = 'Mozilla Persona' + @classmethod + def get_name(cls, provider_details): + return provider_details.get('fullname') class Registry(object): @@ -84,7 +173,7 @@ class Registry(object): @classmethod def _enable(cls, provider): - """Enables a single `provider`.""" + """Enables a single provider.""" if provider.NAME in cls._ENABLED: raise ValueError('Provider %s already enabled' % provider.NAME) cls._ENABLED[provider.NAME] = provider @@ -93,10 +182,17 @@ class Registry(object): def configure_once(cls, provider_names): """Configures providers. - Takes `provider_names`, a list of string. + Args: + provider_names: list of string. The providers to configure. + + Raises: + ValueError: if the registry has already been configured, or if any + of the passed provider_names does not have a corresponding + BaseProvider child implementation. """ if cls._CONFIGURED: raise ValueError('Provider registry already configured') + # Flip the bit eagerly -- configure() should not be re-callable if one # _enable call fails. cls._CONFIGURED = True @@ -114,10 +210,27 @@ class Registry(object): @classmethod def get(cls, provider_name): - """Gets provider named `provider_name` string if enabled, else None.""" + """Gets provider named provider_name string if enabled, else None.""" cls._check_configured() return cls._ENABLED.get(provider_name) + @classmethod + def get_by_backend_name(cls, backend_name): + """Gets provider (or None) by backend name. + + Args: + backend_name: string. The python-social-auth + backends.base.BaseAuth.name (for example, 'google-oauth2') to + try and get a provider for. + + Raises: + RuntimeError: if the registry has not been configured. + """ + cls._check_configured() + for enabled in cls._ENABLED.values(): + if enabled.BACKEND_CLASS.name == backend_name: + return enabled + @classmethod def _reset(cls): """Returns the registry to an unconfigured state; for tests only.""" diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index 0ff55e1213d8b9eb27a184ba9aae0ec49603a00a..bb4fc71415c92a032a3e3ab80a9fd03b86e2a6e4 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -46,8 +46,15 @@ If true, it: from . import provider +_FIELDS_STORED_IN_SESSION = ['auth_entry'] +_MIDDLEWARE_CLASSES = ( + 'third_party_auth.middleware.ExceptionMiddleware', +) +_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' + + def _merge_auth_info(django_settings, auth_info): - """Merge `auth_info` dict onto `django_settings` module.""" + """Merge auth_info dict onto django_settings module.""" enabled_provider_names = [] to_merge = [] @@ -66,39 +73,77 @@ def _merge_auth_info(django_settings, auth_info): def _set_global_settings(django_settings): """Set provider-independent settings.""" + + # Whitelisted URL query parameters retrained in the pipeline session. + # Params not in this whitelist will be silently dropped. + django_settings.FIELDS_STORED_IN_SESSION = _FIELDS_STORED_IN_SESSION + # Register and configure python-social-auth with Django. django_settings.INSTALLED_APPS += ( 'social.apps.django_app.default', 'third_party_auth', ) - django_settings.TEMPLATE_CONTEXT_PROCESSORS += ( - 'social.apps.django_app.context_processors.backends', - 'social.apps.django_app.context_processors.login_redirect', - ) + + # Inject exception middleware to make redirects fire. + django_settings.MIDDLEWARE_CLASSES += _MIDDLEWARE_CLASSES + + # Where to send the user if there's an error during social authentication + # and we cannot send them to a more specific URL + # (see middleware.ExceptionMiddleware). + django_settings.SOCIAL_AUTH_LOGIN_ERROR_URL = '/' + + # Where to send the user once social authentication is successful. + django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = _SOCIAL_AUTH_LOGIN_REDIRECT_URL + # Inject our customized auth pipeline. All auth backends must work with # this pipeline. django_settings.SOCIAL_AUTH_PIPELINE = ( - 'third_party_auth.pipeline.step', + 'third_party_auth.pipeline.parse_query_params', + 'social.pipeline.social_auth.social_details', + 'social.pipeline.social_auth.social_uid', + 'social.pipeline.social_auth.auth_allowed', + 'social.pipeline.social_auth.social_user', + 'social.pipeline.user.get_username', + 'third_party_auth.pipeline.redirect_to_supplementary_form', + 'social.pipeline.user.create_user', + 'social.pipeline.social_auth.associate_user', + 'social.pipeline.social_auth.load_extra_data', + 'social.pipeline.user.user_details', + ) + + # We let the user specify their email address during signup. + django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] + + # Disable exceptions by default for prod so you get redirect behavior + # instead of a Django error page. During development you may want to + # enable this when you want to get stack traces rather than redirections. + django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False + + # Context processors required under Django. + django_settings.SOCIAL_AUTH_UUID_LENGTH = 4 + django_settings.TEMPLATE_CONTEXT_PROCESSORS += ( + 'social.apps.django_app.context_processors.backends', + 'social.apps.django_app.context_processors.login_redirect', ) def _set_provider_settings(django_settings, enabled_providers, auth_info): - """Set provider-specific settings.""" + """Sets provider-specific settings.""" # Must prepend here so we get called first. django_settings.AUTHENTICATION_BACKENDS = ( - tuple(enabled_provider.AUTHENTICATION_BACKEND for enabled_provider in enabled_providers) + + tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) + django_settings.AUTHENTICATION_BACKENDS) # Merge settings from provider classes, and configure all placeholders. for enabled_provider in enabled_providers: enabled_provider.merge_onto(django_settings) - # Merge settings from <deployment>.auth.json. + # Merge settings from <deployment>.auth.json, overwriting placeholders. _merge_auth_info(django_settings, auth_info) def apply_settings(auth_info, django_settings): - """Apply settings from `auth_info` dict to `django_settings` module.""" + """Applies settings from auth_info dict to django_settings module.""" provider_names = auth_info.keys() provider.Registry.configure_once(provider_names) enabled_providers = provider.Registry.enabled() diff --git a/common/djangoapps/third_party_auth/tests/specs/__init__.py b/common/djangoapps/third_party_auth/tests/specs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py new file mode 100644 index 0000000000000000000000000000000000000000..98ce0d6a3479896513f242af0b643b493282c188 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -0,0 +1,689 @@ +"""Base integration test for provider implementations.""" + +import re +import unittest + +import json +import mock + +from django import test +from django.contrib import auth +from django.contrib.auth import models as auth_models +from django.contrib.messages.storage import fallback +from django.contrib.sessions.backends import cache +from django.test import utils as django_utils +from django.conf import settings as django_settings +from social import actions, exceptions +from social.apps.django_app import utils as social_utils +from social.apps.django_app import views as social_views +from student import models as student_models +from student import views as student_views + +from third_party_auth import middleware, pipeline +from third_party_auth import settings as auth_settings +from third_party_auth.tests import testutil + + +@unittest.skipUnless( + testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') +@django_utils.override_settings() # For settings reversion on a method-by-method basis. +class IntegrationTest(testutil.TestCase, test.TestCase): + """Abstract base class for provider integration tests.""" + + # Configuration. You will need to override these values in your test cases. + + # Class. The third_party_auth.provider.BaseProvider child we are testing. + PROVIDER_CLASS = None + + # Dict of string -> object. Settings that will be merged onto Django's + # settings object before test execution. In most cases, this is + # PROVIDER_CLASS.SETTINGS with test values. + PROVIDER_SETTINGS = {} + + # Methods you must override in your children. + + def get_response_data(self): + """Gets a dict of response data of the form given by the provider. + + To determine what the provider returns, drop into a debugger in your + provider's do_auth implementation. Providers may merge different kinds + of data (for example, data about the user and data about the user's + credentials). + """ + raise NotImplementedError + + def get_username(self): + """Gets username based on response data from a provider. + + Each provider has different logic for username generation. Sadly, + this is not extracted into its own method in python-social-auth, so we + must provide a getter ourselves. + + Note that this is the *initial* value the framework will attempt to use. + If it collides, the pipeline will generate a new username. We extract + it here so we can force collisions in a polymorphic way. + """ + raise NotImplementedError + + # Asserts you can optionally override and make more specific. + + def assert_redirect_to_provider_looks_correct(self, response): + """Asserts the redirect to the provider's site looks correct. + + When we hit /auth/login/<provider>, we should be redirected to the + provider's site. Here we check that we're redirected, but we don't know + enough about the provider to check what we're redirected to. Child test + implementations may optionally strengthen this assertion with, for + example, more details about the format of the Location header. + """ + self.assertEqual(302, response.status_code) + self.assertTrue(response.has_header('Location')) + + def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_kwargs): + """Performs spot checks of the rendered register.html page. + + When we display the new account registration form after the user signs + in with a third party, we prepopulate the form with values sent back + from the provider. The exact set of values varies on a provider-by- + provider basis and is generated by + provider.BaseProvider.get_register_form_data. We provide some stock + assertions based on the provider's implementation; if you want more + assertions in your test, override this method. + """ + self.assertEqual(200, response.status_code) + # Check that the correct provider was selected. + self.assertIn('successfully signed in with <strong>%s</strong>' % self.PROVIDER_CLASS.NAME, response.content) + # Expect that each truthy value we've prepopulated the register form + # with is actually present. + for prepopulated_form_value in self.PROVIDER_CLASS.get_register_form_data(pipeline_kwargs).values(): + if prepopulated_form_value: + self.assertIn(prepopulated_form_value, response.content) + + # Implementation details and actual tests past this point -- no more + # configuration needed. + + def setUp(self): + super(IntegrationTest, self).setUp() + self.configure_runtime() + self.backend_name = self.PROVIDER_CLASS.BACKEND_CLASS.name + self.client = test.Client() + self.request_factory = test.RequestFactory() + + def assert_dashboard_response_looks_correct(self, response, user, duplicate=False, linked=None): + """Asserts the user's dashboard is in the expected state. + + We check unconditionally that the dashboard 200s and contains the + user's info. If duplicate is True, we expect the duplicate account + association error to be present. If linked is passed, we conditionally + check the content and controls in the Account Links section of the + sidebar. + """ + duplicate_account_error_needle = '<section class="dashboard-banner third-party-auth">' + assert_duplicate_presence_fn = self.assertIn if duplicate else self.assertNotIn + + self.assertEqual(200, response.status_code) + self.assertIn(user.email, response.content) + self.assertIn(user.username, response.content) + assert_duplicate_presence_fn(duplicate_account_error_needle, response.content) + + if linked is not None: + + if linked: + expected_control_text = pipeline.ProviderUserState( + self.PROVIDER_CLASS, user, False).get_unlink_form_name() + else: + expected_control_text = pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_DASHBOARD) + + icon_state = re.search(r'third-party-auth.+icon icon-(\w+)', response.content, re.DOTALL).groups()[0] + provider_name = re.search(r'<span class="provider">([^<]+)', response.content, re.DOTALL).groups()[0] + + self.assertIn(expected_control_text, response.content) + self.assertEqual('link' if linked else 'unlink', icon_state) + self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name) + + def assert_exception_redirect_looks_correct(self, auth_entry=None): + """Tests middleware conditional redirection. + + middleware.ExceptionMiddleware makes sure the user ends up in the right + place when they cancel authentication via the provider's UX. + """ + exception_middleware = middleware.ExceptionMiddleware() + request, _ = self.get_request_and_strategy(auth_entry=auth_entry) + response = exception_middleware.process_exception( + request, exceptions.AuthCanceled(request.social_strategy.backend)) + location = response.get('Location') + + self.assertEqual(302, response.status_code) + self.assertIn('canceled', location) + self.assertIn(self.backend_name, location) + + if auth_entry: + # Custom redirection to form. + self.assertTrue(location.startswith('/' + auth_entry)) + else: + # Stock framework redirection to root. + self.assertTrue(location.startswith('/?')) + + def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None): + """Asserts first party auth was used in place of third party auth. + + Args: + email: string. The user's email. If not None, will be set on POST. + password: string. The user's password. If not None, will be set on + POST. + success: None or bool. Whether we expect auth to be successful. Set + to None to indicate we expect the request to be invalid (meaning + one of username or password will be missing). + """ + _, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + self.create_user_models_for_existing_account( + strategy, email, password, self.get_username(), skip_social_auth=True) + + strategy.request.POST = dict(strategy.request.POST) + + if email: + strategy.request.POST['email'] = email + if password: + strategy.request.POST['password'] = 'bad_' + password if success is False else password + + self.assert_pipeline_running(strategy.request) + payload = json.loads(student_views.login_user(strategy.request).content) + + if success is None: + # Request malformed -- just one of email/password given. + self.assertFalse(payload.get('success')) + self.assertIn('There was an error receiving your login information', payload.get('value')) + elif success: + # Request well-formed and credentials good. + self.assertTrue(payload.get('success')) + else: + # Request well-formed but credentials bad. + self.assertFalse(payload.get('success')) + self.assertIn('incorrect', payload.get('value')) + + def assert_javascript_would_submit_login_form(self, boolean, response): + """Asserts we pass form submit JS the right boolean string.""" + argument_string = re.search( + r'function\ post_form_if_pipeline_running.*\(([a-z]+)\)', response.content, re.DOTALL).groups()[0] + self.assertIn(argument_string, ['true', 'false']) + self.assertEqual(boolean, True if argument_string == 'true' else False) + + def assert_json_failure_response_is_missing_social_auth(self, response): + """Asserts failure on /login for missing social auth looks right.""" + self.assertEqual(200, response.status_code) # Yes, it's a 200 even though it's a failure. + payload = json.loads(response.content) + self.assertFalse(payload.get('success')) + self.assertIn('associated with your %s account' % self.PROVIDER_CLASS.NAME, payload.get('value')) + + def assert_json_failure_response_is_username_collision(self, response): + """Asserts the json response indicates a username collision.""" + self.assertEqual(400, response.status_code) + payload = json.loads(response.content) + self.assertFalse(payload.get('success')) + self.assertIn('already exists', payload.get('value')) + + def assert_json_success_response_looks_correct(self, response): + """Asserts the json response indicates success and redirection.""" + self.assertEqual(200, response.status_code) + payload = json.loads(response.content) + self.assertTrue(payload.get('success')) + self.assertEqual(pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name), payload.get('redirect_url')) + + def assert_login_response_before_pipeline_looks_correct(self, response): + """Asserts a GET of /login not in the pipeline looks correct.""" + self.assertEqual(200, response.status_code) + self.assertIn('Sign in with ' + self.PROVIDER_CLASS.NAME, response.content) + self.assert_javascript_would_submit_login_form(False, response) + self.assert_signin_button_looks_functional(response.content, pipeline.AUTH_ENTRY_LOGIN) + + def assert_login_response_in_pipeline_looks_correct(self, response): + """Asserts a GET of /login in the pipeline looks correct.""" + self.assertEqual(200, response.status_code) + # Make sure the form submit JS is told to submit the form: + self.assert_javascript_would_submit_login_form(True, response) + + def assert_password_overridden_by_pipeline(self, username, password): + """Verifies that the given password is not correct. + + The pipeline overrides POST['password'], if any, with random data. + """ + self.assertIsNone(auth.authenticate(password=password, username=username)) + + def assert_pipeline_running(self, request): + """Makes sure the given request is running an auth pipeline.""" + self.assertTrue(pipeline.running(request)) + + def assert_redirect_to_dashboard_looks_correct(self, response): + """Asserts a response would redirect to /dashboard.""" + self.assertEqual(302, response.status_code) + # pylint: disable-msg=protected-access + self.assertEqual(auth_settings._SOCIAL_AUTH_LOGIN_REDIRECT_URL, response.get('Location')) + + def assert_redirect_to_login_looks_correct(self, response): + """Asserts a response would redirect to /login.""" + self.assertEqual(302, response.status_code) + self.assertEqual('/' + pipeline.AUTH_ENTRY_LOGIN, response.get('Location')) + + def assert_redirect_to_register_looks_correct(self, response): + """Asserts a response would redirect to /register.""" + self.assertEqual(302, response.status_code) + self.assertEqual('/' + pipeline.AUTH_ENTRY_REGISTER, response.get('Location')) + + def assert_register_response_before_pipeline_looks_correct(self, response): + """Asserts a GET of /register not in the pipeline looks correct.""" + self.assertEqual(200, response.status_code) + self.assertIn('Sign in with ' + self.PROVIDER_CLASS.NAME, response.content) + self.assert_signin_button_looks_functional(response.content, pipeline.AUTH_ENTRY_REGISTER) + + def assert_signin_button_looks_functional(self, content, auth_entry): + """Asserts JS is available to signin buttons and has the right args.""" + self.assertTrue(re.search(r'function thirdPartySignin', content)) + self.assertEqual( + pipeline.get_login_url(self.PROVIDER_CLASS.NAME, auth_entry), + re.search(r"thirdPartySignin\(event, '([^']+)", content).groups()[0]) + + def assert_social_auth_does_not_exist_for_user(self, user, strategy): + """Asserts a user does not have an auth with the expected provider.""" + social_auths = strategy.storage.user.get_social_auth_for_user( + user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + self.assertEqual(0, len(social_auths)) + + def assert_social_auth_exists_for_user(self, user, strategy): + """Asserts a user has a social auth with the expected provider.""" + social_auths = strategy.storage.user.get_social_auth_for_user( + user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + self.assertEqual(1, len(social_auths)) + self.assertEqual(self.backend_name, social_auths[0].provider) + + def configure_runtime(self): + """Configures settings details.""" + auth_settings.apply_settings({self.PROVIDER_CLASS.NAME: self.PROVIDER_SETTINGS}, django_settings) + # Force settings to propagate into cached members on + # social.apps.django_app.utils. + reload(social_utils) + + def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False): + """Creates user, profile, registration, and (usually) social auth. + + This synthesizes what happens during /register. + See student.views.register and student.views._do_create_account. + """ + response_data = self.get_response_data() + uid = strategy.backend.get_user_id(response_data, response_data) + user = social_utils.Storage.user.create_user(email=email, password=password, username=username) + profile = student_models.UserProfile(user=user) + profile.save() + registration = student_models.Registration() + registration.register(user) + registration.save() + + if not skip_social_auth: + social_utils.Storage.user.create_social_auth(user, uid, self.PROVIDER_CLASS.BACKEND_CLASS.name) + + return user + + def fake_auth_complete(self, strategy): + """Fake implementation of social.backends.BaseAuth.auth_complete. + + Unlike what the docs say, it does not need to return a user instance. + Sometimes (like when directing users to the /register form) it instead + returns a response that 302s to /register. + """ + args = () + kwargs = { + 'request': strategy.request, + 'backend': strategy.backend, + 'user': None, + 'response': self.get_response_data(), + } + return strategy.authenticate(*args, **kwargs) + + def get_registration_post_vars(self, overrides=None): + """POST vars generated by the registration form.""" + defaults = { + 'username': 'username', + 'name': 'First Last', + 'gender': '', + 'year_of_birth': '', + 'level_of_education': '', + 'goals': '', + 'honor_code': 'true', + 'terms_of_service': 'true', + 'password': 'password', + 'mailing_address': '', + 'email': 'user@email.com', + } + + if overrides: + defaults.update(overrides) + + return defaults + + def get_request_and_strategy(self, auth_entry=None, redirect_uri=None): + """Gets a fully-configured request and strategy. + + These two objects contain circular references, so we create them + together. The references themselves are a mixture of normal __init__ + stuff and monkey-patching done by python-social-auth. See, for example, + social.apps.django_apps.utils.strategy(). + """ + request = self.request_factory.get( + pipeline.get_complete_url(self.backend_name) + + '?redirect_state=redirect_state_value&code=code_value&state=state_value') + request.user = auth_models.AnonymousUser() + request.session = cache.SessionStore() + request.session[self.backend_name + '_state'] = 'state_value' + + if auth_entry: + request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry + + strategy = social_utils.load_strategy(backend=self.backend_name, redirect_uri=redirect_uri, request=request) + request.social_strategy = strategy + + return request, strategy + + def get_user_by_email(self, strategy, email): + """Gets a user by email, using the given strategy.""" + return strategy.storage.user.user_model().objects.get(email=email) + + # Actual tests, executed once per child. + + def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self): + self.assert_exception_redirect_looks_correct(auth_entry=pipeline.AUTH_ENTRY_LOGIN) + + def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self): + self.assert_exception_redirect_looks_correct(auth_entry=pipeline.AUTH_ENTRY_REGISTER) + + def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): + self.assert_exception_redirect_looks_correct() + + def test_full_pipeline_succeeds_for_linking_account(self): + # First, create, the request and strategy that store pipeline state, + # configure the backend, and mock out wire traffic. + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + request.user = self.create_user_models_for_existing_account( + strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) + + # Instrument the pipeline to get to the dashboard with the full + # expected state. + self.client.get( + pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access + student_views.signin_user(strategy.request) + student_views.login_user(strategy.request) + actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access + + # First we expect that we're in the unlinked state, and that there + # really is no association in the backend. + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=False) + self.assert_social_auth_does_not_exist_for_user(request.user, strategy) + + # Fire off the auth pipeline to link. + self.assert_redirect_to_dashboard_looks_correct(actions.do_complete( + request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access + redirect_field_name=auth.REDIRECT_FIELD_NAME)) + + # Now we expect to be in the linked state, with a backend entry. + self.assert_social_auth_exists_for_user(request.user, strategy) + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=True) + + def test_full_pipeline_succeeds_for_unlinking_account(self): + # First, create, the request and strategy that store pipeline state, + # configure the backend, and mock out wire traffic. + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + user = self.create_user_models_for_existing_account( + strategy, 'user@example.com', 'password', self.get_username()) + self.assert_social_auth_exists_for_user(user, strategy) + + # Instrument the pipeline to get to the dashboard with the full + # expected state. + self.client.get( + pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access + student_views.signin_user(strategy.request) + student_views.login_user(strategy.request) + actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access + + # First we expect that we're in the linked state, with a backend entry. + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=True) + self.assert_social_auth_exists_for_user(request.user, strategy) + + # Fire off the disconnect pipeline to unlink. + self.assert_redirect_to_dashboard_looks_correct(actions.do_disconnect( + request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME)) + + # Now we expect to be in the unlinked state, with no backend entry. + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=False) + self.assert_social_auth_does_not_exist_for_user(user, strategy) + + def test_linking_already_associated_account_raises_auth_already_associated(self): + # This is of a piece with + # test_already_associated_exception_populates_dashboard_with_error. It + # verifies the exception gets raised when we expect; the latter test + # covers exception handling. + email = 'user@example.com' + password = 'password' + username = self.get_username() + _, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + linked_user = self.create_user_models_for_existing_account(strategy, email, password, username) + unlinked_user = social_utils.Storage.user.create_user( + email='other_' + email, password=password, username='other_' + username) + + self.assert_social_auth_exists_for_user(linked_user, strategy) + self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy) + + with self.assertRaises(exceptions.AuthAlreadyAssociated): + actions.do_complete(strategy, social_views._do_login, user=unlinked_user) # pylint: disable-msg=protected-access + + def test_already_associated_exception_populates_dashboard_with_error(self): + # Instrument the pipeline with an exception. We test that the + # exception is raised correctly separately, so it's ok that we're + # raising it artificially here. This makes the linked=True artificial + # in the final assert because in practice the account would be + # unlinked, but getting that behavior is cumbersome here and already + # covered in other tests. Using linked=True does, however, let us test + # that the duplicate error has no effect on the state of the controls. + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + user = self.create_user_models_for_existing_account( + strategy, 'user@example.com', 'password', self.get_username()) + self.assert_social_auth_exists_for_user(user, strategy) + + self.client.get('/login') + self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access + student_views.signin_user(strategy.request) + student_views.login_user(strategy.request) + actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access + + # Monkey-patch storage for messaging; pylint: disable-msg=protected-access + request._messages = fallback.FallbackStorage(request) + middleware.ExceptionMiddleware().process_exception( + request, exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'duplicate')) + + self.assert_dashboard_response_looks_correct( + student_views.dashboard(request), user, duplicate=True, linked=True) + + def test_full_pipeline_succeeds_for_signing_in_to_existing_account(self): + # First, create, the request and strategy that store pipeline state, + # configure the backend, and mock out wire traffic. + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + user = self.create_user_models_for_existing_account( + strategy, 'user@example.com', 'password', self.get_username()) + self.assert_social_auth_exists_for_user(user, strategy) + + # Begin! Ensure that the login form contains expected controls before + # the user starts the pipeline. + self.assert_login_response_before_pipeline_looks_correct(self.client.get('/login')) + + # The pipeline starts by a user GETting /auth/login/<provider>. + # Synthesize that request and check that it redirects to the correct + # provider page. + self.assert_redirect_to_provider_looks_correct(self.client.get( + pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + + # Next, the provider makes a request against /auth/complete/<provider> + # to resume the pipeline. + # pylint: disable-msg=protected-access + self.assert_redirect_to_login_looks_correct(actions.do_complete(strategy, social_views._do_login)) + + # At this point we know the pipeline has resumed correctly. Next we + # fire off the view that displays the login form and posts it via JS. + self.assert_login_response_in_pipeline_looks_correct(student_views.signin_user(strategy.request)) + + # Next, we invoke the view that handles the POST, and expect it + # redirects to /auth/complete. In the browser ajax handlers will + # redirect the user to the dashboard; we invoke it manually here. + self.assert_json_success_response_looks_correct(student_views.login_user(strategy.request)) + self.assert_redirect_to_dashboard_looks_correct( + actions.do_complete(strategy, social_views._do_login, user=user)) + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user) + + def test_signin_fails_if_no_account_associated(self): + _, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + self.create_user_models_for_existing_account( + strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) + + self.assert_json_failure_response_is_missing_social_auth(student_views.login_user(strategy.request)) + + def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_email_in_request(self): + self.assert_first_party_auth_trumps_third_party_auth(email='user@example.com') + + def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_password_in_request(self): + self.assert_first_party_auth_trumps_third_party_auth(password='password') + + def test_first_party_auth_trumps_third_party_auth_and_fails_when_credentials_bad(self): + self.assert_first_party_auth_trumps_third_party_auth( + email='user@example.com', password='password', success=False) + + def test_first_party_auth_trumps_third_party_auth_and_succeeds_when_credentials_good(self): + self.assert_first_party_auth_trumps_third_party_auth( + email='user@example.com', password='password', success=True) + + def test_full_pipeline_succeeds_registering_new_account(self): + # First, create, the request and strategy that store pipeline state. + # Mock out wire traffic. + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + + # Begin! Grab the registration page and check the login control on it. + self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register')) + + # The pipeline starts by a user GETting /auth/login/<provider>. + # Synthesize that request and check that it redirects to the correct + # provider page. + self.assert_redirect_to_provider_looks_correct(self.client.get( + pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + + # Next, the provider makes a request against /auth/complete/<provider>. + # pylint:disable-msg=protected-access + self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) + + # At this point we know the pipeline has resumed correctly. Next we + # fire off the view that displays the registration form. + self.assert_register_response_in_pipeline_looks_correct( + student_views.register_user(strategy.request), pipeline.get(request)['kwargs']) + + # Next, we invoke the view that handles the POST. Not all providers + # supply email. Manually add it as the user would have to; this + # also serves as a test of overriding provider values. Always provide a + # password for us to check that we override it properly. + overridden_password = strategy.request.POST.get('password') + email = 'new@example.com' + + if not strategy.request.POST.get('email'): + strategy.request.POST = self.get_registration_post_vars({'email': email}) + + # The user must not exist yet... + with self.assertRaises(auth_models.User.DoesNotExist): + self.get_user_by_email(strategy, email) + + # ...but when we invoke create_account the existing edX view will make + # it, but not social auths. The pipeline creates those later. + self.assert_json_success_response_looks_correct(student_views.create_account(strategy.request)) + # We've overridden the user's password, so authenticate() with the old + # value won't work: + created_user = self.get_user_by_email(strategy, email) + self.assert_password_overridden_by_pipeline(overridden_password, created_user.username) + + # Last step in the pipeline: we re-invoke the pipeline and expect to + # end up on /dashboard, with the correct social auth object now in the + # backend and the correct user's data on display. + self.assert_redirect_to_dashboard_looks_correct( + actions.do_complete(strategy, social_views._do_login, user=created_user)) + self.assert_social_auth_exists_for_user(created_user, strategy) + self.assert_dashboard_response_looks_correct(student_views.dashboard(request), created_user) + + def test_new_account_registration_assigns_distinct_username_on_collision(self): + original_username = self.get_username() + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + + # Create a colliding username in the backend, then proceed with + # assignment via pipeline to make sure a distinct username is created. + strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + # pylint:disable-msg=protected-access + self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) + distinct_username = pipeline.get(request)['kwargs']['username'] + self.assertNotEqual(original_username, distinct_username) + + def test_new_account_registration_fails_if_email_exists(self): + request, strategy = self.get_request_and_strategy( + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + # pylint:disable-msg=protected-access + self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) + self.assert_register_response_in_pipeline_looks_correct( + student_views.register_user(strategy.request), pipeline.get(request)['kwargs']) + strategy.request.POST = self.get_registration_post_vars() + # Create twice: once successfully, and once causing a collision. + student_views.create_account(strategy.request) + self.assert_json_failure_response_is_username_collision(student_views.create_account(strategy.request)) + + def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self): + auth_entry = 'invalid' + self.assertNotIn(auth_entry, pipeline._AUTH_ENTRY_CHOICES) # pylint: disable-msg=protected-access + + _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete') + + with self.assertRaises(pipeline.AuthEntryError): + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + + def test_pipeline_raises_auth_entry_error_if_auth_entry_missing(self): + _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete') + + with self.assertRaises(pipeline.AuthEntryError): + strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + + +class Oauth2IntegrationTest(IntegrationTest): # pylint: disable-msg=abstract-method + """Base test case for integration tests of Oauth2 providers.""" + + # Dict of string -> object. Information about the token granted to the + # user. Override with test values in subclass; None to force a throw. + TOKEN_RESPONSE_DATA = None + + # Dict of string -> object. Information about the user themself. Override + # with test values in subclass; None to force a throw. + USER_RESPONSE_DATA = None + + def get_response_data(self): + """Gets dict (string -> object) of merged data about the user.""" + response_data = dict(self.TOKEN_RESPONSE_DATA) + response_data.update(self.USER_RESPONSE_DATA) + return response_data diff --git a/common/djangoapps/third_party_auth/tests/specs/test_google.py b/common/djangoapps/third_party_auth/tests/specs/test_google.py new file mode 100644 index 0000000000000000000000000000000000000000..320739b81e6a6b8b147a74b9035e004c1e08b612 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_google.py @@ -0,0 +1,34 @@ +"""Integration tests for Google providers.""" + +from third_party_auth import provider +from third_party_auth.tests.specs import base + + +class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest): + """Integration tests for provider.GoogleOauth2.""" + + PROVIDER_CLASS = provider.GoogleOauth2 + PROVIDER_SETTINGS = { + 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key', + 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'google_oauth2_secret', + } + TOKEN_RESPONSE_DATA = { + 'access_token': 'access_token_value', + 'expires_in': 'expires_in_value', + 'id_token': 'id_token_value', + 'token_type': 'token_type_value', + } + USER_RESPONSE_DATA = { + 'email': 'email_value@example.com', + 'family_name': 'family_name_value', + 'given_name': 'given_name_value', + 'id': 'id_value', + 'link': 'link_value', + 'locale': 'locale_value', + 'name': 'name_value', + 'picture': 'picture_value', + 'verified_email': 'verified_email_value', + } + + def get_username(self): + return self.get_response_data().get('email').split('@')[0] diff --git a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py new file mode 100644 index 0000000000000000000000000000000000000000..e51cc2ecc7d47274daef76e2f8229ac70966498c --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py @@ -0,0 +1,27 @@ +"""Integration tests for LinkedIn providers.""" + +from third_party_auth import provider +from third_party_auth.tests.specs import base + + +class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest): + """Integration tests for provider.LinkedInOauth2.""" + + PROVIDER_CLASS = provider.LinkedInOauth2 + PROVIDER_SETTINGS = { + 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_oauth2_key', + 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': 'linkedin_oauth2_secret', + } + TOKEN_RESPONSE_DATA = { + 'access_token': 'access_token_value', + 'expires_in': 'expires_in_value', + } + USER_RESPONSE_DATA = { + 'lastName': 'lastName_value', + 'id': 'id_value', + 'firstName': 'firstName_value', + } + + def get_username(self): + response_data = self.get_response_data() + return response_data.get('firstName') + response_data.get('lastName') diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline.py b/common/djangoapps/third_party_auth/tests/test_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..66c11d90437558c643dfc684f79dc5a29529befd --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_pipeline.py @@ -0,0 +1,42 @@ +"""Unit tests for third_party_auth/pipeline.py.""" + +import random + +from third_party_auth import pipeline, provider +from third_party_auth.tests import testutil + + +# Allow tests access to protected methods (or module-protected methods) under +# test. pylint: disable-msg=protected-access + + +class MakeRandomPasswordTest(testutil.TestCase): + """Tests formation of random placeholder passwords.""" + + def setUp(self): + super(MakeRandomPasswordTest, self).setUp() + self.seed = 1 + + def test_default_args(self): + self.assertEqual(pipeline._DEFAULT_RANDOM_PASSWORD_LENGTH, len(pipeline.make_random_password())) + + def test_probably_only_uses_charset(self): + # This is ultimately probablistic since we could randomly select a good character 100000 consecutive times. + for char in pipeline.make_random_password(length=100000): + self.assertIn(char, pipeline._PASSWORD_CHARSET) + + def test_pseudorandomly_picks_chars_from_charset(self): + random_instance = random.Random(self.seed) + expected = ''.join( + random_instance.choice(pipeline._PASSWORD_CHARSET) + for _ in xrange(pipeline._DEFAULT_RANDOM_PASSWORD_LENGTH)) + random_instance.seed(self.seed) + self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice)) + + +class ProviderUserStateTestCase(testutil.TestCase): + """Tests ProviderUserState behavior.""" + + def test_get_unlink_form_name(self): + state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), False) + self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name()) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..8d1f3b7019aef523f08eb1695f59097ab813277a --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -0,0 +1,179 @@ +"""Integration tests for pipeline.py.""" + +import unittest + +from django.conf import settings +from django import test +from django.contrib.auth import models + +from third_party_auth import pipeline, provider +from third_party_auth.tests import testutil +from social.apps.django_app.default import models as social_models + + +# Get Django User model by reference from python-social-auth. Not a type +# constant, pylint. +User = social_models.DjangoStorage.user.user_model() # pylint: disable-msg=invalid-name + + +class TestCase(testutil.TestCase, test.TestCase): + """Base test case.""" + + def setUp(self): + super(TestCase, self).setUp() + self.enabled_provider_name = provider.GoogleOauth2.NAME + provider.Registry.configure_once([self.enabled_provider_name]) + self.enabled_provider = provider.Registry.get(self.enabled_provider_name) + + +@unittest.skipUnless( + testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') +class GetAuthenticatedUserTestCase(TestCase): + """Tests for get_authenticated_user.""" + + def setUp(self): + super(GetAuthenticatedUserTestCase, self).setUp() + self.user = social_models.DjangoStorage.user.create_user(username='username', password='password') + + def get_by_username(self, username): + """Gets a User by username.""" + return social_models.DjangoStorage.user.user_model().objects.get(username=username) + + def test_raises_does_not_exist_if_user_missing(self): + with self.assertRaises(models.User.DoesNotExist): + pipeline.get_authenticated_user('new_' + self.user.username, 'backend') + + def test_raises_does_not_exist_if_user_found_but_no_association(self): + backend_name = 'backend' + + self.assertIsNotNone(self.get_by_username(self.user.username)) + self.assertIsNone(provider.Registry.get_by_backend_name(backend_name)) + + with self.assertRaises(models.User.DoesNotExist): + pipeline.get_authenticated_user(self.user.username, 'backend') + + def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self): + self.assertIsNotNone(self.get_by_username(self.user.username)) + social_models.DjangoStorage.user.create_social_auth( + self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name) + + with self.assertRaises(models.User.DoesNotExist): + pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + + def test_returns_user_with_is_authenticated_and_backend_set_if_match(self): + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name) + user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + + self.assertEqual(self.user, user) + self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend) + + +@unittest.skipUnless( + testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') +class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase): + """Tests generation of ProviderUserStates.""" + + def setUp(self): + super(GetProviderUserStatesTestCase, self).setUp() + self.user = social_models.DjangoStorage.user.create_user(username='username', password='password') + + def test_returns_empty_list_if_no_enabled_providers(self): + provider.Registry.configure_once([]) + self.assertEquals([], pipeline.get_provider_user_states(self.user)) + + def test_state_not_returned_for_disabled_provider(self): + disabled_provider = provider.GoogleOauth2 + enabled_provider = provider.LinkedInOauth2 + provider.Registry.configure_once([enabled_provider.NAME]) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name) + states = pipeline.get_provider_user_states(self.user) + + self.assertEqual(1, len(states)) + self.assertNotIn(disabled_provider, (state.provider for state in states)) + + def test_states_for_enabled_providers_user_has_accounts_associated_with(self): + provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name) + social_models.DjangoStorage.user.create_social_auth( + self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name) + states = pipeline.get_provider_user_states(self.user) + + self.assertEqual(2, len(states)) + + google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] + linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + + self.assertTrue(google_state.has_account) + self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(self.user, google_state.user) + + self.assertTrue(linkedin_state.has_account) + self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(self.user, linkedin_state.user) + + def test_states_for_enabled_providers_user_has_no_account_associated_with(self): + provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) + states = pipeline.get_provider_user_states(self.user) + + self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()]) + self.assertEqual(2, len(states)) + + google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] + linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + + self.assertFalse(google_state.has_account) + self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(self.user, google_state.user) + + self.assertFalse(linkedin_state.has_account) + self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(self.user, linkedin_state.user) + + +@unittest.skipUnless( + testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') +class UrlFormationTestCase(TestCase): + """Tests formation of URLs for pipeline hook points.""" + + def test_complete_url_raises_value_error_if_provider_not_enabled(self): + provider_name = 'not_enabled' + + self.assertIsNone(provider.Registry.get(provider_name)) + + with self.assertRaises(ValueError): + pipeline.get_complete_url(provider_name) + + def test_complete_url_returns_expected_format(self): + complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name) + + self.assertTrue(complete_url.startswith('/auth/complete')) + self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url) + + def test_disconnect_url_raises_value_error_if_provider_not_enabled(self): + provider_name = 'not_enabled' + + self.assertIsNone(provider.Registry.get(provider_name)) + + with self.assertRaises(ValueError): + pipeline.get_disconnect_url(provider_name) + + def test_disconnect_url_returns_expected_format(self): + disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME) + + self.assertTrue(disconnect_url.startswith('/auth/disconnect')) + self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url) + + def test_login_url_raises_value_error_if_provider_not_enabled(self): + provider_name = 'not_enabled' + + self.assertIsNone(provider.Registry.get(provider_name)) + + with self.assertRaises(ValueError): + pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN) + + def test_login_url_returns_expected_format(self): + login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN) + + self.assertTrue(login_url.startswith('/auth/login')) + self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url) + self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN)) diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index 18774eb61a7404b673a214b3bd0bd203af93510f..20120d73290cb7facc65c66e8db6a1af74526b86 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -1,6 +1,4 @@ -""" -Test configuration of providers. -""" +"""Unit tests for provider.py.""" from third_party_auth import provider from third_party_auth.tests import testutil @@ -10,8 +8,7 @@ class RegistryTest(testutil.TestCase): """Tests registry discovery and operation.""" # Allow access to protected methods (or module-protected methods) under - # test. - # pylint: disable-msg=protected-access + # test. pylint: disable-msg=protected-access def test_calling_configure_once_twice_raises_value_error(self): provider.Registry.configure_once([provider.GoogleOauth2.NAME]) @@ -68,4 +65,18 @@ class RegistryTest(testutil.TestCase): def test_get_returns_none_if_provider_not_enabled(self): provider.Registry.configure_once([]) - self.assertIsNone(provider.Registry.get(provider.MozillaPersona.NAME)) + self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME)) + + def test_get_by_backend_name_raises_runtime_error_if_not_configured(self): + with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): + provider.Registry.get_by_backend_name('') + + def test_get_by_backend_name_returns_enabled_provider(self): + provider.Registry.configure_once([provider.GoogleOauth2.NAME]) + self.assertIs( + provider.GoogleOauth2, + provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + + def test_get_by_backend_name_returns_none_if_provider_not_enabled(self): + provider.Registry.configure_once([]) + self.assertIsNone(provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index 05f502928430d94aa2bbd5f7cfb5881894f018e6..b75e4bc032ae25f61b1d6e516c1b86fe19b5d415 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -1,18 +1,17 @@ -""" -Unit tests for settings code. -""" +"""Unit tests for settings.py.""" -from third_party_auth import provider -from third_party_auth import settings +from third_party_auth import provider, settings from third_party_auth.tests import testutil _ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',) _ORIGINAL_INSTALLED_APPS = ('first_installed_app',) +_ORIGINAL_MIDDLEWARE_CLASSES = ('first_middleware_class',) _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS = ('first_template_context_preprocessor',) _SETTINGS_MAP = { 'AUTHENTICATION_BACKENDS': _ORIGINAL_AUTHENTICATION_BACKENDS, 'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS, + 'MIDDLEWARE_CLASSES': _ORIGINAL_MIDDLEWARE_CLASSES, 'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS, } @@ -20,6 +19,8 @@ _SETTINGS_MAP = { class SettingsUnitTest(testutil.TestCase): """Unit tests for settings management code.""" + # Allow access to protected methods (or module-protected methods) under + # test. pylint: disable-msg=protected-access # Suppress sprurious no-member warning on fakes. # pylint: disable-msg=no-member @@ -27,6 +28,15 @@ class SettingsUnitTest(testutil.TestCase): super(SettingsUnitTest, self).setUp() self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP) + def test_apply_settings_adds_exception_middleware(self): + settings.apply_settings({}, self.settings) + for middleware_name in settings._MIDDLEWARE_CLASSES: + self.assertIn(middleware_name, self.settings.MIDDLEWARE_CLASSES) + + def test_apply_settings_adds_fields_stored_in_session(self): + settings.apply_settings({}, self.settings) + self.assertEqual(settings._FIELDS_STORED_IN_SESSION, self.settings.FIELDS_STORED_IN_SESSION) + def test_apply_settings_adds_third_party_auth_to_installed_apps(self): settings.apply_settings({}, self.settings) self.assertIn('third_party_auth', self.settings.INSTALLED_APPS) @@ -50,9 +60,9 @@ class SettingsUnitTest(testutil.TestCase): def test_apply_settings_prepends_auth_backends(self): self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS) - settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.MozillaPersona.NAME: {}}, self.settings) + settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings) self.assertEqual(( - provider.GoogleOauth2.AUTHENTICATION_BACKEND, provider.MozillaPersona.AUTHENTICATION_BACKEND) + + provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) + _ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS) @@ -66,3 +76,9 @@ class SettingsUnitTest(testutil.TestCase): } with self.assertRaisesRegexp(ValueError, '^.*not initialized$'): settings.apply_settings(auth_info, self.settings) + + def test_apply_settings_turns_off_raising_social_exceptions(self): + # Guard against submitting a conf change that's convenient in dev but + # bad in prod. + settings.apply_settings({}, self.settings) + self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS) diff --git a/common/djangoapps/third_party_auth/tests/test_settings_integration.py b/common/djangoapps/third_party_auth/tests/test_settings_integration.py index 15cff5605a18f0843d79547cadbb4a652621759f..8992f9fb79c9688dc68f8b66d4ecf3aabe6a0043 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_settings_integration.py @@ -1,9 +1,4 @@ -""" -Integration tests for settings code. -""" - -import mock -import unittest +"""Integration tests for settings.py.""" from django.conf import settings @@ -11,29 +6,22 @@ from third_party_auth import provider from third_party_auth import settings as auth_settings from third_party_auth.tests import testutil -_AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH' - class SettingsIntegrationTest(testutil.TestCase): - """Integration tests of auth settings pipeline.""" + """Integration tests of auth settings pipeline. - @unittest.skipUnless(_AUTH_FEATURES_KEY in settings.FEATURES, _AUTH_FEATURES_KEY + ' not in settings.FEATURES') - def test_enable_third_party_auth_is_disabled_by_default(self): - self.assertIs(False, settings.FEATURES.get(_AUTH_FEATURES_KEY)) + Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in + cms/envs/test.py. This implicitly gives us coverage of the full settings + mechanism with both values, so we do not have explicit test methods as they + are superfluous. + """ - @mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_AUTH': True}) def test_can_enable_google_oauth2(self): auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings) self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled()) self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) - @mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_AUTH': True}) def test_can_enable_linkedin_oauth2(self): auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings) self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled()) self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY) - - @mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_ATUH': True}) - def test_can_enable_mozilla_persona(self): - auth_settings.apply_settings({'Mozilla Persona': {}}, settings) - self.assertEqual([provider.MozillaPersona], provider.Registry.enabled()) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index a431aaca02fd41425f1bcefc251dd2a6ae7ba998..6907cea26eb72e7402a97cadb966a44b38d0649d 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -9,11 +9,14 @@ import unittest from third_party_auth import provider +AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH' + + class FakeDjangoSettings(object): """A fake for Django settings.""" def __init__(self, mappings): - """Initializes the fake from `mappings`, a dict.""" + """Initializes the fake from mappings dict.""" for key, value in mappings.iteritems(): setattr(self, key, value) diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index 05322a680d2a08fe7cefe3c1c76114111fdb104c..dc02425ef3937892464a7654397e2e01e5507645 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -2,6 +2,8 @@ from django.conf.urls import include, patterns, url + urlpatterns = patterns( - '', url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), + '', + url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), ) diff --git a/lms/envs/test.py b/lms/envs/test.py index 52288774712cd89a098b60efbc65adf8f88e9497..f0f734532d5862f1a7eafbd55e62c25e99208d5d 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -180,6 +180,9 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' # hide ratelimit warnings while running tests filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit') +######### Third-party auth ########## +FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True + ################################## OPENID ##################################### FEATURES['AUTH_USE_OPENID'] = True FEATURES['AUTH_USE_OPENID_PROVIDER'] = True diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index fa8d5f9c3804fc062531aff16af878476770c3bb..2f83bfbf9590d9bdf2becc21962b8b886c2180fc 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -450,6 +450,20 @@ } } + // forms - third-party auth + .form-third-party-auth { + margin-bottom: $baseline; + + button { + margin-right: $baseline; + + .icon { + color: inherit; + margin-right: $baseline/2; + } + } + } + // forms - messages/status .status { @include box-sizing(border-box); diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 5172981661d96b79793abd858a2d51eb8e5b7f01..8c987c9630d2890dd18f45a3f486b9251d215c10 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -130,6 +130,23 @@ white-space: nowrap; text-overflow: ellipsis; overflow: hidden; + + .third-party-auth { + color: inherit; + font-weight: inherit; + + .control { + float: right; + } + + .icon { + margin-top: 4px; + } + + .provider { + display: inline; + } + } } } } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 94120161a4219a893b6e4bfbb23a7c522aaf524d..3a9220d0d822b7c8b0b5f25b7148d51fb0e6ce9c 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -1,5 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.template import RequestContext %> +<%! from third_party_auth import pipeline %> <%! from django.core.urlresolvers import reverse @@ -194,6 +195,13 @@ </section> %endif + % if duplicate_provider: + <section class="dashboard-banner third-party-auth"> + ## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. + ${_('The selected {provider_name} account is already linked to another {platform_name} account. Please {link_start}log out{link_end}, then log in with your {provider_name} account.').format(link_end='</a>', link_start='<a href="%s">' % logout_url, provider_name='<strong>%s</strong>' % duplicate_provider.NAME, platform_name=platform_name)} + </section> + % endif + <section class="profile-sidebar"> <header class="profile"> <h1 class="user-name">${ user.username }</h1> @@ -215,6 +223,53 @@ <%include file='dashboard/_dashboard_info_language.html' /> %endif + % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + <li class="controls--account"> + <span class="title"> + <div class="icon icon-gears"></div> + ## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account. + ${_("Account Links")} + </span> + + <span class="data"> + <span class="third-party-auth"> + + % for state in provider_user_states: + <div> + + % if state.has_account: + <span class="icon icon-link pull-left"></span> + % else: + <span class="icon icon-unlink pull-left"></span> + % endif + + <span class="provider">${state.provider.NAME}</span> + <span class="control"> + + % if state.has_account: + <form + action="${pipeline.get_disconnect_url(state.provider.NAME)}" + method="post" + name="${state.get_unlink_form_name()}"> + <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"> + </form> + <a href="#" onclick="document.${state.get_unlink_form_name()}.submit()"> + ## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). + ${_("unlink")} + </a> + % else: + <a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_DASHBOARD)}"> + ## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). + ${_("link")} + </a> + % endif + </span> + </div> + % endfor + </span> + </li> + % endif + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain: <li class="controls--account"> <span class="title"><div class="icon"></div><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span> diff --git a/lms/templates/login.html b/lms/templates/login.html index ec5da5ad5091dd809b3dedc421ec7006bdbaa626..5d79076227b71394ef71a12ac4e4f72bec9dfe2c 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -4,6 +4,7 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> +<%! from third_party_auth import provider, pipeline %> <%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)}</%block> @@ -93,6 +94,22 @@ text("${_(u'Processing your account information…')}"); } } + + function thirdPartySignin(event, url) { + event.preventDefault(); + window.location.href = url; + } + + (function post_form_if_pipeline_running(pipeline_running) { + // If the pipeline is running, the user has already authenticated via a + // third-party provider. We want to invoke /login_ajax to loop in the + // code that does logging and sets cookies on the request. It is most + // consistent to do that by using the same mechanism that is used when + // the use does first-party sign-in: POSTing the sign-in form. + if (pipeline_running) { + $('#login-form').submit(); + } + })(${pipeline_running}) </script> </%block> @@ -164,6 +181,28 @@ <button name="submit" type="submit" id="submit" class="action action-primary action-update"></button> </div> </form> + + % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + + <hr /> + + <p class="instructions"> + ## Developers: this is a sentence fragment, which is usually frowned upon. The design of the pags uses this fragment to provide an "else" clause underneath a number of choices. It's OK to leave it. + ## Translators: this is the last choice of a number of choices of how to log in to the site. + ${_('or, if you have connected one of these providers, log in below.')} + </p> + + <div class="form-actions form-third-party-auth"> + + % for enabled in provider.Registry.enabled(): + ## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn). + <button type="submit" class="button button-primary" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_LOGIN)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button> + % endfor + + </div> + + % endif + </section> <aside role="complementary"> diff --git a/lms/templates/register.html b/lms/templates/register.html index e8c818f2a5831f3843f50aaa1391292db94ae557..7e35ca4452ab2d5e5a2525e1f17e24c4579f9163 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -12,6 +12,7 @@ <%! from django.utils.translation import ugettext as _ %> <%! from student.models import UserProfile %> <%! from datetime import date %> +<%! from third_party_auth import pipeline, provider %> <%! import calendar %> <%block name="pagetitle">${_("Register for {platform_name}").format(platform_name=platform_name)}</%block> @@ -67,6 +68,11 @@ }); })(this); + function thirdPartySignin(event, url) { + event.preventDefault(); + window.location.href = url; + } + function toggleSubmitButton(enable) { var $submitButton = $('form .form-actions #submit'); @@ -110,11 +116,46 @@ <ul class="message-copy"> </ul> </div> + % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + + % if not running_pipeline: + + <p class="instructions"> + ${_("Register to start learning today!")} + </p> + + <div class="form-actions form-third-party-auth"> + + % for enabled in provider.Registry.enabled(): + ## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn). + <button type="submit" class="button button-primary" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_REGISTER)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button> + % endfor + + </div> + + <p class="instructions"> + ${_('or create your own {platform_name} account by completing all <strong>required*</strong> fields below.').format(platform_name=platform_name)} + </p> + + % else: + + <p class="instructions"> + ## Translators: selected_provider is the name of an external, third-party user authentication service (like Google or LinkedIn). + ${_("You've successfully signed in with {selected_provider}.").format(selected_provider='<strong>%s</strong>' % selected_provider)}<br /> + ${_("Finish your account registration below to start learning.")} + </p> + + % endif + + % else: + <p class="instructions"> ${_("Please complete the following fields to register for an account. ")}<br /> ${_('Required fields are noted by <strong class="indicator">bold text and an asterisk (*)</strong>.')} </p> + % endif + <div class="group group-form group-form-requiredinformation"> <h2 class="sr">${_('Required Information')}</h2> @@ -123,20 +164,33 @@ <ol class="list-input"> <li class="field required text" id="field-email"> <label for="email">${_('E-mail')}</label> - <input class="" id="email" type="email" name="email" value="" placeholder="${_('example: username@domain.com')}" required aria-required="true" /> + <input class="" id="email" type="email" name="email" value="${email}" placeholder="${_('example: username@domain.com')}" required aria-required="true" /> </li> + + % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and running_pipeline: + + <li class="is-disabled field optional password" id="field-password" hidden> + <label for="password">${_('Password')}</label> + <input id="password" type="password" name="password" value="" disabled required aria-required="true" /> + </li> + + % else: + <li class="field required password" id="field-password"> <label for="password">${_('Password')}</label> <input id="password" type="password" name="password" value="" required aria-required="true" /> </li> + + % endif + <li class="field required text" id="field-username"> <label for="username">${_('Public Username')}</label> - <input id="username" type="text" name="username" value="" placeholder="${_('example: JaneDoe')}" required aria-required="true" aria-describedby="username-tip"/> + <input id="username" type="text" name="username" value="${username}" placeholder="${_('example: JaneDoe')}" required aria-required="true" aria-describedby="username-tip"/> <span class="tip tip-input" id="username-tip">${_('Will be shown in any discussions or forums you participate in')} <strong>(${_('cannot be changed later')})</strong></span> </li> <li class="field required text" id="field-name"> <label for="name">${_('Full Name')}</label> - <input id="name" type="text" name="name" value="" placeholder="${_('example: Jane Doe')}" required aria-required="true" aria-describedby="name-tip" /> + <input id="name" type="text" name="name" value="${name}" placeholder="${_('example: Jane Doe')}" required aria-required="true" aria-describedby="name-tip" /> <span class="tip tip-input" id="name-tip">${_("Needed for any certificates you may earn")}</span> </li> </ol> diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 012b0cefa39e9747a2b989f3b623a01b1d8926e2..466cadcd660286d1d2f83fb10c1c9e614e445c16 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -60,7 +60,7 @@ pyparsing==2.0.1 python-memcached==1.48 python-openid==2.2.5 python-dateutil==2.1 -python-social-auth==0.1.21 +python-social-auth==0.1.23 pytz==2012h pysrt==0.4.7 PyYAML==3.10