Commit 8ecfa867 by John Cox

Add common/djangoapps/third_party_auth; update pylintrc to allow test_* names.

third_party_auth contains a working settings mechanism, the start of the provider interface + 3 implementations (Google, Mozilla Persona, LinkedIn), and a stub for the auth pipeline. Modified existing lms settings files to use but deactivate the module.
parent bf754c86
"""Auth pipeline definitions."""
from social.pipeline import partial
@partial.partial
def step(*args, **kwargs):
"""Fake pipeline step; just throws loudly for now."""
raise NotImplementedError('%s, %s' % (args, kwargs))
"""Third-party auth provider definitions.
Loaded by Django's settings mechanism. Consequently, this module must not
invoke the Django armature.
"""
class BaseProvider(object):
"""Abstract base class for third-party auth providers.
All providers must subclass BaseProvider -- otherwise, they cannot be put
in the provider Registry.
"""
# String. Dot-delimited module.Class. The name of the backend
# implementation to load.
AUTHENTICATION_BACKEND = None
# String. User-facing name of the provider. Must be unique across all
# enabled providers.
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 merge_onto(cls, settings):
"""Merge class-level settings onto a django `settings` module."""
for key, value in cls.SETTINGS.iteritems():
setattr(settings, key, value)
class GoogleOauth2(BaseProvider):
"""Provider for Google's Oauth2 auth system."""
AUTHENTICATION_BACKEND = 'social.backends.google.GoogleOAuth2'
NAME = 'Google'
SETTINGS = {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None,
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None,
}
class LinkedInOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system."""
AUTHENTICATION_BACKEND = 'social.backends.linkedin.LinkedinOAuth2'
NAME = 'LinkedIn'
SETTINGS = {
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None,
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None,
}
class MozillaPersona(BaseProvider):
"""Provider for Mozilla's Persona auth system."""
AUTHENTICATION_BACKEND = 'social.backends.persona.PersonaAuth'
NAME = 'Mozilla Persona'
class Registry(object):
"""Singleton registry of third-party auth providers.
Providers must subclass BaseProvider in order to be usable in the registry.
"""
_CONFIGURED = False
_ENABLED = {}
@classmethod
def _check_configured(cls):
"""Ensures registry is configured."""
if not cls._CONFIGURED:
raise RuntimeError('Registry not configured')
@classmethod
def _get_all(cls):
"""Gets all provider implementations loaded into the Python runtime."""
# BaseProvider does so have __subclassess__. pylint: disable-msg=no-member
return {klass.NAME: klass for klass in BaseProvider.__subclasses__()}
@classmethod
def _enable(cls, provider):
"""Enables a single `provider`."""
if provider.NAME in cls._ENABLED:
raise ValueError('Provider %s already enabled' % provider.NAME)
cls._ENABLED[provider.NAME] = provider
@classmethod
def configure_once(cls, provider_names):
"""Configures providers.
Takes `provider_names`, a list of string.
"""
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
for name in provider_names:
all_providers = cls._get_all()
if name not in all_providers:
raise ValueError('No implementation found for provider ' + name)
cls._enable(all_providers.get(name))
@classmethod
def enabled(cls):
"""Returns list of enabled providers."""
cls._check_configured()
return sorted(cls._ENABLED.values(), key=lambda provider: provider.NAME)
@classmethod
def get(cls, provider_name):
"""Gets provider named `provider_name` string if enabled, else None."""
cls._check_configured()
return cls._ENABLED.get(provider_name)
@classmethod
def _reset(cls):
"""Returns the registry to an unconfigured state; for tests only."""
cls._CONFIGURED = False
cls._ENABLED = {}
"""Settings for the third-party auth module.
Defers configuration of settings so we can inspect the provider registry and
create settings placeholders for only those values actually needed by a given
deployment. Required by Django; consequently, this file must not invoke the
Django armature.
The flow for settings registration is:
The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating
whether this module is enabled. Ancillary settings files (aws.py, dev.py) put
options in THIRD_PARTY_SETTINGS. startup.py probes the ENABLE_THIRD_PARTY_AUTH.
If true, it:
a) loads this module.
b) calls apply_settings(), passing in settings.THIRD_PARTY_AUTH.
THIRD_PARTY AUTH is a dict of the form
'THIRD_PARTY_AUTH': {
'<PROVIDER_NAME>': {
'<PROVIDER_SETTING_NAME>': '<PROVIDER_SETTING_VALUE>',
[...]
},
[...]
}
If you are using a dev settings file, your settings dict starts at the
level of <PROVIDER_NAME> and is a map of provider name string to
settings dict. If you are using an auth.json file, it should contain a
THIRD_PARTY_AUTH entry as above.
c) apply_settings() builds a list of <PROVIDER_NAMES>. These are the
enabled third party auth providers for the deployment. These are enabled
in provider.Registry, the canonical list of enabled providers.
d) then, it sets global, provider-independent settings.
e) then, it sets provider-specific settings. For each enabled provider, we
read its SETTINGS member. These are merged onto the Django settings
object. In most cases these are stubs and the real values are set from
THIRD_PARTY_AUTH. All values that are set from this dict must first be
initialized from SETTINGS. This allows us to validate the dict and
ensure that the values match expected configuration options on the
provider.
f) finally, the (key, value) pairs from the dict file are merged onto the
django settings object.
"""
from . import provider
def _merge_auth_info(django_settings, auth_info):
"""Merge `auth_info` dict onto `django_settings` module."""
enabled_provider_names = []
to_merge = []
for provider_name, provider_dict in auth_info.items():
enabled_provider_names.append(provider_name)
# Merge iff all settings have been intialized.
for key in provider_dict:
if key not in dir(django_settings):
raise ValueError('Auth setting %s not initialized' % key)
to_merge.append(provider_dict)
for passed_validation in to_merge:
for key, value in passed_validation.iteritems():
setattr(django_settings, key, value)
def _set_global_settings(django_settings):
"""Set provider-independent settings."""
# 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 our customized auth pipeline. All auth backends must work with
# this pipeline.
django_settings.SOCIAL_AUTH_PIPELINE = (
'third_party_auth.pipeline.step',
)
def _set_provider_settings(django_settings, enabled_providers, auth_info):
"""Set 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) +
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_auth_info(django_settings, auth_info)
def apply_settings(auth_info, django_settings):
"""Apply 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()
_set_global_settings(django_settings)
_set_provider_settings(django_settings, enabled_providers, auth_info)
"""
Test configuration of providers.
"""
from third_party_auth import provider
from third_party_auth.tests import testutil
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
def test_calling_configure_once_twice_raises_value_error(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
with self.assertRaisesRegexp(ValueError, '^.*already configured$'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
def test_configure_once_adds_gettable_providers(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
def test_configuring_provider_with_no_implementation_raises_value_error(self):
with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'):
provider.Registry.configure_once(['no_implementation'])
def test_configuring_single_provider_twice_raises_value_error(self):
provider.Registry._enable(provider.GoogleOauth2)
with self.assertRaisesRegexp(ValueError, '^.*already enabled'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
def test_custom_provider_can_be_enabled(self):
name = 'CustomProvider'
with self.assertRaisesRegexp(ValueError, '^No implementation.*$'):
provider.Registry.configure_once([name])
class CustomProvider(provider.BaseProvider):
"""Custom class to ensure BaseProvider children outside provider can be enabled."""
NAME = name
provider.Registry._reset()
provider.Registry.configure_once([CustomProvider.NAME])
self.assertEqual([CustomProvider], provider.Registry.enabled())
def test_enabled_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.enabled()
def test_enabled_returns_list_of_enabled_providers_sorted_by_name(self):
all_providers = provider.Registry._get_all()
provider.Registry.configure_once(all_providers.keys())
self.assertEqual(
sorted(all_providers.values(), key=lambda provider: provider.NAME), provider.Registry.enabled())
def test_get_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.get('anything')
def test_get_returns_enabled_provider(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
def test_get_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([])
self.assertIsNone(provider.Registry.get(provider.MozillaPersona.NAME))
"""
Unit tests for settings code.
"""
from third_party_auth import provider
from third_party_auth import settings
from third_party_auth.tests import testutil
_ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',)
_ORIGINAL_INSTALLED_APPS = ('first_installed_app',)
_ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS = ('first_template_context_preprocessor',)
_SETTINGS_MAP = {
'AUTHENTICATION_BACKENDS': _ORIGINAL_AUTHENTICATION_BACKENDS,
'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS,
'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS,
}
class SettingsUnitTest(testutil.TestCase):
"""Unit tests for settings management code."""
# Suppress sprurious no-member warning on fakes.
# pylint: disable-msg=no-member
def setUp(self):
super(SettingsUnitTest, self).setUp()
self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP)
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)
def test_apply_settings_enables_no_providers_and_completes_when_app_info_empty(self):
settings.apply_settings({}, self.settings)
self.assertEqual([], provider.Registry.enabled())
def test_apply_settings_initializes_stubs_and_merges_settings_from_auth_info(self):
for key in provider.GoogleOauth2.SETTINGS:
self.assertFalse(hasattr(self.settings, key))
auth_info = {
provider.GoogleOauth2.NAME: {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key',
},
}
settings.apply_settings(auth_info, self.settings)
self.assertEqual('google_oauth2_key', self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
self.assertIsNone(self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET)
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)
self.assertEqual((
provider.GoogleOauth2.AUTHENTICATION_BACKEND, provider.MozillaPersona.AUTHENTICATION_BACKEND) +
_ORIGINAL_AUTHENTICATION_BACKENDS,
self.settings.AUTHENTICATION_BACKENDS)
def test_apply_settings_raises_value_error_if_provider_contains_uninitialized_setting(self):
bad_setting_name = 'bad_setting'
self.assertNotIn('bad_setting_name', provider.GoogleOauth2.SETTINGS)
auth_info = {
provider.GoogleOauth2.NAME: {
bad_setting_name: None,
},
}
with self.assertRaisesRegexp(ValueError, '^.*not initialized$'):
settings.apply_settings(auth_info, self.settings)
"""
Integration tests for settings code.
"""
import mock
import unittest
from django.conf import settings
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."""
@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))
@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())
"""
Utilities for writing third_party_auth tests.
Used by Django and non-Django tests; must not have Django deps.
"""
import unittest
from third_party_auth import provider
class FakeDjangoSettings(object):
"""A fake for Django settings."""
def __init__(self, mappings):
"""Initializes the fake from `mappings`, a dict."""
for key, value in mappings.iteritems():
setattr(self, key, value)
class TestCase(unittest.TestCase):
"""Base class for auth test cases."""
# Allow access to protected methods (or module-protected methods) under
# test.
# pylint: disable-msg=protected-access
def setUp(self):
super(TestCase, self).setUp()
provider.Registry._reset()
def tearDown(self):
provider.Registry._reset()
super(TestCase, self).tearDown()
"""Url configuration for the auth module."""
from django.conf.urls import include, patterns, url
urlpatterns = patterns(
'', url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
)
......@@ -385,3 +385,6 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD
##### X-Frame-Options response header settings #####
X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS)
##### Third-party auth options ################################################
THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH)
......@@ -234,6 +234,10 @@ FEATURES = {
# Turn on/off Microsites feature
'USE_MICROSITES': False,
# Turn on third-party auth. Disabled for now because full implementations are not yet available. Remember to syncdb
# if you enable this; we don't create tables by default.
'ENABLE_THIRD_PARTY_AUTH': False,
}
# Used for A/B testing
......@@ -1247,6 +1251,7 @@ LINKEDIN_API = {
'COMPANY_ID': '2746406',
}
##### ACCOUNT LOCKOUT DEFAULT PARAMETERS #####
MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = 5
MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60
......@@ -1465,3 +1470,7 @@ for app_name in OPTIONAL_APPS:
except ImportError:
continue
INSTALLED_APPS += (app_name,)
# Stub for third_party_auth options.
# See common/djangoapps/third_party_auth/settings.py for configuration details.
THIRD_PARTY_AUTH = {}
......@@ -26,6 +26,9 @@ def run():
if settings.FEATURES.get('USE_MICROSITES', False):
enable_microsites()
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH', False):
enable_third_party_auth()
def enable_theme():
"""
......@@ -99,3 +102,14 @@ def enable_microsites():
edxmako.startup.run()
settings.STATICFILES_DIRS.insert(0, microsites_root)
def enable_third_party_auth():
"""
Enable the use of third_party_auth, which allows users to sign in to edX
using other identity providers. For configuration details, see
common/djangoapps/third_party_auth/settings.py.
"""
from third_party_auth import settings as auth_settings
auth_settings.apply_settings(settings.THIRD_PARTY_AUTH, settings)
......@@ -488,6 +488,12 @@ if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
url(r'^auto_auth$', 'student.views.auto_auth'),
)
# Third-party auth.
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
urlpatterns += (
url(r'', include('third_party_auth.urls')),
)
urlpatterns = patterns(*urlpatterns)
if settings.DEBUG:
......
......@@ -153,10 +153,10 @@ const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Regular expression which should only match correct function names
function-rgx=[a-z_][a-z0-9_]{2,30}$
function-rgx=([a-z_][a-z0-9_]{2,30}|test_[a-z0-9_]+)$
# Regular expression which should only match correct method names
method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff)$
method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
# Regular expression which should only match correct instance attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
......
......@@ -60,6 +60,7 @@ pyparsing==1.5.6
python-memcached==1.48
python-openid==2.2.5
python-dateutil==2.1
python-social-auth==0.1.21
pytz==2012h
pysrt==0.4.7
PyYAML==3.10
......
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