Commit 7ee7093b by E. Kolpakov

Automatic account provisioning for opted-in SAML providers

parent 9d4b82f3
......@@ -1357,11 +1357,22 @@ def change_setting(request):
class AccountValidationError(Exception):
""" Exception thrown if some account validation error happened """
def __init__(self, message, field):
super(AccountValidationError, self).__init__(message)
self.field = field
class AccountUserNameValidationError(AccountValidationError):
""" Exception thrown if attempted to create account with username already taken """
pass
class AccountEmailAlreadyExistsValidationError(AccountValidationError):
""" Exception thrown if attempted to create account with email already used by other account """
pass
@receiver(post_save, sender=User)
def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
"""
......@@ -1403,12 +1414,12 @@ def _do_create_account(form):
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=user.username)) > 0:
raise AccountValidationError(
raise AccountUserNameValidationError(
_("An account with the Public Username '{username}' already exists.").format(username=user.username),
field="username"
)
elif len(User.objects.filter(email=user.email)) > 0:
raise AccountValidationError(
raise AccountEmailAlreadyExistsValidationError(
_("An account with the Email '{email}' already exists.").format(email=user.email),
field="email"
)
......@@ -1442,7 +1453,8 @@ def _do_create_account(form):
return (user, profile, registration)
def create_account_with_params(request, params):
# pylint: disable=too-many-statements
def create_account_with_params(request, params, skip_email=False):
"""
Given a request and a dict of parameters (which may or may not have come
from the request), create an account for the requesting user, including
......@@ -1536,7 +1548,8 @@ def create_account_with_params(request, params):
(user, profile, registration) = _do_create_account(form)
# next, link the account with social auth, if provided via the API.
# (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
# (If the user is using the normal register page or account is automatically provisioned,
# the social auth pipeline does the linking, not this code)
if should_link_with_social_auth:
backend_name = params['provider']
request.social_strategy = social_utils.load_strategy(request)
......@@ -1620,11 +1633,12 @@ def create_account_with_params(request, params):
# the other for *new* systems. we need to be careful about
# changing settings on a running system to make sure no users are
# left in an inconsistent state (or doing a migration if they are).
send_email = (
not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and
not (
send_email = not (
skip_email or
settings.FEATURES.get('SKIP_EMAIL_VALIDATION', False) or
settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING', False) or
(do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) or
(
third_party_provider and third_party_provider.skip_email_verification and
user.email == running_pipeline['kwargs'].get('details', {}).get('email')
)
......
......@@ -93,6 +93,15 @@ class ProviderConfig(ConfigurationModel):
"email, and their account will be activated immediately upon registration."
),
)
autoprovision_account = models.BooleanField(
default=False,
help_text=_(
"If this option is selected, users will not be required to confirm their details even if "
"some required data is missing or fails validation (e.g. duplicate email). Instead, fake or generated "
"values will be used. This setting forces skipping email verification, so 'Skip email verification' "
"setting have no effect."
)
)
prefix = None # used for provider_id. Set to a string value in subclass
backend_name = None # Set to a field or fixed value in subclass
......
......@@ -56,12 +56,12 @@ 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 collections import OrderedDict
import urllib
import analytics
from django.conf import settings
from eventtracking import tracker
from django.contrib.auth.models import User
......@@ -73,6 +73,7 @@ from social.apps.django_app.default import models
from social.exceptions import AuthException
from social.pipeline import partial
from social.pipeline.social_auth import associate_by_email
from social.pipeline.user import create_user as social_create_user
import student
......@@ -80,9 +81,6 @@ from logging import getLogger
from . import provider
# Note that this lives in openedx, so this dependency should be refactored.
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
# These are the query string params you can pass
# to the URL that starts the authentication process.
......@@ -189,6 +187,20 @@ class NotActivatedException(AuthException):
)
class EmailAlreadyInUseException(AuthException):
""" Raised when new user account is created with an email already used by another account """
def __init__(self, backend, email):
self.email = email
super(EmailAlreadyInUseException, self).__init__(backend, email)
def __str__(self):
return (
_('Email {email_address} is already used in our system. To link your accounts, '
'sign in now using your {platform_name} password.')
.format(email_address=self.email, platform_name=settings.PLATFORM_NAME)
)
class ProviderUserState(object):
"""Object representing the provider state (attached or not) for a user.
......@@ -503,18 +515,34 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
"""Redirects to the registration page."""
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
def should_force_account_creation():
""" For some third party providers, we auto-create user accounts """
current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs})
def get_provider():
"""
Gets third-party provider for request
"""
return provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs})
def should_autoprovision_account():
""" For some third party providers we trust the provider so much that we automatically provision the account """
current_provider = get_provider()
return current_provider and current_provider.autoprovision_account
def autosubmit_registration_form():
""" For some third party providers, we auto-submit registration forms """
current_provider = get_provider()
return current_provider and current_provider.skip_email_verification
if not user:
if should_autoprovision_account():
# User has authenticated with the third party provider and provider is configured
# to automatically provision edX account, which is done via strategy.create_user in next pipeline step
return {'autoprovision': True}
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
# User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any.
if should_force_account_creation():
if autosubmit_registration_form():
return dispatch_to_register()
return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
......@@ -555,6 +583,22 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
@partial.partial
def create_user(strategy, details, user=None, *args, **kwargs):
"""
Substitution method for stock social create_user that catches email validation error and redirects to login
"""
from student.views import AccountEmailAlreadyExistsValidationError
try:
return social_create_user(strategy, details, user, *args, **kwargs)
except AccountEmailAlreadyExistsValidationError as exc:
logger.exception(exc.message)
# We're raising an exception that inherits from AuthException. Such exceptions are properly handled
# by social auth pipeline: their string representation (see __str__ method) is displayed to user on the page
# we're redirecting to.
raise EmailAlreadyInUseException(exc.message, details['email'])
@partial.partial
def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs):
"""This pipeline step sets the "logged in" cookie for authenticated users.
......
......@@ -9,7 +9,6 @@ If true, it:
a) loads this module.
b) calls apply_settings(), passing in the Django settings
"""
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
......@@ -53,7 +52,7 @@ def apply_settings(django_settings):
'social.pipeline.user.get_username',
'third_party_auth.pipeline.set_pipeline_timeout',
'third_party_auth.pipeline.ensure_user_information',
'social.pipeline.user.create_user',
'third_party_auth.pipeline.create_user',
'social.pipeline.social_auth.associate_user',
'social.pipeline.social_auth.load_extra_data',
'social.pipeline.user.user_details',
......@@ -85,3 +84,12 @@ def apply_settings(django_settings):
'social.apps.django_app.context_processors.backends',
'social.apps.django_app.context_processors.login_redirect',
)
# These fields are grabbed from third party auth response and passed to strategy.create_user
# If autoprovisioning an account we want as much data preserved as possible, so we try to get those as well
# If they are not available it would just pass None and should not crash, unless consuming code depends on those
# values being set, which is not the case by the time of writing
if not hasattr(django_settings, 'SOCIAL_AUTH_USER_FIELDS'):
django_settings.SOCIAL_AUTH_USER_FIELDS = getattr(
django_settings, 'USER_FIELDS', ['username', 'email', 'first_name', 'last_name', 'fullname']
)
......@@ -2,11 +2,15 @@
A custom Strategy for python-social-auth that allows us to fetch configuration from
ConfigurationModels rather than django.settings
"""
import logging
from .models import OAuth2ProviderConfig
from social.backends.oauth import BaseOAuth2
from social.strategies.django_strategy import DjangoStrategy
log = logging.getLogger(__name__)
class ConfigurationModelStrategy(DjangoStrategy):
"""
A DjangoStrategy customized to load settings from ConfigurationModels
......@@ -32,3 +36,44 @@ class ConfigurationModelStrategy(DjangoStrategy):
# At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row.
# It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend)
def _ensure_passes_length_check(self, user_data, key, fallback, min_length=2):
"""
Ensures that value we get from user_data is meets length requirements. IF it is shorter than required, fallback
is used
"""
assert len(fallback) >= min_length
value = user_data.get(key)
if value and len(value) >= min_length:
return value
return fallback
def create_user(self, *args, **kwargs):
"""
# Creates user using information provided by pipeline. This method is called in create_user pipeline step.
# Unless the workflow is changed, create_user immediately terminates if the user already found/
# So far, user is either created in ensure_user_information via registration form or account needs to be
# autoprovisioned. So, this method is only called when autoprovisioning account.
"""
from student.views import create_account_with_params
from .pipeline import make_random_password
user_fields = dict(kwargs)
# needs to be >2 chars to pass validation
name = self._ensure_passes_length_check(
user_fields, 'fullname', self.setting("THIRD_PARTY_AUTH_FALLBACK_FULL_NAME")
)
password = self._ensure_passes_length_check(user_fields, 'password', make_random_password())
user_fields['name'] = name
user_fields['password'] = password
user_fields['honor_code'] = True
user_fields['terms_of_service'] = True
if not user_fields.get('email'):
user_fields['email'] = "{username}@{domain}".format(
username=user_fields['username'], domain=self.setting("FAKE_EMAIL_DOMAIN")
)
# when autoprovisioning we need to skip email activation, hence skip_email is True
return create_account_with_params(self.request, user_fields, skip_email=True)
......@@ -28,6 +28,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
super(TestShibIntegrationTest, self).setUp()
self.login_page_url = reverse('signin_user')
self.register_page_url = reverse('register_user')
self.dashboard_page_url = reverse('dashboard')
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
......@@ -142,7 +143,56 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
# And we should be redirected to the dashboard:
self.assertEqual(continue_response.status_code, 302)
self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
self.assertEqual(continue_response['Location'], self.url_prefix + self.dashboard_page_url)
# Now check that we can login again:
self.client.logout()
self._test_return_login()
def test_autoprovision_from_login(self):
self._configure_testshib_provider(autoprovision_account=True)
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
# check that we don't have a user we're autoprovisioning account for
self._assert_user_does_not_exist('myself')
# The user goes to the register page, and sees a button to register with TestShib:
self._check_login_page()
self._test_autoprovision(TPA_TESTSHIB_LOGIN_URL)
def test_autoprovision_from_register(self):
self._configure_testshib_provider(autoprovision_account=True)
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
# check that we don't have a user we're autoprovisioning account for
self._assert_user_does_not_exist('myself')
# The user goes to the register page, and sees a button to register with TestShib:
self._check_register_page()
self._test_autoprovision(TPA_TESTSHIB_REGISTER_URL)
def _test_autoprovision(self, entry_point):
""" Actual autoprovision code """
# The user clicks on the TestShib button:
try_entry_response = self.client.get(entry_point)
# The user should be redirected to TestShib:
self.assertEqual(try_entry_response.status_code, 302)
self.assertTrue(try_entry_response['Location'].startswith(TESTSHIB_SSO_URL))
# Now the user will authenticate with the SAML provider
self._fake_testshib_login_and_return()
# Then there's one more redirect to set logged_in cookie
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
# We should be redirected to the dashboard screen since profile should be created and logged in
self.assertEqual(continue_response.status_code, 302)
self.assertEqual(continue_response['Location'], self.url_prefix + self.dashboard_page_url)
# assert account is created and activated
self._assert_account_created(username='myself', email='myself@testshib.org', full_name='Me Myself And I')
# Now check that we can login again:
self.client.logout()
......@@ -168,7 +218,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
# And then we should be redirected to the dashboard:
login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
self.assertEqual(login_response.status_code, 302)
self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard'))
self.assertEqual(login_response['Location'], self.url_prefix + self.dashboard_page_url)
# Now we are logged in:
dashboard_response = self.client.get(reverse('dashboard'))
self.assertEqual(dashboard_response.status_code, 200)
......@@ -205,6 +255,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL)
kwargs.setdefault('icon_class', 'fa-university')
kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
kwargs.setdefault('autoprovision_account', False)
self.configure_saml_provider(**kwargs)
if fetch_metadata:
......@@ -228,3 +279,16 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
user = User.objects.get(email=email)
user.is_active = True
user.save()
def _assert_user_does_not_exist(self, username):
""" Asserts that user with specified username does not exist """
with self.assertRaises(User.DoesNotExist):
User.objects.get(username=username)
def _assert_account_created(self, username, email, full_name):
""" Asserts that user with specified username exists, activated and have specified full name and email """
user = User.objects.get(username=username)
self.assertIsNotNone(user.profile)
self.assertEqual(user.email, email)
self.assertEqual(user.profile.name, full_name)
self.assertTrue(user.is_active)
"""Unit tests for third_party_auth/pipeline.py."""
import random
import mock
from student.views import AccountEmailAlreadyExistsValidationError
from third_party_auth import pipeline, provider
from third_party_auth.tests import testutil
import unittest
......@@ -43,3 +45,38 @@ class ProviderUserStateTestCase(testutil.TestCase):
google_provider = self.configure_google_provider(enabled=True)
state = pipeline.ProviderUserState(google_provider, object(), 1000)
self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name())
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class TestCreateUser(testutil.TestCase):
"""
Tests for custom create_user step
"""
def _raise_email_in_use_exception(self, *unused_args, **unused_kwargs):
""" Helper to raise AccountEmailAlreadyExistsValidationError """
raise AccountEmailAlreadyExistsValidationError(mock.Mock(), mock.Mock())
def test_create_user_normal_scenario(self):
""" Tests happy path - user is created and results are returned intact """
retval = mock.Mock()
with mock.patch("third_party_auth.pipeline.social_create_user") as patched_social_create_user:
patched_social_create_user.return_value = retval
strategy, details, user, idx = mock.Mock(), {'email': 'qwe@asd.com'}, mock.Mock(), 1
# pylint: disable=redundant-keyword-arg
result = pipeline.create_user(strategy, idx, details=details, user=user)
self.assertEqual(result, retval)
def test_create_user_exception_scenario(self):
"""
Tests sad path - expected exception is thrown, captured and transformed into AuthException subclass instance
"""
with mock.patch("third_party_auth.pipeline.social_create_user") as patched_social_create_user:
patched_social_create_user.side_effect = self._raise_email_in_use_exception
strategy, details, user = mock.Mock(), {'email': 'qwe@asd.com'}, mock.Mock()
with self.assertRaises(pipeline.EmailAlreadyInUseException):
# pylint: disable=redundant-keyword-arg
pipeline.create_user(strategy, 1, details=details, user=user)
""" unittests for strategy.py """
import unittest
import ddt
import mock
from unittest import TestCase
from third_party_auth.strategy import ConfigurationModelStrategy
from third_party_auth.tests import testutil
@ddt.ddt
@mock.patch('student.views.create_account_with_params')
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class TestStrategy(TestCase):
""" Unit tests for authentication strategy """
def setUp(self):
super(TestStrategy, self).setUp()
self.request_mock = mock.Mock()
self.strategy = ConfigurationModelStrategy(mock.Mock(), request=self.request_mock)
def _get_last_call_args(self, patched_create_account):
""" Helper to get last call arguments from a mock """
args, unused_kwargs = patched_create_account.call_args
return args
def test_create_user_sets_tos_and_honor_code(self, patched_create_account):
self.strategy.create_user(username='myself')
self.assertTrue(patched_create_account.called)
request, user_data = self._get_last_call_args(patched_create_account)
self.assertEqual(request, self.request_mock)
self.assertTrue(user_data['terms_of_service'])
self.assertTrue(user_data['honor_code'])
@ddt.data(
(None, 'Fallback Name', 'Fallback Name'),
('q', 'Other Name', 'Other Name'),
('q2', 'Other Name', 'q2'),
('qwe', 'Other Name', 'qwe'),
('user1', 'Fallback Name', 'user1')
)
@ddt.unpack
def test_create_user_sets_name(self, full_name, fallback_name, expected_name, patched_create_account):
with mock.patch.object(self.strategy, 'setting', mock.Mock()) as patched_setting:
patched_setting.return_value = fallback_name
self.strategy.create_user(username='myself', fullname=full_name)
# it is actually always called, but this assertion is relaxed to allow not actually going to settings
# if there's no point in that
if expected_name == fallback_name:
self.assertIn(mock.call("THIRD_PARTY_AUTH_FALLBACK_FULL_NAME"), patched_setting.mock_calls)
_, user_data = self._get_last_call_args(patched_create_account)
self.assertEqual(user_data['name'], expected_name)
def test_sets_password_if_missing(self, patched_create_account):
self.strategy.create_user(username='myself', fullname='myself')
_, user_data = self._get_last_call_args(patched_create_account)
self.assertIn('password', user_data)
@ddt.data(
(None, False),
('q', False),
('12', False),
('456', True),
('$up3r_$e(ur3_p/|$$w0rd', True),
)
@ddt.unpack
def test_passes_password_if_specified(self, password, should_match, patched_create_account):
self.strategy.create_user(username='myself', fullname='myself', password=password)
_, user_data = self._get_last_call_args(patched_create_account)
self.assertIn('password', user_data)
if should_match:
self.assertEqual(user_data['password'], password)
@ddt.data(
(None, 'fallback_domain.com', 'myself@fallback_domain.com'),
('', 'other_domain.com', 'myself@other_domain.com'),
('qwe@asd.com', 'fallback_domain.com', 'qwe@asd.com'),
('zxc@darpa.gov.mil.edu', 'fallback_domain.com', 'zxc@darpa.gov.mil.edu'),
)
@ddt.unpack
def test_sets_email_if_not_provided(self, email, fallback_domain, expected_email, patched_create_account):
with mock.patch.object(self.strategy, 'setting', mock.Mock()) as patched_setting:
patched_setting.return_value = fallback_domain
# fullname is needed to avoid calling setting twice
self.strategy.create_user(username='myself', fullname='myself', email=email)
# it is actually always called, but this assertion is relaxed to allow not actually going to settings
# if there's no point in that
if email != expected_email:
self.assertIn(mock.call("FAKE_EMAIL_DOMAIN"), patched_setting.mock_calls)
_, user_data = self._get_last_call_args(patched_create_account)
self.assertEqual(user_data['email'], expected_email)
......@@ -590,6 +590,14 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)),
}
# FAKE EMAIL DOMAIN setting is used to generate an email for an automatically provisioned account in case
# it is not provided by IdP (which should'nt normally be the case for providers with automatic provisioning)
FAKE_EMAIL_DOMAIN = ENV_TOKENS.get('FAKE_EMAIL_DOMAIN', 'fake-email-domain.foo')
# This setting is used as a user full name when automatically provisioning an account in case
# IdP provided name is empty, missing or does not pass minimal length check
THIRD_PARTY_AUTH_FALLBACK_FULL_NAME = ENV_TOKENS.get('THIRD_PARTY_AUTH_FALLBACK_FULL_NAME', "Unknown")
##### OAUTH2 Provider ##############
if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
......
......@@ -258,6 +258,9 @@ AUTHENTICATION_BACKENDS = (
'third_party_auth.saml.SAMLAuthBackend',
) + AUTHENTICATION_BACKENDS
FAKE_EMAIL_DOMAIN = 'fake-email-domain.foo'
THIRD_PARTY_AUTH_FALLBACK_FULL_NAME = "Unknown"
################################## OPENID #####################################
FEATURES['AUTH_USE_OPENID'] = True
FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
......
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