Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 229 additions & 39 deletions common/djangoapps/embargo/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Embargoing site (access restriction of the main site)

Embargo can restrict by states and whitelist/blacklist (IP Addresses
(ie. 10.0.0.0) or Networks (ie. 10.0.0.0/24)).
(ie. 10.0.0.0), Networks (ie. 10.0.0.0/24)), or the user profile country.

Usage:

Expand All @@ -30,16 +30,20 @@
# if EMBARGO_SITE_REDIRECT_URL is missing, a HttpResponseForbidden is returned.

"""
from functools import partial
import logging
import pygeoip
from lazy import lazy

from django.core.exceptions import MiddlewareNotUsed
from django.core.cache import cache
from django.conf import settings
from django.shortcuts import redirect
from django.http import HttpResponseRedirect, HttpResponseForbidden
from ipware.ip import get_ip
from util.request import course_id_from_url

from student.models import unique_id_for_user
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter

log = logging.getLogger(__name__)
Expand All @@ -52,58 +56,244 @@ class EmbargoMiddleware(object):
This is configured by creating ``EmbargoedCourse``, ``EmbargoedState``, and
optionally ``IPFilter`` rows in the database, using the django admin site.
"""

# Reasons a user might be blocked.
# These are used to generate info messages in the logs.
REASONS = {
"ip_blacklist": u"Restricting IP address {ip_addr} {from_course} because IP is blacklisted.",
"ip_country": u"Restricting IP address {ip_addr} {from_course} because IP is from country {ip_country}.",
"profile_country": (
u"Restricting user {user_id} {from_course} because "
u"the user set the profile country to {profile_country}."
)
}

def __init__(self):
self.site_enabled = settings.FEATURES.get('SITE_EMBARGOED', False)
# If embargoing is turned off, make this middleware do nothing
if not settings.FEATURES.get('EMBARGO', False) and \
not self.site_enabled:
if not settings.FEATURES.get('EMBARGO', False) and not self.site_enabled:
raise MiddlewareNotUsed()

def process_request(self, request):
"""
Processes embargo requests
Processes embargo requests.
"""
url = request.path
course_id = course_id_from_url(url)
course_is_embargoed = EmbargoedCourse.is_embargoed(course_id)

# If they're trying to access a course that cares about embargoes
if self.site_enabled or course_is_embargoed:
response = redirect('embargo')
# Set the proper response if site is enabled
if self.site_enabled:
redirect_url = getattr(settings, 'EMBARGO_SITE_REDIRECT_URL', None)
response = HttpResponseRedirect(redirect_url) if redirect_url \
else HttpResponseForbidden('Access Denied')

# If we're having performance issues, add caching here
ip_addr = get_ip(request)

# if blacklisted, immediately fail
if ip_addr in IPFilter.current().blacklist_ips:
if course_is_embargoed:
msg = "Embargo: Restricting IP address %s to course %s because IP is blacklisted." % \
(ip_addr, course_id)
else:
msg = "Embargo: Restricting IP address %s because IP is blacklisted." % ip_addr
log.info(msg)
return response
# ipv6 support
if ip_addr.find(':') >= 0:
country_code_from_ip = pygeoip.GeoIP(settings.GEOIPV6_PATH).country_code_by_addr(ip_addr)

# Construct the list of functions that check whether the user is embargoed.
# We wrap each of these functions in a decorator that logs the reason the user
# was blocked.
# Each function should return `True` iff the user is blocked by an embargo.
check_functions = [
self._log_embargo_reason(check_func, course_id, course_is_embargoed)
for check_func in [
partial(self._is_embargoed_by_ip, get_ip(request)),
partial(self._is_embargoed_by_profile_country, request.user)
]
]

# Perform each of the checks
# If the user fails any of the checks, immediately redirect them
# and skip later checks.
for check_func in check_functions:
if check_func():
return self._embargo_redirect_response

# If all the check functions pass, implicitly return None
# so that the middleware processor can continue processing
# the response.

def _is_embargoed_by_ip(self, ip_addr, course_id=u"", course_is_embargoed=False):
"""
Check whether the user is embargoed based on the IP address.

Args:
ip_addr (str): The IP address the request originated from.

Keyword Args:
course_id (unicode): The course the user is trying to access.
course_is_embargoed (boolean): Whether the course the user is accessing has been embargoed.

Returns:
A unicode message if the user is embargoed, otherwise `None`

"""
# If blacklisted, immediately fail
if ip_addr in IPFilter.current().blacklist_ips:
return self.REASONS['ip_blacklist'].format(
ip_addr=ip_addr,
from_course=self._from_course_msg(course_id, course_is_embargoed)
)

# If we're white-listed, then allow access
if ip_addr in IPFilter.current().whitelist_ips:
return None

