Commit ee9f632a by Jesse Shapiro

Allow per-SSO-provider session expiration limits

parent 07683f9c
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('third_party_auth', '0008_auto_20170413_1455'),
]
operations = [
migrations.AddField(
model_name='ltiproviderconfig',
name='max_session_length',
field=models.PositiveIntegerField(default=None, help_text='If this option is set, then users logging in using this SSO provider will have their session length limited to no longer than this value. If set to 0 (zero), the session will expire upon the user closing their browser. If left blank, the Django platform session default length will be used.', null=True, verbose_name=b'Max session length (seconds)', blank=True),
),
migrations.AddField(
model_name='oauth2providerconfig',
name='max_session_length',
field=models.PositiveIntegerField(default=None, help_text='If this option is set, then users logging in using this SSO provider will have their session length limited to no longer than this value. If set to 0 (zero), the session will expire upon the user closing their browser. If left blank, the Django platform session default length will be used.', null=True, verbose_name=b'Max session length (seconds)', blank=True),
),
migrations.AddField(
model_name='samlproviderconfig',
name='max_session_length',
field=models.PositiveIntegerField(default=None, help_text='If this option is set, then users logging in using this SSO provider will have their session length limited to no longer than this value. If set to 0 (zero), the session will expire upon the user closing their browser. If left blank, the Django platform session default length will be used.', null=True, verbose_name=b'Max session length (seconds)', blank=True),
),
]
...@@ -148,6 +148,18 @@ class ProviderConfig(ConfigurationModel): ...@@ -148,6 +148,18 @@ class ProviderConfig(ConfigurationModel):
"URL query parameter mapping to this provider is included in the request." "URL query parameter mapping to this provider is included in the request."
) )
) )
max_session_length = models.PositiveIntegerField(
null=True,
blank=True,
default=None,
verbose_name='Max session length (seconds)',
help_text=_(
"If this option is set, then users logging in using this SSO provider will have "
"their session length limited to no longer than this value. If set to 0 (zero), "
"the session will expire upon the user closing their browser. If left blank, the "
"Django platform session default length will be used."
)
)
prefix = None # used for provider_id. Set to a string value in subclass 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 backend_name = None # Set to a field or fixed value in subclass
accepts_logins = True # Whether to display a sign-in button when the provider is enabled accepts_logins = True # Whether to display a sign-in button when the provider is enabled
......
...@@ -32,7 +32,7 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method ...@@ -32,7 +32,7 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
try: try:
return self._config.get_setting(name) return self._config.get_setting(name)
except KeyError: except KeyError:
return self.strategy.setting(name, default) return self.strategy.setting(name, default, backend=self)
def auth_url(self): def auth_url(self):
""" """
......
...@@ -3,7 +3,8 @@ A custom Strategy for python-social-auth that allows us to fetch configuration f ...@@ -3,7 +3,8 @@ A custom Strategy for python-social-auth that allows us to fetch configuration f
ConfigurationModels rather than django.settings ConfigurationModels rather than django.settings
""" """
from .models import OAuth2ProviderConfig from .models import OAuth2ProviderConfig
from .pipeline import AUTH_ENTRY_CUSTOM from .pipeline import AUTH_ENTRY_CUSTOM, get as get_pipeline_from_request
from .provider import Registry
from social.backends.oauth import OAuthAuth from social.backends.oauth import OAuthAuth
from social.strategies.django_strategy import DjangoStrategy from social.strategies.django_strategy import DjangoStrategy
...@@ -41,6 +42,15 @@ class ConfigurationModelStrategy(DjangoStrategy): ...@@ -41,6 +42,15 @@ class ConfigurationModelStrategy(DjangoStrategy):
if error_url: if error_url:
return error_url return error_url
# Special case: we want to get this particular setting directly from the provider database
# entry if possible; if we don't have the information, fall back to the default behavior.
if name == 'MAX_SESSION_LENGTH':
running_pipeline = get_pipeline_from_request(self.request) if self.request else None
if running_pipeline is not None:
provider_config = Registry.get_from_pipeline(running_pipeline)
if provider_config:
return provider_config.max_session_length
# At this point, we know 'name' is not set in a [OAuth2|LTI|SAML]ProviderConfig row. # At this point, we know 'name' is not set in a [OAuth2|LTI|SAML]ProviderConfig row.
# It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION': # It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend) return super(ConfigurationModelStrategy, self).setting(name, default, backend)
...@@ -144,7 +144,7 @@ class IntegrationTestMixin(object): ...@@ -144,7 +144,7 @@ class IntegrationTestMixin(object):
""" """
raise NotImplementedError raise NotImplementedError
def _test_return_login(self, user_is_activated=True): def _test_return_login(self, user_is_activated=True, previous_session_timed_out=False):
""" Test logging in to an account that is already linked. """ """ Test logging in to an account that is already linked. """
# Make sure we're not logged in: # Make sure we're not logged in:
dashboard_response = self.client.get(reverse('dashboard')) dashboard_response = self.client.get(reverse('dashboard'))
...@@ -156,12 +156,14 @@ class IntegrationTestMixin(object): ...@@ -156,12 +156,14 @@ class IntegrationTestMixin(object):
# The user should be redirected to the provider: # The user should be redirected to the provider:
self.assertEqual(try_login_response.status_code, 302) self.assertEqual(try_login_response.status_code, 302)
login_response = self.do_provider_login(try_login_response['Location']) login_response = self.do_provider_login(try_login_response['Location'])
# There will be one weird redirect required to set the login cookie: # If the previous session was manually logged out, there will be one weird redirect
self.assertEqual(login_response.status_code, 302) # required to set the login cookie (it sticks around if the main session times out):
self.assertEqual(login_response['Location'], self.url_prefix + self.complete_url) if not previous_session_timed_out:
# And then we should be redirected to the dashboard: self.assertEqual(login_response.status_code, 302)
login_response = self.client.get(login_response['Location']) self.assertEqual(login_response['Location'], self.url_prefix + self.complete_url)
self.assertEqual(login_response.status_code, 302) # And then we should be redirected to the dashboard:
login_response = self.client.get(login_response['Location'])
self.assertEqual(login_response.status_code, 302)
if user_is_activated: if user_is_activated:
url_expected = reverse('dashboard') url_expected = reverse('dashboard')
else: else:
......
""" """
Third_party_auth integration tests using a mock version of the TestShib provider Third_party_auth integration tests using a mock version of the TestShib provider
""" """
import datetime
import ddt import ddt
import unittest import unittest
import httpretty import httpretty
import json import json
import time
from mock import patch from mock import patch
from freezegun import freeze_time
from social.apps.django_app.default.models import UserSocialAuth from social.apps.django_app.default.models import UserSocialAuth
from unittest import skip from unittest import skip
...@@ -90,6 +93,7 @@ class SamlIntegrationTestUtilities(object): ...@@ -90,6 +93,7 @@ class SamlIntegrationTestUtilities(object):
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL) kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL)
kwargs.setdefault('icon_class', 'fa-university') 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('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
kwargs.setdefault('max_session_length', None)
self.configure_saml_provider(**kwargs) self.configure_saml_provider(**kwargs)
if fetch_metadata: if fetch_metadata:
...@@ -207,6 +211,26 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin ...@@ -207,6 +211,26 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
self.assertEqual(num_failed, 0) self.assertEqual(num_failed, 0)
self.assertEqual(len(failure_messages), 0) self.assertEqual(len(failure_messages), 0)
def test_login_with_testshib_provider_short_session_length(self):
"""
Test that when we have a TPA provider which as an explicit maximum
session length set, waiting for longer than that between requests
results in us being logged out.
"""
# Configure the provider with a 10-second timeout
self._configure_testshib_provider(max_session_length=10)
now = datetime.datetime.utcnow()
with freeze_time(now):
# Test the login flow, adding the user in the process
super(TestShibIntegrationTest, self).test_login()
# Wait 30 seconds; longer than the manually-set 10-second timeout
later = now + datetime.timedelta(seconds=30)
with freeze_time(later):
# Test returning as a logged in user; this method verifies that we're logged out first.
self._test_return_login(previous_session_timed_out=True)
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') @unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin, testutil.SAMLTestCase): class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin, testutil.SAMLTestCase):
......
...@@ -66,6 +66,9 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase): ...@@ -66,6 +66,9 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase):
# Remove the icon_image from the POST data, to simulate unchanged icon_image # Remove the icon_image from the POST data, to simulate unchanged icon_image
post_data = models.model_to_dict(provider1) post_data = models.model_to_dict(provider1)
del post_data['icon_image'] del post_data['icon_image']
# Remove max_session_length; it has a default null value which must be POSTed
# back as an absent value, rather than as a "null-like" included value.
del post_data['max_session_length']
# Change the name, to verify POST # Change the name, to verify POST
post_data['name'] = 'Another name' post_data['name'] = 'Another name'
......
...@@ -90,7 +90,10 @@ python-memcached==1.48 ...@@ -90,7 +90,10 @@ python-memcached==1.48
django-memcached-hashring==0.1.2 django-memcached-hashring==0.1.2
python-openid==2.2.5 python-openid==2.2.5
python-dateutil==2.1 python-dateutil==2.1
python-social-auth==0.2.21 # We need to be able to set a maximum session length on our third-party auth providers;
# our goal is to upstream these changes and return to the canonical version ASAP.
# python-social-auth==0.2.21
git+https://github.com/edx/python-social-auth@758985102cee98f440fae44ed99617b7cfef3473#egg=python-social-auth==0.2.21.edx.a
pytz==2016.7 pytz==2016.7
pysrt==0.4.7 pysrt==0.4.7
PyYAML==3.10 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