Commit 0248f8af by Diana Huang Committed by Adam Palay

Add new clickjacking decorator that whitelists LTI consumers.

parent c76e2492
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Public views Public views
""" """
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.clickjacking import xframe_options_deny
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.shortcuts import redirect from django.shortcuts import redirect
...@@ -17,6 +18,7 @@ __all__ = ['signup', 'login_page', 'howitworks'] ...@@ -17,6 +18,7 @@ __all__ = ['signup', 'login_page', 'howitworks']
@ensure_csrf_cookie @ensure_csrf_cookie
@xframe_options_deny
def signup(request): def signup(request):
""" """
Display the signup form. Display the signup form.
...@@ -34,6 +36,7 @@ def signup(request): ...@@ -34,6 +36,7 @@ def signup(request):
@ssl_login_shortcut @ssl_login_shortcut
@ensure_csrf_cookie @ensure_csrf_cookie
@xframe_options_deny
def login_page(request): def login_page(request):
""" """
Display the login form. Display the login form.
......
"""
Decorators that can be used to interact with third_party_auth.
"""
from functools import wraps
from urlparse import urlparse
from django.conf import settings
from django.utils.decorators import available_attrs
from third_party_auth.models import LTIProviderConfig
def xframe_allow_whitelisted(view_func):
"""
Modifies a view function so that its response has the X-Frame-Options HTTP header
set to 'DENY' if the request HTTP referrer is not from a whitelisted hostname.
"""
def wrapped_view(request, *args, **kwargs):
""" Modify the response with the correct X-Frame-Options. """
resp = view_func(request, *args, **kwargs)
x_frame_option = 'DENY'
if settings.FEATURES['ENABLE_THIRD_PARTY_AUTH']:
referer = request.META.get('HTTP_REFERER')
if referer is not None:
parsed_url = urlparse(referer)
hostname = parsed_url.hostname
if LTIProviderConfig.objects.current_set().filter(lti_hostname=hostname, enabled=True).exists():
x_frame_option = 'ALLOW'
resp['X-Frame-Options'] = x_frame_option
return resp
return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'LTIProviderConfig.lti_hostname'
db.add_column('third_party_auth_ltiproviderconfig', 'lti_hostname',
self.gf('django.db.models.fields.CharField')(default='localhost', max_length=255, db_index=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'LTIProviderConfig.lti_hostname'
db.delete_column('third_party_auth_ltiproviderconfig', 'lti_hostname')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'third_party_auth.ltiproviderconfig': {
'Meta': {'object_name': 'LTIProviderConfig'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lti_consumer_key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'lti_consumer_secret': ('django.db.models.fields.CharField', [], {'default': "'ae8c9adcb7764ad67272c57c602dabd0b71acf22'", 'max_length': '255', 'blank': 'True'}),
'lti_hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'lti_max_timestamp_age': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'third_party_auth.oauth2providerconfig': {
'Meta': {'object_name': 'OAuth2ProviderConfig'},
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'third_party_auth.samlconfiguration': {
'Meta': {'object_name': 'SAMLConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
'private_key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'public_key': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'third_party_auth.samlproviderconfig': {
'Meta': {'object_name': 'SAMLProviderConfig'},
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'third_party_auth.samlproviderdata': {
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'public_key': ('django.db.models.fields.TextField', [], {}),
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
}
}
complete_apps = ['third_party_auth']
\ No newline at end of file
...@@ -493,6 +493,15 @@ class LTIProviderConfig(ProviderConfig): ...@@ -493,6 +493,15 @@ class LTIProviderConfig(ProviderConfig):
'The name that the LTI Tool Consumer will use to identify itself' 'The name that the LTI Tool Consumer will use to identify itself'
) )
) )
lti_hostname = models.CharField(
max_length=255,
help_text=(
'The domain that will be acting as the LTI consumer.'
),
db_index=True
)
lti_consumer_secret = models.CharField( lti_consumer_secret = models.CharField(
default=long_token, default=long_token,
max_length=255, max_length=255,
......
"""
Tests for third_party_auth decorators.
"""
import ddt
from django.http import HttpResponse
from django.test import RequestFactory
from third_party_auth.decorators import xframe_allow_whitelisted
from third_party_auth.tests.testutil import TestCase
@xframe_allow_whitelisted
def mock_view(_request):
""" A test view for testing purposes. """
return HttpResponse()
@ddt.ddt
class TestXFrameWhitelistDecorator(TestCase):
""" Test the xframe_allow_whitelisted decorator. """
def setUp(self):
super(TestXFrameWhitelistDecorator, self).setUp()
self.configure_lti_provider(name='Test', lti_hostname='localhost', lti_consumer_key='test_key', enabled=True)
self.factory = RequestFactory()
def construct_request(self, referer):
""" Add the given referer to a request and then return it. """
request = self.factory.get('/login')
request.META['HTTP_REFERER'] = referer
return request
@ddt.unpack
@ddt.data(
('http://localhost:8000/login', 'ALLOW'),
('http://not-a-real-domain.com/login', 'DENY'),
(None, 'DENY')
)
def test_x_frame_options(self, url, expected_result):
request = self.construct_request(url)
response = mock_view(request)
self.assertEqual(response['X-Frame-Options'], expected_result)
@ddt.data('http://localhost/login', 'http://not-a-real-domain.com', None)
def test_feature_flag_off(self, url):
with self.settings(FEATURES={'ENABLE_THIRD_PARTY_AUTH': False}):
request = self.construct_request(url)
response = mock_view(request)
self.assertEqual(response['X-Frame-Options'], 'DENY')
...@@ -354,6 +354,24 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -354,6 +354,24 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
self.assertContains(resp, "Register for Test Microsite") self.assertContains(resp, "Register for Test Microsite")
self.assertContains(resp, "register-form") self.assertContains(resp, "register-form")
def test_login_registration_xframe_protected(self):
resp = self.client.get(
reverse("register_user"),
{},
HTTP_REFERER="http://localhost/iframe"
)
self.assertEqual(resp['X-Frame-Options'], 'DENY')
self.configure_lti_provider(name='Test', lti_hostname='localhost', lti_consumer_key='test_key', enabled=True)
resp = self.client.get(
reverse("register_user"),
HTTP_REFERER="http://localhost/iframe"
)
self.assertEqual(resp['X-Frame-Options'], 'ALLOW')
def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers): def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers):
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """ """Verify that third party auth info is rendered correctly in a DOM data attribute. """
finish_auth_url = None finish_auth_url = None
......
...@@ -34,6 +34,7 @@ from student.views import ( ...@@ -34,6 +34,7 @@ from student.views import (
from student.helpers import get_next_url_for_login_page from student.helpers import get_next_url_for_login_page
import third_party_auth import third_party_auth
from third_party_auth import pipeline from third_party_auth import pipeline
from third_party_auth.decorators import xframe_allow_whitelisted
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.accounts.api import request_password_change
...@@ -45,6 +46,7 @@ AUDIT_LOG = logging.getLogger("audit") ...@@ -45,6 +46,7 @@ AUDIT_LOG = logging.getLogger("audit")
@require_http_methods(['GET']) @require_http_methods(['GET'])
@ensure_csrf_cookie @ensure_csrf_cookie
@xframe_allow_whitelisted
def login_and_registration_form(request, initial_mode="login"): def login_and_registration_form(request, initial_mode="login"):
"""Render the combined login/registration form, defaulting to login """Render the combined login/registration form, defaulting to login
......
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