# Retrieve the country code from the IP address
# and check it against the list of embargoed countries
ip_country = self._country_code_from_ip(ip_addr)
if ip_country in self._embargoed_countries:
return self.REASONS['ip_country'].format(
ip_addr=ip_addr,
ip_country=ip_country,
from_course=self._from_course_msg(course_id, course_is_embargoed)
)

# If none of the other checks caught anything,
# implicitly return None to indicate that the user can access the course

def _is_embargoed_by_profile_country(self, user, course_id="", course_is_embargoed=False):
"""
Check whether the user is embargoed based on the country code in the user's profile.

Args:
user (User): The user attempting to access courseware.

Keyword Args:
course_id (unicode): The course the user is trying to access.
course_is_embargoed (boolean): Whether the course the user is accessing has been embargoed.

Returns:
A unicode message if the user is embargoed, otherwise `None`

"""
cache_key = u'user.{user_id}.profile.country'.format(user_id=user.id)
profile_country = cache.get(cache_key)
if profile_country is None:
profile = getattr(user, 'profile', None)
if profile is not None:
profile_country = profile.country.code.upper()
else:
country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)

is_embargoed = country_code_from_ip in EmbargoedState.current().embargoed_countries_list
# Fail if country is embargoed and the ip address isn't explicitly
# whitelisted
if is_embargoed and ip_addr not in IPFilter.current().whitelist_ips:
if course_is_embargoed:
msg = "Embargo: Restricting IP address %s to course %s because IP is from country %s." % \
(ip_addr, course_id, country_code_from_ip)
else:
msg = "Embargo: Restricting IP address %s because IP is from country %s." % \
(ip_addr, country_code_from_ip)
profile_country = ""
cache.set(cache_key, profile_country)

if profile_country in self._embargoed_countries:
return self.REASONS['profile_country'].format(
user_id=unique_id_for_user(user),
profile_country=profile_country,
from_course=self._from_course_msg(course_id, course_is_embargoed)
)
else:
return None

def _country_code_from_ip(self, ip_addr):
"""
Return the country code associated with an IP address.
Handles both IPv4 and IPv6 addresses.

Args:
ip_addr (str): The IP address to look up.

Returns:
str: A 2-letter country code.

"""
if ip_addr.find(':') >= 0:
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)

@property
def _embargo_redirect_response(self):
"""
The HTTP response to send when the user is blocked from a course.
This will either be a redirect to a URL configured in Django settings
or a forbidden response.

Returns:
HTTPResponse

"""
response = redirect('embargo')

# Set the proper response if site is enabled
if self.site_enabled:
redirect_url = getattr(settings, 'EMBARGO_SITE_REDIRECT_URL', None)
response = (
HttpResponseRedirect(redirect_url)
if redirect_url
else HttpResponseForbidden('Access Denied')
)

return response

@lazy
def _embargoed_countries(self):
"""
Return the list of 2-letter country codes for embargoed countries.
The result is cached within the scope of the response.

Returns:
list

"""
return EmbargoedState.current().embargoed_countries_list

def _from_course_msg(self, course_id, course_is_embargoed):
"""
Format a message indicating whether the user was blocked from a specific course.
This can be used in info messages, but should not be used in user-facing messages.

Args:
course_id (unicode): The ID of the course being accessed.
course_is_embarged (boolean): Whether the course being accessed is embargoed.

Returns:
unicode

"""
return (
u"from course {course_id}".format(course_id=course_id)
if course_is_embargoed
else u""
)

def _log_embargo_reason(self, check_func, course_id, course_is_embargoed):
"""
Decorator for embargo check functions that will:
* execute the check function
* check whether the user is blocked by an embargo, and if so, log the reason
* return a boolean indicating whether the user was blocked.

Args:
check_func (partial): A function that should return unicode reason if the user
was blocked, otherwise should return None. This function will be passed
`course_id` and `course_is_embarged` kwargs so it can format a detailed
reason message.

course_id (unicode): The ID of the course the user is trying to access.

course_is_embargoed (boolean): Whether the course the user is trying
to access is under an embargo.

Returns:
boolean: True iff the user was blocked by an embargo

"""
def _inner():
# Perform the check and retrieve the reason string.
# The reason will be `None` if the user passes the check and can access the course.
# We pass in the course ID and whether the course is embargoed
# so that the check function can fill in the "reason" message with more specific details.
reason = check_func(
course_id=course_id,
course_is_embargoed=course_is_embargoed
)

# If the reason was `None`, indicate that the user was not blocked.
if reason is None:
return False

# Otherwise, log the reason the user was blocked
# and return True.
else:
msg = u"Embargo: {reason}".format(reason=reason)
log.info(msg)
return response
return True

return _inner
Loading