Commit 98ee3a53 by Will Daly

Implement IP filtering in embargo middleware.

Add history table for course access rule changes.

Provide test utility for simulating restricted access.

Provide `redirect_if_blocked` method for integration with other
parts of the system (will be used for blocking enrollment).

Add info-level logging explaining when and why users are blocked.
parent 268280df
......@@ -10,12 +10,114 @@ import pygeoip
from django.core.cache import cache
from django.conf import settings
from embargo.models import CountryAccessRule, RestrictedCourse
log = logging.getLogger(__name__)
def get_user_country_from_profile(user):
def redirect_if_blocked(course_key, access_point='enrollment', **kwargs):
"""Redirect if the user does not have access to the course.
Arguments:
course_key (CourseKey): Location of the course the user is trying to access.
Keyword Arguments:
Same as `check_course_access` and `message_url_path`
"""
if settings.FEATURES.get('ENABLE_COUNTRY_ACCESS'):
is_blocked = not check_course_access(course_key, **kwargs)
if is_blocked:
return message_url_path(course_key, access_point)
def check_course_access(course_key, user=None, ip_address=None, url=None):
"""
Check is the user with this ip_address has access to the given course
Arguments:
course_key (CourseKey): Location of the course the user is trying to access.
Keyword Arguments:
user (User): The user making the request. Can be None, in which case
the user's profile country will not be checked.
ip_address (str): The IP address of the request.
url (str): The URL the user is trying to access. Used in
log messages.
Returns:
Boolean: True if the user has access to the course; False otherwise
"""
# First, check whether there are any restrictions on the course.
# If not, then we do not need to do any further checks
course_is_restricted = RestrictedCourse.is_restricted_course(course_key)
if not course_is_restricted:
return True
if ip_address is not None:
# Retrieve the country code from the IP address
# and check it against the allowed countries list for a course
user_country_from_ip = _country_code_from_ip(ip_address)
if not CountryAccessRule.check_country_access(course_key, user_country_from_ip):
log.info(
(
u"Blocking user %s from accessing course %s at %s "
u"because the user's IP address %s appears to be "
u"located in %s."
),
getattr(user, 'id', '<Not Authenticated>'),
course_key,
url,
ip_address,
user_country_from_ip
)
return False
if user is not None:
# Retrieve the country code from the user's profile
# and check it against the allowed countries list for a course.
user_country_from_profile = _get_user_country_from_profile(user)
if not CountryAccessRule.check_country_access(course_key, user_country_from_profile):
log.info(
(
u"Blocking user %s from accessing course %s at %s "
u"because the user's profile country is %s."
),
user.id, course_key, url, user_country_from_profile
)
return False
return True
def message_url_path(course_key, access_point):
"""Determine the URL path for the message explaining why the user was blocked.
This is configured per-course. See `RestrictedCourse` in the `embargo.models`
module for more details.
Arguments:
course_key (CourseKey): The location of the course.
access_point (str): How the user was trying to access the course.
Can be either "enrollment" or "courseware".
Returns:
unicode: The URL path to a page explaining why the user was blocked.
Raises:
InvalidAccessPoint: Raised if access_point is not a supported value.
"""
return RestrictedCourse.message_url_path(course_key, access_point)
def _get_user_country_from_profile(user):
"""
Check whether the user is embargoed based on the country code in the user's profile.
......@@ -55,40 +157,3 @@ def _country_code_from_ip(ip_addr):
return pygeoip.GeoIP(settings.GEOIPV6_PATH).country_code_by_addr(ip_addr)
else:
return pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
def check_course_access(user, ip_address, course_key):
"""
Check is the user with this ip_address has access to the given course
Params:
user (User): Currently logged in user object
ip_address (str): The ip_address of user
course_key (CourseLocator): CourseLocator object the user is trying to access
Returns:
The return will be True if the user has access on the course.
if any constraints fails it will return the False
"""
course_is_restricted = RestrictedCourse.is_restricted_course(course_key)
# If they're trying to access a course that cares about embargoes
# If course is not restricted then return immediately return True
# no need for further checking
if not course_is_restricted:
return True
# Retrieve the country code from the IP address
# and check it against the allowed countries list for a course
user_country_from_ip = _country_code_from_ip(ip_address)
# if user country has access to course return True
if not CountryAccessRule.check_country_access(course_key, user_country_from_ip):
return False
# Retrieve the country code from the user profile.
user_country_from_profile = get_user_country_from_profile(user)
# if profile country has access return True
if not CountryAccessRule.check_country_access(course_key, user_country_from_profile):
return False
return True
"""Exceptions for the embargo app."""
class InvalidAccessPoint(Exception):
"""The requested access point is not supported. """
def __init__(self, access_point, *args, **kwargs):
msg = (
u"Access point '{access_point}' should be either 'enrollment' or 'courseware'"
).format(access_point=access_point)
super(InvalidAccessPoint, self).__init__(msg, *args, **kwargs)
......@@ -32,11 +32,13 @@ EMBARGO_SITE_REDIRECT_URL = 'https://www.edx.org/'
"""
from functools import partial
import logging
import re
import pygeoip
from lazy import lazy
from django.core.exceptions import MiddlewareNotUsed
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.conf import settings
from django.shortcuts import redirect
from django.http import HttpResponseRedirect, HttpResponseForbidden
......@@ -45,7 +47,7 @@ from util.request import course_id_from_url
from student.models import unique_id_for_user
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.api import check_course_access
from embargo import api as embargo_api
log = logging.getLogger(__name__)
......@@ -58,6 +60,17 @@ class EmbargoMiddleware(object):
optionally ``IPFilter`` rows in the database, using the django admin site.
"""
ALLOW_URL_PATTERNS = [
# Don't block the embargo message pages; otherwise we'd
# end up in an infinite redirect loop.
re.compile(r'^/embargo/blocked-message/'),
# Don't block the Django admin pages. Otherwise, we might
# accidentally lock ourselves out of Django admin
# during testing.
re.compile(r'^/admin/'),
]
# Reasons a user might be blocked.
# These are used to generate info messages in the logs.
REASONS = {
......@@ -71,20 +84,81 @@ class EmbargoMiddleware(object):
def __init__(self):
self.site_enabled = settings.FEATURES.get('SITE_EMBARGOED', False)
self.enable_country_access = settings.FEATURES.get('ENABLE_COUNTRY_ACCESS', False)
# If embargoing is turned off, make this middleware do nothing
if not settings.FEATURES.get('EMBARGO', False) and not self.site_enabled:
disable_middleware = not (
settings.FEATURES.get('EMBARGO') or
self.site_enabled or
self.enable_country_access
)
if disable_middleware:
raise MiddlewareNotUsed()
self.enable_country_access = settings.FEATURES.get('ENABLE_COUNTRY_ACCESS', False)
def process_request(self, request):
"""Block requests based on embargo rules.
In the new ENABLE_COUNTRY_ACCESS implmentation,
this will perform the following checks:
1) If the user's IP address is blacklisted, block.
2) If the user's IP address is whitelisted, allow.
3) If the user's country (inferred from their IP address) is blocked for
a courseware page, block.
4) If the user's country (retrieved from the user's profile) is blocked
for a courseware page, block.
5) Allow access.
"""
Processes embargo requests.
"""
# If the feature flag is set, use the new "country access" implementation.
# This is a more flexible implementation of the embargo feature that allows
# per-course country access rules.
if self.enable_country_access:
if self.country_access_rules(request):
# Never block certain patterns by IP address
for pattern in self.ALLOW_URL_PATTERNS:
if pattern.match(request.path) is not None:
return None
ip_address = get_ip(request)
ip_filter = IPFilter.current()
if ip_filter.enabled and ip_address in ip_filter.blacklist_ips:
log.info(
(
u"User %s was blocked from accessing %s "
u"because IP address %s is blacklisted."
), request.user.id, request.path, ip_address
)
# If the IP is blacklisted, reject.
# This applies to any request, not just courseware URLs.
ip_blacklist_url = reverse(
'embargo_blocked_message',
kwargs={
'access_point': 'courseware',
'message_key': 'embargo'
}
)
return redirect(ip_blacklist_url)
elif ip_filter.enabled and ip_address in ip_filter.whitelist_ips:
log.info(
(
u"User %s was allowed access to %s because "
u"IP address %s is whitelisted."
),
request.user.id, request.path, ip_address
)
# If the IP is whitelisted, then allow access,
# skipping later checks.
return None
else:
return self._embargo_redirect_response
# Otherwise, perform the country access checks.
# This applies only to courseware URLs.
return self.country_access_rules(request.user, ip_address, request.path)
url = request.path
course_id = course_id_from_url(url)
......@@ -306,19 +380,30 @@ class EmbargoMiddleware(object):
return _inner
def country_access_rules(self, request):
def country_access_rules(self, user, ip_address, url_path):
"""
check the country access rules for a given course.
if course id is invalid return True
Check the country access rules for a given course.
Applies only to courseware URLs.
Args:
request
user (User): The user making the current request.
ip_address (str): The IP address from which the request originated.
url_path (str): The request path.
Return:
boolean: True if the user has access else false.
Returns:
HttpResponse or None
"""
url = request.path
course_id = course_id_from_url(url)
if course_id is None:
return True
return check_course_access(request.user, get_ip(request), course_id)
course_id = course_id_from_url(url_path)
if course_id:
redirect_url = embargo_api.redirect_if_blocked(
course_id,
user=user,
ip_address=ip_address,
url=url_path,
access_point='courseware'
)
if redirect_url:
return redirect(redirect_url)
# -*- 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 'CourseAccessRuleHistory'
db.create_table('embargo_courseaccessrulehistory', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('timestamp', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('snapshot', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
))
db.send_create_signal('embargo', ['CourseAccessRuleHistory'])
def backwards(self, orm):
# Deleting model 'CourseAccessRuleHistory'
db.delete_table('embargo_courseaccessrulehistory')
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.country': {
'Meta': {'ordering': "['country']", 'object_name': 'Country'},
'country': ('django_countries.fields.CountryField', [], {'unique': 'True', 'max_length': '2', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'embargo.countryaccessrule': {
'Meta': {'unique_together': "(('restricted_course', 'country'),)", 'object_name': 'CountryAccessRule'},
'country': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['embargo.Country']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'restricted_course': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['embargo.RestrictedCourse']"}),
'rule_type': ('django.db.models.fields.CharField', [], {'default': "'blacklist'", 'max_length': '255'})
},
'embargo.courseaccessrulehistory': {
'Meta': {'object_name': 'CourseAccessRuleHistory'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'snapshot': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'})
},
'embargo.embargoedcourse': {
'Meta': {'object_name': 'EmbargoedCourse'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
'embargoed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'embargo.embargoedstate': {
'Meta': {'object_name': 'EmbargoedState'},
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'embargo.ipfilter': {
'Meta': {'object_name': 'IPFilter'},
'blacklist': ('django.db.models.fields.TextField', [], {'blank': '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'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'embargo.restrictedcourse': {
'Meta': {'object_name': 'RestrictedCourse'},
'access_msg_key': ('django.db.models.fields.CharField', [], {'default': "'default'", 'max_length': '255'}),
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
'enroll_msg_key': ('django.db.models.fields.CharField', [], {'default': "'default'", 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
}
}
complete_apps = ['embargo']
\ No newline at end of file
"""Utilities for writing unit tests that involve course embargos. """
import contextlib
import mock
import pygeoip
from django.core.urlresolvers import reverse
from django.core.cache import cache
from embargo.models import Country, CountryAccessRule, RestrictedCourse
@contextlib.contextmanager
def restrict_course(course_key, access_point="enrollment"):
"""Simulate that a course is restricted.
This does two things:
1) Configures country access rules so that the course is restricted.
2) Mocks the GeoIP call so the user appears to be coming
from a country that's blocked from the course.
This is useful for tests that need to verify
that restricted users won't be able to access
particular views.
Arguments:
course_key (CourseKey): The location of the course to block.
Keyword Arguments:
access_point (str): Either "courseware" or "enrollment"
Yields:
str: A URL to the page in the embargo app that explains
why the user was blocked.
Example Usage:
>>> with restrict_course(course_key) as redirect_url:
>>> # The client will appear to be coming from
>>> # an IP address that is blocked.
>>> resp = self.client.get(url)
>>> self.assertRedirects(resp, redirect_url)
"""
# Clear the cache to ensure that previous tests don't interfere
# with this test.
cache.clear()
with mock.patch.object(pygeoip.GeoIP, 'country_code_by_addr') as mock_ip:
# Remove all existing rules for the course
CountryAccessRule.objects.all().delete()
# Create the country object
# Ordinarily, we'd create models for every country,
# but that would slow down the test suite.
country, __ = Country.objects.get_or_create(country='IR')
# Create a model for the restricted course
restricted_course, __ = RestrictedCourse.objects.get_or_create(course_key=course_key)
restricted_course.enroll_msg_key = 'default'
restricted_course.access_msg_key = 'default'
restricted_course.save()
# Ensure that there is a blacklist rule for the country
CountryAccessRule.objects.get_or_create(
restricted_course=restricted_course,
country=country,
rule_type='blacklist'
)
# Simulate that the user is coming from the blacklisted country
mock_ip.return_value = 'IR'
# Yield the redirect url so the tests don't need to know
# the embargo messaging URL structure.
redirect_url = reverse(
'embargo_blocked_message',
kwargs={
'access_point': access_point,
'message_key': 'default'
}
)
yield redirect_url
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