Commit 9e9dec1d by Will Daly

Integrate third party auth into the combined login/registration page.

Change third party auth login failure code to a 401, to detect authentication
success with no linked account.

If already authenticated, redirect immediately to the dashboard.

Use "Location" header correctly for 302 redirects from student views.

Add utility functions for simulating a running third-party auth pipeline.

Add a utility function for checking whether third party auth is enabled.

Respect default values sent by the server
parent afefe21c
......@@ -92,6 +92,7 @@ from util.password_policy_validators import (
validate_password_dictionary
)
import third_party_auth
from third_party_auth import pipeline, provider
from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import CourseRegistrationCode
......@@ -406,7 +407,7 @@ def register_user(request, extra_context=None):
# If third-party auth is enabled, prepopulate the form with data from the
# selected provider.
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request):
if third_party_auth.is_enabled() 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'))
......@@ -619,7 +620,7 @@ def dashboard(request):
'provider_states': [],
}
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
if third_party_auth.is_enabled():
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
context['provider_user_states'] = pipeline.get_provider_user_states(user)
......@@ -911,7 +912,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
redirect_url = None
response = None
running_pipeline = None
third_party_auth_requested = microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request)
third_party_auth_requested = third_party_auth.is_enabled() 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
......@@ -933,7 +934,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
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 HttpResponseBadRequest(
return HttpResponse(
_("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
)
......@@ -1344,7 +1345,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
)
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request):
if third_party_auth.is_enabled() and pipeline.running(request):
post_vars = dict(post_vars.items())
post_vars.update({'password': pipeline.make_random_password()})
......@@ -1524,7 +1525,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
# If the user is registering via 3rd party auth, track which provider they use
provider_name = None
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request):
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME
......@@ -1615,7 +1616,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
redirect_url = try_change_enrollment(request)
# Resume the third-party-auth pipeline if necessary.
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request):
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
......
"""Third party authentication. """
from microsite_configuration import microsite
def is_enabled():
"""Check whether third party authentication has been enabled. """
# We do this imports internally to avoid initializing settings prematurely
from django.conf import settings
return microsite.get_value(
"ENABLE_THIRD_PARTY_AUTH",
settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH")
)
......@@ -82,12 +82,27 @@ AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_PROFILE = 'profile'
AUTH_ENTRY_REGISTER = 'register'
# TODO (ECOM-369): Repace `AUTH_ENTRY_LOGIN` and `AUTH_ENTRY_REGISTER`
# with these values once the A/B test completes, then delete
# these constants.
AUTH_ENTRY_LOGIN_2 = 'account_login'
AUTH_ENTRY_REGISTER_2 = 'account_register'
_AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_DASHBOARD,
AUTH_ENTRY_LOGIN,
AUTH_ENTRY_PROFILE,
AUTH_ENTRY_REGISTER
AUTH_ENTRY_REGISTER,
# TODO (ECOM-369): For the A/B test of the combined
# login/registration, we needed to introduce two
# additional end-points. Once the test completes,
# delete these constants from the choices list.
AUTH_ENTRY_LOGIN_2,
AUTH_ENTRY_REGISTER_2,
])
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
_PASSWORD_CHARSET = string.letters + string.digits
......@@ -346,11 +361,28 @@ def parse_query_params(strategy, response, *args, **kwargs):
'is_register': auth_entry == AUTH_ENTRY_REGISTER,
# Whether the auth pipeline entered from /profile.
'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
}
# TODO (ECOM-369): Delete these once the A/B test
# for the combined login/registration form completes.
'is_login_2': auth_entry == AUTH_ENTRY_LOGIN_2,
'is_register_2': auth_entry == AUTH_ENTRY_REGISTER_2,
}
# TODO (ECOM-369): Once the A/B test of the combined login/registration
# form completes, we will be able to remove the extra login/registration
# end-points. HOWEVER, users who used the new forms during the A/B
# test may still have values for "is_login_2" and "is_register_2"
# in their sessions. For this reason, we need to continue accepting
# these kwargs in `redirect_to_supplementary_form`, but
# these should redirect to the same location as "is_login" and "is_register"
# (whichever login/registration end-points win in the test).
@partial.partial
def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_profile=None, is_register=None, user=None, *args, **kwargs):
def redirect_to_supplementary_form(
strategy, details, response, uid,
is_dashboard=None, is_login=None, is_profile=None, is_register=None,
is_login_2=None, is_register_2=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
......@@ -364,20 +396,33 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
# 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.
user_inactive = user and not user.is_active
user_unset = user is None
dispatch_to_login = is_login and (user_unset or user_inactive)
# TODO (ECOM-369): Consolidate this with `dispatch_to_login`
# once the A/B test completes.
dispatch_to_login_2 = is_login_2 and (user_unset or user_inactive)
if is_dashboard or is_profile:
return
if dispatch_to_login:
return redirect('/login', name='signin_user')
# TODO (ECOM-369): Consolidate this with `dispatch_to_login`
# once the A/B test completes.
if dispatch_to_login_2:
return redirect(reverse(AUTH_ENTRY_LOGIN_2))
if is_register and user_unset:
return redirect('/register', name='register_user')
# TODO (ECOM-369): Consolidate this with `is_register`
# once the A/B test completes.
if is_register_2 and user_unset:
return redirect(reverse(AUTH_ENTRY_REGISTER_2))
@partial.partial
def login_analytics(*args, **kwargs):
""" Sends login info to Segment.io """
......@@ -387,6 +432,12 @@ def login_analytics(*args, **kwargs):
'is_login': 'edx.bi.user.account.authenticated',
'is_dashboard': 'edx.bi.user.account.linked',
'is_profile': 'edx.bi.user.account.linked',
# Backwards compatibility: during an A/B test for the combined
# login/registration form, we introduced a new login end-point.
# Since users may continue to have this in their sessions after
# the test concludes, we need to continue accepting this action.
'is_login_2': 'edx.bi.user.account.authenticated',
}
# Note: we assume only one of the `action` kwargs (is_dashboard, is_login) to be
......@@ -408,7 +459,7 @@ def login_analytics(*args, **kwargs):
},
context={
'Google Analytics': {
'clientId': tracking_context.get('client_id')
'clientId': tracking_context.get('client_id')
}
}
)
......
......@@ -4,7 +4,9 @@ Utilities for writing third_party_auth tests.
Used by Django and non-Django tests; must not have Django deps.
"""
from contextlib import contextmanager
import unittest
import mock
from third_party_auth import provider
......@@ -37,3 +39,81 @@ class TestCase(unittest.TestCase):
provider.Registry._reset()
provider.Registry.configure_once(self._original_providers)
super(TestCase, self).tearDown()
@contextmanager
def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=None, username=None):
"""Simulate that a pipeline is currently running.
You can use this context manager to test packages that rely on third party auth.
This uses `mock.patch` to override some calls in `third_party_auth.pipeline`,
so you will need to provide the "target" module *as it is imported*
in the software under test. For example, if `foo/bar.py` does this:
>>> from third_party_auth import pipeline
then you will need to do something like this:
>>> with simulate_running_pipeline("foo.bar.pipeline", "google-oauth2"):
>>> bar.do_something_with_the_pipeline()
If, on the other hand, `foo/bar.py` had done this:
>>> import third_party_auth
then you would use the target "foo.bar.third_party_auth.pipeline" instead.
Arguments:
pipeline_target (string): The path to `third_party_auth.pipeline` as it is imported
in the software under test.
backend (string): The name of the backend currently running, for example "google-oauth2".
Note that this is NOT the same as the name of the *provider*. See the Python
social auth documentation for the names of the backends.
Keyword Arguments:
email (string): If provided, simulate that the current provider has
included the user's email address (useful for filling in the registration form).
fullname (string): If provided, simulate that the current provider has
included the user's full name (useful for filling in the registration form).
username (string): If provided, simulate that the pipeline has provided
this suggested username. This is something that the `third_party_auth`
app generates itself and should be available by the time the user
is authenticating with a third-party provider.
Returns:
None
"""
pipeline_data = {
"backend": backend,
"kwargs": {
"details": {}
}
}
if email is not None:
pipeline_data["kwargs"]["details"]["email"] = email
if fullname is not None:
pipeline_data["kwargs"]["details"]["fullname"] = fullname
if username is not None:
pipeline_data["kwargs"]["username"] = username
pipeline_get = mock.patch("{pipeline}.get".format(pipeline=pipeline_target), spec=True)
pipeline_running = mock.patch("{pipeline}.running".format(pipeline=pipeline_target), spec=True)
mock_get = pipeline_get.start()
mock_running = pipeline_running.start()
mock_get.return_value = pipeline_data
mock_running.return_value = True
try:
yield
finally:
pipeline_get.stop()
pipeline_running.stop()
......@@ -2,6 +2,7 @@
Helper functions for the account/profile Python APIs.
This is NOT part of the public API.
"""
from collections import defaultdict
from functools import wraps
import logging
import json
......@@ -101,6 +102,12 @@ class FormDescription(object):
"password": ["min_length", "max_length"],
}
OVERRIDE_FIELD_PROPERTIES = [
"label", "type", "default", "placeholder",
"instructions", "required", "restrictions",
"options"
]
def __init__(self, method, submit_url):
"""Configure how the form should be submitted.
......@@ -112,6 +119,7 @@ class FormDescription(object):
self.method = method
self.submit_url = submit_url
self.fields = []
self._field_overrides = defaultdict(dict)
def add_field(
self, name, label=u"", field_type=u"text", default=u"",
......@@ -161,8 +169,8 @@ class FormDescription(object):
raise InvalidFieldError(msg)
field_dict = {
"label": label,
"name": name,
"label": label,
"type": field_type,
"default": default,
"placeholder": placeholder,
......@@ -192,6 +200,10 @@ class FormDescription(object):
)
raise InvalidFieldError(msg)
# If there are overrides for this field, apply them now.
# Any field property can be overwritten (for example, the default value or placeholder)
field_dict.update(self._field_overrides.get(name, {}))
self.fields.append(field_dict)
def to_json(self):
......@@ -244,6 +256,31 @@ class FormDescription(object):
"fields": self.fields
})
def override_field_properties(self, field_name, **kwargs):
"""Override properties of a field.
The overridden values take precedence over the values provided
to `add_field()`.
Field properties not in `OVERRIDE_FIELD_PROPERTIES` will be ignored.
Arguments:
field_name (string): The name of the field to override.
Keyword Args:
Same as to `add_field()`.
"""
# Transform kwarg "field_type" to "type" (a reserved Python keyword)
if "field_type" in kwargs:
kwargs["type"] = kwargs["field_type"]
self._field_overrides[field_name].update({
property_name: property_value
for property_name, property_value in kwargs.iteritems()
if property_name in self.OVERRIDE_FIELD_PROPERTIES
})
def shim_student_view(view_func, check_logged_in=False):
"""Create a "shim" view for a view function from the student Django app.
......@@ -320,16 +357,28 @@ def shim_student_view(view_func, check_logged_in=False):
success = True
redirect_url = None
# If the user is not authenticated, and we expect them to be
# send a status 403.
if check_logged_in and not request.user.is_authenticated():
response.status_code = 403
# If the user is not authenticated when we expect them to be
# send the appropriate status code.
# We check whether the user attribute is set to make
# it easier to test this without necessarily running
# the request through authentication middleware.
is_authenticated = (
getattr(request, 'user', None) is not None
and request.user.is_authenticated()
)
if check_logged_in and not is_authenticated:
# Preserve the 401 status code so the client knows
# that the user successfully authenticated with third-party auth
# but does not have a linked account.
# Otherwise, send a 403 to indicate that the login failed.
if response.status_code != 401:
response.status_code = 403
response.content = msg
# If the view wants to redirect us, send a status 302
elif redirect_url is not None:
response.status_code = 302
response.content = redirect_url
response['Location'] = redirect_url
# If an error condition occurs, send a status 400
elif response.status_code != 200 or not success:
......@@ -343,6 +392,9 @@ def shim_student_view(view_func, check_logged_in=False):
# If the response is successful, then return the content
# of the response directly rather than including it
# in a JSON-serialized dictionary.
# This will also preserve error status codes such as a 401
# (if the user is trying to log in using a third-party provider
# but hasn't yet linked his or her account.)
else:
response.content = msg
......
......@@ -144,6 +144,15 @@ class StudentViewShimTest(TestCase):
self.assertNotIn("enrollment_action", self.captured_request.POST)
self.assertNotIn("course_id", self.captured_request.POST)
@ddt.data(True, False)
def test_preserve_401_status(self, check_logged_in):
view = self._shimmed_view(
HttpResponse(status=401),
check_logged_in=check_logged_in
)
response = view(HttpRequest())
self.assertEqual(response.status_code, 401)
def test_non_json_response(self):
view = self._shimmed_view(HttpResponse(content="Not a JSON dict"))
response = view(HttpRequest())
......@@ -160,7 +169,7 @@ class StudentViewShimTest(TestCase):
)
response = view(HttpRequest())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.content, "/redirect")
self.assertEqual(response['Location'], "/redirect")
def test_error_from_json(self):
view = self._shimmed_view(
......@@ -180,8 +189,13 @@ class StudentViewShimTest(TestCase):
response = view(HttpRequest())
self.assertEqual(response["test-header"], "test")
def _shimmed_view(self, response):
def test_check_logged_in(self):
view = self._shimmed_view(HttpResponse(), check_logged_in=True)
response = view(HttpRequest())
self.assertEqual(response.status_code, 403)
def _shimmed_view(self, response, check_logged_in=False):
def stub_view(request):
self.captured_request = request
return response
return shim_student_view(stub_view)
return shim_student_view(stub_view, check_logged_in=check_logged_in)
......@@ -13,7 +13,7 @@ from django.test.utils import override_settings
from unittest import SkipTest, skipUnless
import ddt
from pytz import UTC
from mock import patch
import mock
from user_api.api import account as account_api, profile as profile_api
......@@ -21,6 +21,7 @@ from student.tests.factories import UserFactory
from user_api.tests.factories import UserPreferenceFactory
from django_comment_common import models
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.tests.test_constants import SORTED_COUNTRIES
......@@ -808,6 +809,82 @@ class RegistrationViewTest(ApiTestCase):
}
)
def test_register_form_third_party_auth_running(self):
no_extra_fields_setting = {}
with simulate_running_pipeline(
"user_api.views.third_party_auth.pipeline",
"google-oauth2", email="bob@example.com",
fullname="Bob", username="Bob123"
):
# Password field should be hidden
self._assert_reg_field(
no_extra_fields_setting,
{
"name": "password",
"default": "",
"type": "hidden",
"required": False,
"label": "",
"placeholder": "",
"instructions": "",
"restrictions": {},
}
)
# Email should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"email",
u"default": u"bob@example.com",
u"type": u"text",
u"required": True,
u"label": u"E-mail",
u"placeholder": u"example: username@domain.com",
u"instructions": u"This is the e-mail address you used to register with edX",
u"restrictions": {
u"min_length": 3,
u"max_length": 254
},
}
)
# Full name should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"name",
u"default": u"Bob",
u"type": u"text",
u"required": True,
u"label": u"Full Name",
u"placeholder": u"",
u"instructions": u"Needed for any certificates you may earn",
u"restrictions": {
"max_length": 255,
}
}
)
# Username should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"username",
u"default": u"Bob123",
u"type": u"text",
u"required": True,
u"label": u"Public Username",
u"placeholder": u"",
u"instructions": u"Will be shown in any discussions or forums you participate in (cannot be changed)",
u"restrictions": {
u"min_length": 2,
u"max_length": 30,
}
}
)
def test_register_form_level_of_education(self):
self._assert_reg_field(
{"level_of_education": "optional"},
......@@ -950,7 +1027,7 @@ class RegistrationViewTest(ApiTestCase):
@override_settings(
MKTG_URLS={"ROOT": "https://www.test.com/", "HONOR": "honor"},
)
@patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
def test_registration_honor_code_mktg_site_enabled(self):
self._assert_reg_field(
{"honor_code": "required"},
......@@ -967,7 +1044,7 @@ class RegistrationViewTest(ApiTestCase):
)
@override_settings(MKTG_URLS_LINK_MAP={"HONOR": "honor"})
@patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
def test_registration_honor_code_mktg_site_disabled(self):
self._assert_reg_field(
{"honor_code": "required"},
......@@ -988,7 +1065,7 @@ class RegistrationViewTest(ApiTestCase):
"HONOR": "honor",
"TOS": "tos",
})
@patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
def test_registration_separate_terms_of_service_mktg_site_enabled(self):
# Honor code field should say ONLY honor code,
# not "terms of service and honor code"
......@@ -1022,7 +1099,7 @@ class RegistrationViewTest(ApiTestCase):
)
@override_settings(MKTG_URLS_LINK_MAP={"HONOR": "honor", "TOS": "tos"})
@patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
def test_registration_separate_terms_of_service_mktg_site_disabled(self):
# Honor code field should say ONLY honor code,
# not "terms of service and honor code"
......
......@@ -24,6 +24,8 @@ from django_comment_common.models import Role
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from edxmako.shortcuts import marketing_link
import third_party_auth
from microsite_configuration import microsite
from user_api.api import account as account_api, profile as profile_api
from user_api.helpers import FormDescription, shim_student_view, require_post_params
......@@ -111,6 +113,8 @@ class LoginSessionView(APIView):
Returns:
HttpResponse: 200 on success
HttpResponse: 400 if the request is not valid.
HttpResponse: 401 if the user successfully authenticated with a third-party
provider but does not have a linked account.
HttpResponse: 403 if authentication failed.
HttpResponse: 302 if redirecting to another page.
......@@ -189,6 +193,7 @@ class RegistrationView(APIView):
"""
form_desc = FormDescription("post", reverse("user_api_registration"))
self._apply_third_party_auth_overrides(request, form_desc)
# Default fields are always required
for field_name in self.DEFAULT_FIELDS:
......@@ -408,6 +413,53 @@ class RegistrationView(APIView):
[("", "--")] + list(options)
)
def _apply_third_party_auth_overrides(self, request, form_desc):
"""Modify the registration form if the user has authenticated with a third-party provider.
If a user has successfully authenticated with a third-party provider,
but does not yet have an account with EdX, we want to fill in
the registration form with any info that we get from the
provider.
This will also hide the password field, since we assign users a default
(random) password on the assumption that they will be using
third-party auth to log in.
Arguments:
request (HttpRequest): The request for the registration form, used
to determine if the user has successfully authenticated
with a third-party provider.
form_desc (FormDescription): The registration form description
"""
if third_party_auth.is_enabled():
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
# Override username / email / full name
field_overrides = current_provider.get_register_form_data(
running_pipeline.get('kwargs')
)
for field_name in self.DEFAULT_FIELDS:
if field_name in field_overrides:
form_desc.override_field_properties(
field_name, default=field_overrides[field_name]
)
# Hide the password field
form_desc.override_field_properties(
"password",
default="",
field_type="hidden",
required=False,
label="",
instructions="",
restrictions={}
)
class UserViewSet(viewsets.ReadOnlyModelViewSet):
authentication_classes = (authentication.SessionAuthentication,)
......
......@@ -6,7 +6,7 @@ from unittest import skipUnless
from urllib import urlencode
import json
from mock import patch
import mock
import ddt
from django.test import TestCase
from django.conf import settings
......@@ -14,14 +14,15 @@ from django.core.urlresolvers import reverse
from django.core import mail
from util.testing import UrlResetMixin
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
@ddt.ddt
class StudentAccountViewTest(UrlResetMixin, TestCase):
""" Tests for the student account views. """
class StudentAccountUpdateTest(UrlResetMixin, TestCase):
""" Tests for the student account views that update the user's account information. """
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
......@@ -51,9 +52,9 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
INVALID_KEY = u"123abc"
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
@mock.patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
def setUp(self):
super(StudentAccountViewTest, self).setUp("student_account.urls")
super(StudentAccountUpdateTest, self).setUp("student_account.urls")
# Create/activate a new account
activation_key = account_api.create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
......@@ -67,37 +68,6 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
response = self.client.get(reverse('account_index'))
self.assertContains(response, "Student Account")
@ddt.data(
("account_login", "login"),
("account_register", "register"),
)
@ddt.unpack
def test_login_and_registration_form(self, url_name, initial_mode):
response = self.client.get(reverse(url_name))
expected_data = u"data-initial-mode=\"{mode}\"".format(mode=initial_mode)
self.assertContains(response, expected_data)
@ddt.data("account_login", "account_register")
def test_login_and_registration_third_party_auth_urls(self, url_name):
response = self.client.get(reverse(url_name))
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_data = u"data-third-party-auth-providers=\"{providers}\"".format(
providers=json.dumps([
{
u'icon_class': u'icon-facebook',
u'login_url': u'/auth/login/facebook/?auth_entry=login',
u'name': u'Facebook'
},
{
u'icon_class': u'icon-google-plus',
u'login_url': u'/auth/login/google-oauth2/?auth_entry=login',
u'name': u'Google'
}
])
)
self.assertContains(response, expected_data)
def test_change_email(self):
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 200)
......@@ -144,7 +114,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
def test_email_change_request_no_user(self):
# Patch account API to raise an internal error when an email change is requested
with patch('student_account.views.account_api.request_email_change') as mock_call:
with mock.patch('student_account.views.account_api.request_email_change') as mock_call:
mock_call.side_effect = account_api.AccountUserNotFound
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
......@@ -215,7 +185,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD)
# Patch account API to return an internal error
with patch('student_account.views.account_api.confirm_email_change') as mock_call:
with mock.patch('student_account.views.account_api.confirm_email_change') as mock_call:
mock_call.side_effect = account_api.AccountInternalError
response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
......@@ -392,3 +362,88 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
data['email'] = email
return self.client.post(path=reverse('password_change_request'), data=data)
@ddt.ddt
class StudentAccountLoginAndRegistrationTest(TestCase):
""" Tests for the student account views that update the user's account information. """
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = "password"
@ddt.data(
("account_login", "login"),
("account_register", "register"),
)
@ddt.unpack
def test_login_and_registration_form(self, url_name, initial_mode):
response = self.client.get(reverse(url_name))
expected_data = u"data-initial-mode=\"{mode}\"".format(mode=initial_mode)
self.assertContains(response, expected_data)
@ddt.data("account_login", "account_register")
def test_login_and_registration_form_already_authenticated(self, url_name):
# Create/activate a new account and log in
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.activate_account(activation_key)
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.assertTrue(result)
# Verify that we're redirected to the dashboard
response = self.client.get(reverse(url_name))
self.assertRedirects(response, reverse("dashboard"))
@mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data("account_login", "account_register")
def test_third_party_auth_disabled(self, url_name):
response = self.client.get(reverse(url_name))
expected_data = "data-third-party-auth='{auth_info}'".format(
auth_info=json.dumps({
"currentProvider": None,
"providers": []
})
)
self.assertContains(response, expected_data)
@ddt.data(
("account_login", None, None),
("account_register", None, None),
("account_login", "google-oauth2", "Google"),
("account_register", "google-oauth2", "Google"),
("account_login", "facebook", "Facebook"),
("account_register", "facebook", "Facebook"),
)
@ddt.unpack
def test_third_party_auth(self, url_name, current_backend, current_provider):
# Simulate a running pipeline
if current_backend is not None:
pipeline_target = "student_account.views.third_party_auth.pipeline"
with simulate_running_pipeline(pipeline_target, current_backend):
response = self.client.get(reverse(url_name))
# Do NOT simulate a running pipeline
else:
response = self.client.get(reverse(url_name))
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_data = u"data-third-party-auth='{auth_info}'".format(
auth_info=json.dumps({
"currentProvider": current_provider,
"providers": [
{
"name": "Facebook",
"iconClass": "icon-facebook",
"loginUrl": "/auth/login/facebook/?auth_entry=account_login",
"registerUrl": "/auth/login/facebook/?auth_entry=account_register",
},
{
"name": "Google",
"iconClass": "icon-google-plus",
"loginUrl": "/auth/login/google-oauth2/?auth_entry=account_login",
"registerUrl": "/auth/login/google-oauth2/?auth_entry=account_register",
}
]
})
)
self.assertContains(response, expected_data)
......@@ -6,13 +6,15 @@ from django.conf import settings
from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
)
from django.shortcuts import redirect
from django.core.urlresolvers import reverse
from django.core.mail import send_mail
from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from edxmako.shortcuts import render_to_response, render_to_string
import third_party_auth
from microsite_configuration import microsite
import third_party_auth
from user_api.api import account as account_api
from user_api.api import profile as profile_api
......@@ -58,24 +60,17 @@ def login_and_registration_form(request, initial_mode="login"):
initial_mode (string): Either "login" or "registration".
"""
# If we're already logged in, redirect to the dashboard
if request.user.is_authenticated():
return redirect(reverse('dashboard'))
# Otherwise, render the combined login/registration page
context = {
'disable_courseware_js': True,
'initial_mode': initial_mode,
'third_party_auth_providers': json.dumps([])
'third_party_auth': json.dumps(_third_party_auth_context(request)),
}
if microsite.get_value("ENABLE_THIRD_PARTY_AUTH", settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH")):
context["third_party_auth_providers"] = json.dumps([
{
"name": enabled.NAME,
"icon_class": enabled.ICON_CLASS,
"login_url": third_party_auth.pipeline.get_login_url(
enabled.NAME, third_party_auth.pipeline.AUTH_ENTRY_LOGIN
),
}
for enabled in third_party_auth.provider.Registry.enabled()
])
return render_to_response('student_account/login_and_register.html', context)
......@@ -266,3 +261,44 @@ def password_change_request_handler(request):
return HttpResponse(status=200)
else:
return HttpResponseBadRequest("No email address provided.")
def _third_party_auth_context(request):
"""Context for third party auth providers and the currently running pipeline.
Arguments:
request (HttpRequest): The request, used to determine if a pipeline
is currently running.
Returns:
dict
"""
context = {
"currentProvider": None,
"providers": []
}
if third_party_auth.is_enabled():
context["providers"] = [
{
"name": enabled.NAME,
"iconClass": enabled.ICON_CLASS,
"loginUrl": third_party_auth.pipeline.get_login_url(
enabled.NAME, third_party_auth.pipeline.AUTH_ENTRY_LOGIN_2
),
"registerUrl": third_party_auth.pipeline.get_login_url(
enabled.NAME, third_party_auth.pipeline.AUTH_ENTRY_REGISTER_2
)
}
for enabled in third_party_auth.provider.Registry.enabled()
]
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline is not None:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(
running_pipeline.get('backend')
)
context["currentProvider"] = current_provider.NAME
return context
......@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response
from user_api.api import profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api
from third_party_auth import pipeline
import third_party_auth
@login_required
......@@ -60,8 +60,8 @@ def _get_profile(request):
'disable_courseware_js': True
}
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
context['provider_user_states'] = pipeline.get_provider_user_states(user)
if third_party_auth.is_enabled():
context['provider_user_states'] = third_party_auth.pipeline.get_provider_user_states(user)
return render_to_response('student_profile/index.html', context)
......
......@@ -7,7 +7,7 @@ var edx = edx || {};
edx.student.account = edx.student.account || {};
return new edx.student.account.AccessView({
mode: $('#login-and-registration-container').data('initial-mode') || 'login',
thirdPartyAuth: $('#login-and-registration-container').data('third-party-auth-providers') || false
mode: $('#login-and-registration-container').data('initial-mode'),
thirdPartyAuth: $('#login-and-registration-container').data('third-party-auth')
});
})(jQuery);
\ No newline at end of file
})(jQuery);
......@@ -26,7 +26,13 @@ var edx = edx || {};
initialize: function( obj ) {
this.tpl = $(this.tpl).html();
this.activeForm = obj.mode;
this.activeForm = obj.mode || 'login';
this.thirdPartyAuth = obj.thirdPartyAuth || {
currentProvider: null,
providers: []
};
console.log(obj);
this.render();
},
......@@ -48,12 +54,12 @@ var edx = edx || {};
loadForm: function( type ) {
if ( type === 'login' ) {
this.subview.login = new edx.student.account.LoginView();
this.subview.login = new edx.student.account.LoginView( this.thirdPartyAuth );
// Listen for 'password-help' event to toggle sub-views
this.listenTo( this.subview.login, 'password-help', this.resetPassword );
} else if ( type === 'register' ) {
this.subview.register = new edx.student.account.RegisterView();
this.subview.register = new edx.student.account.RegisterView( this.thirdPartyAuth );
} else if ( type === 'reset' ) {
this.subview.passwordHelp = new edx.student.account.PasswordResetView();
}
......
......@@ -17,15 +17,20 @@ var edx = edx || {};
events: {
'click .js-login': 'submitForm',
'click .forgot-password': 'forgotPassword'
'click .forgot-password': 'forgotPassword',
'click .login-provider': 'thirdPartyAuth'
},
errors: [],
$form: {},
initialize: function() {
initialize: function( thirdPartyAuthInfo ) {
this.tpl = $(this.tpl).html();
this.providers = thirdPartyAuthInfo.providers || [];
this.currentProvider = thirdPartyAuthInfo.currentProvider || "";
this.getInitialData();
},
......@@ -34,7 +39,9 @@ var edx = edx || {};
var fields = html || '';
$(this.el).html( _.template( this.tpl, {
fields: fields
fields: fields,
currentProvider: this.currentProvider,
providers: this.providers
}));
this.postRender();
......@@ -44,9 +51,16 @@ var edx = edx || {};
postRender: function() {
var $container = $(this.el);
this.$form = $container.find('form');
this.$errors = $container.find('.error-msg');
this.$alreadyAuthenticatedMsg = $container.find('.already-authenticated-msg');
// If we're already authenticated with a third-party
// provider, try logging in. The easiest way to do this
// is to simply submit the form.
if (this.currentProvider) {
this.model.save();
}
},
getInitialData: function() {
......@@ -58,8 +72,8 @@ var edx = edx || {};
url: '/user_api/v1/account/login_session/',
success: function( data ) {
console.log(data);
that.buildForm( data.fields );
that.initModel( data.submit_url, data.method );
that.buildForm( data.fields );
},
error: function( jqXHR, textStatus, errorThrown ) {
console.log('fail ', errorThrown);
......@@ -72,9 +86,7 @@ var edx = edx || {};
url: url
});
this.listenTo( this.model, 'error', function( error ) {
console.log(error.status, ' error: ', error.responseText);
});
this.listenTo( this.model, 'error', this.saveError );
},
buildForm: function( data ) {
......@@ -148,6 +160,16 @@ var edx = edx || {};
}
},
thirdPartyAuth: function( event ) {
var providerUrl = $(event.target).data("provider-url") || "";
if (providerUrl) {
window.location.href = providerUrl;
} else {
// TODO -- error handling here
console.log("No URL available for third party auth provider");
}
},
toggleErrorMsg: function( show ) {
if ( show ) {
this.$errors.removeClass('hidden');
......@@ -158,6 +180,23 @@ var edx = edx || {};
validate: function( $el ) {
return edx.utils.validate( $el );
},
saveError: function( error ) {
console.log(error.status, ' error: ', error.responseText);
// If we've gotten a 401 error, it means that we've successfully
// authenticated with a third-party provider, but we haven't
// linked the account to an EdX account. In this case,
// we need to prompt the user to enter a little more information
// to complete the registration process.
if (error.status === 401 && this.currentProvider) {
this.$alreadyAuthenticatedMsg.removeClass("hidden");
}
else {
this.$alreadyAuthenticatedMsg.addClass("hidden");
// TODO -- display the error
}
}
});
......
......@@ -16,15 +16,19 @@ var edx = edx || {};
fieldTpl: $('#form_field-tpl').html(),
events: {
'click .js-register': 'submitForm'
'click .js-register': 'submitForm',
'click .login-provider': 'thirdPartyAuth'
},
errors: [],
$form: {},
initialize: function() {
initialize: function( thirdPartyAuthInfo ) {
this.tpl = $(this.tpl).html();
this.providers = thirdPartyAuthInfo.providers || [];
this.currentProvider = thirdPartyAuthInfo.currentProvider || "";
this.getInitialData();
},
......@@ -33,7 +37,9 @@ var edx = edx || {};
var fields = html || '';
$(this.el).html( _.template( this.tpl, {
fields: fields
fields: fields,
currentProvider: this.currentProvider,
providers: this.providers
}));
this.postRender();
......@@ -81,8 +87,10 @@ var edx = edx || {};
i,
len = data.length,
fieldTpl = this.fieldTpl;
console.log('buildForm ', data);
for ( i=0; i<len; i++ ) {
// "default" is reserved in JavaScript
data[i].value = data[i]["default"];
html.push( _.template( fieldTpl, $.extend( data[i], {
form: 'register'
}) ) );
......@@ -142,6 +150,16 @@ console.log(this.model);
}
},
thirdPartyAuth: function( event ) {
var providerUrl = $(event.target).data("provider-url") || "";
if (providerUrl) {
window.location.href = providerUrl;
} else {
// TODO -- error handling here
console.log("No URL available for third party auth provider");
}
},
toggleErrorMsg: function( show ) {
if ( show ) {
this.$errors.removeClass('hidden');
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.template import RequestContext %>
<%! import third_party_auth %>
<%! from third_party_auth import pipeline %>
<%! from microsite_configuration import microsite %>
......@@ -95,7 +96,7 @@
<%include file='dashboard/_dashboard_info_language.html' />
%endif
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
<li class="controls--account">
<span class="title">
## 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.
......
......@@ -4,6 +4,7 @@
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%! import third_party_auth %>
<%! from third_party_auth import provider, pipeline %>
<%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)}</%block>
......@@ -48,8 +49,16 @@
$('#login-form').on('ajax:error', function(event, request, status_string) {
toggleSubmitButton(true);
$('.third-party-signin.message').addClass('is-shown').focus();
$('.third-party-signin.message .instructions').html(request.responseText);
if (request.status === 401) {
$('.message.submission-error').removeClass('is-shown');
$('.third-party-signin.message').addClass('is-shown').focus();
$('.third-party-signin.message .instructions').html(request.responseText);
} else {
$('.third-party-signin.message').removeClass('is-shown');
$('.message.submission-error').addClass('is-shown').focus();
$('.message.submission-error').html(gettext("Your request could not be completed. Please try again."));
}
});
$('#login-form').on('ajax:success', function(event, json, xhr) {
......@@ -194,7 +203,7 @@
</div>
</form>
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
<span class="deco-divider">
## 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.
......
......@@ -12,6 +12,7 @@
<%! from django.utils.translation import ugettext as _ %>
<%! from student.models import UserProfile %>
<%! from datetime import date %>
<%! import third_party_auth %>
<%! from third_party_auth import pipeline, provider %>
<%! import calendar %>
......@@ -116,7 +117,7 @@
<ul class="message-copy"> </ul>
</div>
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
% if not running_pipeline:
......@@ -182,7 +183,7 @@
<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>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and running_pipeline:
% if third_party_auth.is_enabled() and running_pipeline:
<li class="is-disabled field optional password" id="field-password" hidden>
<label for="password">${_('Password')}</label>
......
......@@ -27,6 +27,7 @@
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %>
<% if ( required ) { %> required<% } %>
value="<%- value %>"
/>
<% } %>
......
......@@ -5,6 +5,22 @@
<p>Email or password is incorrent. <a href="#">Forgot password?</a></p>
</div>
</div>
<div class="already-authenticated-msg hidden">
<% if (currentProvider) { %>
<p class="instructions">
You've successfully logged into <%- currentProvider %>, but you need to link
your account. Please click "I am a returning user" to create
an EdX account.
</p>
<% } %>
</div>
<%= fields %>
<button class="action action-primary action-update js-login">Log in</button>
</form>
\ No newline at end of file
</form>
<% for (var i=0; i < providers.length; i++) {
var provider = providers[i];
%>
<button type="submit"class="button button-primary button-<%- provider.name %> login-provider" data-provider-url="<%- provider.loginUrl %>">
<span class="icon <%- provider.iconClass %>"></span>Sign in with <%- provider.name %>
</button>
<% } %>
......@@ -19,34 +19,10 @@
% endfor
</%block>
## TODO: Use JavaScript to populate this div with
## the actual registration/login forms (loaded asynchronously from the user API)
## The URLS for the forms are:
## - GET /user_api/v1/registration/
## - GET /user_api/v1/login_session/
##
## You can post back to those URLs with JSON-serialized
## data from the form fields in order to complete the registration
## or login.
##
## Also TODO: we need to figure out how to enroll students in
## a course if they got here from a course about page.
##
## third_party_auth_providers is a JSON-serialized list of
## dictionaries of the form:
## {
## "name": "Facebook",
## "icon_class": "facebook-icon",
## "login_url": "http://api.facebook.com/auth"
## }
##
## Note that this list may be empty.
##
<div class="section-bkg-wrapper">
<div id="login-and-registration-container"
class="login-register"
data-initial-mode="${initial_mode}"
data-third-party-auth-providers="${third_party_auth_providers}"
data-third-party-auth='${third_party_auth}'
/>
</div>
<% if (currentProvider) { %>
<p class="instructions">
You've successfully signed in with <strong><%- currentProvider %></strong>.<br />
We just need a little more information before you start learning with edX.
</p>
<% } else {
for (var i=0; i < providers.length; i++) {
var provider = providers[i];
%>
<button type="submit"class="button button-primary button-<%- provider.name %> login-provider" data-provider-url="<%- provider.registerUrl %>">
<span class="icon <%- provider.iconClass %>"></span>Sign up with <%- provider.name %>
</button>
<% }
} %>
<form id="register" autocomplete="off">
<div class="error-msg hidden">
<h4>An error occured in your registration.</h4>
......
<%! from django.utils.translation import ugettext as _ %>
<%! import third_party_auth %>
<%namespace name='static' file='/static_content.html'/>
<%inherit file="../main.html" />
......@@ -25,6 +26,6 @@
<div id="profile-container"></div>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
% if third_party_auth.is_enabled():
<%include file="third_party_auth.html" />
% endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment