diff --git a/cms/envs/common.py b/cms/envs/common.py index 8b61901327..a0665400a9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -97,6 +97,9 @@ sys.path.append(PROJECT_ROOT / 'lib') sys.path.append(COMMON_ROOT / 'djangoapps') sys.path.append(COMMON_ROOT / 'lib') +# For geolocation ip database +GEOIP_PATH = REPO_ROOT / "common/static/data/geoip/GeoIP.dat" + ############################# WEB CONFIGURATION ############################# # This is where we stick our compiled template files. @@ -195,6 +198,8 @@ MIDDLEWARE_CLASSES = ( # for expiring inactive sessions 'session_inactivity_timeout.middleware.SessionInactivityTimeout', + + 'embargo.middleware.EmbargoMiddleware', ) ############# XBlock Configuration ########## @@ -465,6 +470,8 @@ INSTALLED_APPS = ( # User preferences 'user_api', 'django_openid_auth', + + 'embargo', ) diff --git a/cms/urls.py b/cms/urls.py index 8ddfc2e2f7..5ef9c414f4 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -51,6 +51,7 @@ urlpatterns += patterns( # ajax view that actually does the work url(r'^login_post$', 'student.views.login_user', name='login_post'), url(r'^logout$', 'student.views.logout_user', name='logout'), + url(r'^embargo$', 'student.views.embargo', name="embargo"), ) # restful api diff --git a/common/djangoapps/embargo/__init__.py b/common/djangoapps/embargo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/embargo/middleware.py b/common/djangoapps/embargo/middleware.py new file mode 100644 index 0000000000..c521de3c07 --- /dev/null +++ b/common/djangoapps/embargo/middleware.py @@ -0,0 +1,37 @@ +""" +Middleware for embargoing courses. +""" + + +from django.shortcuts import redirect +from util.request import course_id_from_url +from embargo.models import EmbargoConfig +from ipware.ip import get_ip +import pygeoip +from django.conf import settings + + +class EmbargoMiddleware(object): + """ + Middleware for embargoing courses + + This is configured by creating ``DarkLangConfig`` rows in the database, + using the django admin site. + """ + + def process_request(self, request): + """ + Processes embargo requests + """ + url = request.path + course_id = course_id_from_url(url) + + # If they're trying to access a course that cares about embargoes + if course_id in EmbargoConfig.current().embargoed_courses_list: + + # If we're having performance issues, add caching here + ip = get_ip(request) + country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip) + is_embargoed = (country_code_from_ip in EmbargoConfig.current().embargoed_countries_list) + if is_embargoed: + return redirect('embargo') diff --git a/common/djangoapps/embargo/migrations/0001_initial.py b/common/djangoapps/embargo/migrations/0001_initial.py new file mode 100644 index 0000000000..f5289c559d --- /dev/null +++ b/common/djangoapps/embargo/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'EmbargoConfig' + db.create_table('embargo_embargoconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('embargoed_countries', self.gf('django.db.models.fields.TextField')(blank=True)), + ('embargoed_courses', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('embargo', ['EmbargoConfig']) + + + def backwards(self, orm): + # Deleting model 'EmbargoConfig' + db.delete_table('embargo_embargoconfig') + + + 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'}) + }, + 'embargo.embargoconfig': { + 'Meta': {'object_name': 'EmbargoConfig'}, + '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'}), + 'embargoed_countries': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'embargoed_courses': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + } + } + + complete_apps = ['embargo'] \ No newline at end of file diff --git a/common/djangoapps/embargo/models.py b/common/djangoapps/embargo/models.py new file mode 100644 index 0000000000..3bbe5e4fb1 --- /dev/null +++ b/common/djangoapps/embargo/models.py @@ -0,0 +1,39 @@ +""" +Models for embargoing countries +""" +from django.db import models + +from config_models.models import ConfigurationModel + + +class EmbargoConfig(ConfigurationModel): + """ + Configuration for the embargo feature + """ + embargoed_countries = models.TextField( + blank=True, + help_text="A comma-separated list of country codes that fall under U.S. embargo restrictions" + ) + + embargoed_courses = models.TextField( + blank=True, + help_text="A comma-separated list of course IDs that we are enforcing the embargo for" + ) + + @property + def embargoed_countries_list(self): + """ + Returns list of embargoed countries + """ + if not self.embargoed_countries.strip(): + return [] + return [country.strip() for country in self.embargoed_countries.split(',')] + + @property + def embargoed_courses_list(self): + """ + Returns list of embargoed courses + """ + if not self.embargoed_courses.strip(): + return [] + return [course.strip() for course in self.embargoed_courses.split(',')] diff --git a/common/djangoapps/embargo/tests/tests.py b/common/djangoapps/embargo/tests/tests.py new file mode 100644 index 0000000000..a76d59ad94 --- /dev/null +++ b/common/djangoapps/embargo/tests/tests.py @@ -0,0 +1,71 @@ +""" +Tests for EmbargoMiddleware +""" + +from xmodule.modulestore.tests.factories import CourseFactory +from django.test import TestCase +from django.test.utils import override_settings +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from embargo.models import EmbargoConfig +from django.test import Client +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +import mock +import pygeoip + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class EmbargoMiddlewareTests(TestCase): + """ + Tests of EmbargoMiddleware + """ + def setUp(self): + self.client = Client() + self.user = UserFactory(username='fred', password='secret') + self.client.login(username='fred', password='secret') + self.embargo_course = CourseFactory.create() + self.embargo_course.save() + self.regular_course = CourseFactory.create(org="Regular") + self.regular_course.save() + self.embargoed_page = '/courses/' + self.embargo_course.id + '/info' + self.regular_page = '/courses/' + self.regular_course.id + '/info' + EmbargoConfig( + embargoed_countries="CU, IR, SY,SD", + embargoed_courses=self.embargo_course.id, + changed_by=self.user, + enabled=True + ).save() + + CourseEnrollment.enroll(self.user, self.regular_course.id) + CourseEnrollment.enroll(self.user, self.embargo_course.id) + + def test_countries(self): + def mock_country_code_by_addr(ip): + """ + Gives us a fake set of IPs + """ + ip_dict = { + '1.0.0.0': 'CU', + '2.0.0.0': 'IR', + '3.0.0.0': 'SY', + '4.0.0.0': 'SD', + } + return ip_dict.get(ip, 'US') + + with mock.patch.object(pygeoip.GeoIP, 'country_code_by_addr') as mocked_method: + mocked_method.side_effect = mock_country_code_by_addr + + # Accessing an embargoed page from a blocked IP should cause a redirect + response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0') + self.assertEqual(response.status_code, 302) + + # Accessing a regular course from a blocked IP should succeed + response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0') + self.assertEqual(response.status_code, 404) + + # Accessing any course from non-embaroged IPs should succeed + response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0') + self.assertEqual(response.status_code, 404) + + response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0') + self.assertEqual(response.status_code, 404) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 29d4ee06df..ebc6597a35 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -140,6 +140,15 @@ def _get_date_for_press(publish_date): return date +def embargo(request): + """ + Render the embargo page. + + Explains to the user why they are not able to access a particular embargoed course. + """ + return render_to_response('static_templates/embargo.html') + + def press(request): json_articles = cache.get("student_press_json_articles") if json_articles is None: @@ -718,7 +727,7 @@ def login_user(request, error=""): # This is actually the common case, logging in user without external linked login AUDIT_LOG.info("User %s w/o external auth attempting login", user) - # see if account has been locked out due to excessive login failres + # see if account has been locked out due to excessive login failures user_found_by_email_lookup = user if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): if LoginFailures.is_user_locked_out(user_found_by_email_lookup): diff --git a/common/djangoapps/util/request.py b/common/djangoapps/util/request.py index fc9c835194..fd2f876269 100644 --- a/common/djangoapps/util/request.py +++ b/common/djangoapps/util/request.py @@ -1,6 +1,7 @@ """ Utility functions related to HTTP requests """ from django.conf import settings from microsite_configuration.middleware import MicrositeConfiguration +from track.contexts import COURSE_REGEX def safe_get_host(request): @@ -16,3 +17,17 @@ def safe_get_host(request): return request.get_host() else: return MicrositeConfiguration.get_microsite_configuration_value('site_domain', settings.SITE_NAME) + + +def course_id_from_url(url): + """ + Extracts the course_id from the given `url`. + """ + url = url or '' + + match = COURSE_REGEX.match(url) + course_id = '' + if match: + course_id = match.group('course_id') or '' + + return course_id diff --git a/common/static/data/geoip/GeoIP.dat b/common/static/data/geoip/GeoIP.dat new file mode 100644 index 0000000000..5e38e99bdf Binary files /dev/null and b/common/static/data/geoip/GeoIP.dat differ diff --git a/common/static/data/geoip/README b/common/static/data/geoip/README new file mode 100644 index 0000000000..b32fd89783 --- /dev/null +++ b/common/static/data/geoip/README @@ -0,0 +1,2 @@ +This product includes GeoLite data created by MaxMind, available from +http://www.maxmind.com. \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 29929616e7..5c6b472fbc 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -256,6 +256,9 @@ node_paths = [ ] NODE_PATH = ':'.join(node_paths) +# For geolocation ip database +GEOIP_PATH = REPO_ROOT / "common/static/data/geoip/GeoIP.dat" + # Where to look for a status message STATUS_MESSAGE_PATH = ENV_ROOT / "status_message.json" @@ -724,6 +727,8 @@ MIDDLEWARE_CLASSES = ( # for expiring inactive sessions 'session_inactivity_timeout.middleware.SessionInactivityTimeout', + + 'embargo.middleware.EmbargoMiddleware', ) ############################### Pipeline ####################################### @@ -1138,6 +1143,8 @@ INSTALLED_APPS = ( # Student Identity Reverification 'reverification', + + 'embargo', ) ######################### MARKETING SITE ############################### diff --git a/lms/templates/static_templates/embargo.html b/lms/templates/static_templates/embargo.html new file mode 100644 index 0000000000..51622cb33b --- /dev/null +++ b/lms/templates/static_templates/embargo.html @@ -0,0 +1,8 @@ +<%! from django.utils.translation import ugettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("This Course Unavailable In Your Country")} + +
+

${_('Our system indicates that you are trying to access an edX course from an IP address associated with a country currently subjected to U.S. economic and trade sanctions. Unfortunately, at this time edX must comply with export controls, and we cannot allow you to register for this particular course. Feel free to browse our catalogue to find other courses you may be interested in taking.')}

+
diff --git a/lms/urls.py b/lms/urls.py index 634aec78c3..b7c749c69c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -11,7 +11,6 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): urlpatterns = ('', # nopep8 # certificate view - url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), @@ -66,6 +65,8 @@ urlpatterns = ('', # nopep8 url(r'^', include('waffle.urls')), url(r'^i18n/', include('django.conf.urls.i18n')), + + url(r'^embargo$', 'student.views.embargo', name="embargo"), ) # if settings.FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"): diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index c8cb2c4897..beb3a90a33 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -13,6 +13,10 @@ -e git+https://github.com/gabrielfalcao/lettuce.git@cccc3978ad2df82a78b6f9648fe2e9baddd22f88#egg=lettuce -e git+https://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk +# TODO clear this library with appropriate people +-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware +# TODO clear this library with appropriate people +-e git+https://github.com/appliedsec/pygeoip.git@95e69341cebf5a6a9fbf7c4f5439d458898bdc3b#egg=pygeoip # Our libraries: -e git+https://github.com/edx/XBlock.git@893cd83dfb24405ce81b07f49c1c2e3053cdc865#egg=XBlock