Out of the box, Wagtail comes with privacy options for pages and files (anything stored in a Collection, e.g. documents and images). The options that come built in include:
- accessible to anyone (aka Public)
- accessible to people in specific groups
- accessible to anyone who can log into your site at all
- accessible if they have a password you enter on that form
That would seem like a fairly comprehensive list, but at work, we often restrict things to anyone coming from specific IP addresses. So when we rolled out our new CMS, we had requests for it to support the “on campus” option - just like the old ones had.
Adding the “On Campus” option comes in two parts: adding it to the options offered to the editor and then enforcing that restriction when serving pages or documents.
Adding a New Restriction Choice
The privacy options are defined in the BaseViewRestriction class in wagtail.core.models. We will be deciding if a browser is coming from on or off campus with a middleware, so we will not have to add any columns to the tables defined by classes inheriting from BaseViewRestriction. But we do need to add “on campus” to the options offered to the site editor.
To override the stock RESTRICTION_CHOICES
from wagtail.core.models.BaseViewRestriction
with our own, we need to monkeypatch the class. We have a number of small
customizations in our wagtail site, so we collect them all into their own app,
wagtail_patches, which we include in INSTALLED_APPS.
our_site
.... other apps ...
wagtail_patches
monkey_patches.py
wagtail_hook_patches.py
# In our settings.py
INSTALLED_APPS = [
# Our apps...
'www',
# The app containing our monkey patches
'wagtail_patches',
# Wagtail apps.
'wagtail.embeds',
'wagtail.sites',
'wagtail.users',
'wagtail.snippets',
'wagtail.documents',
'wagtail.images',
'wagtail.search',
'wagtail.admin',
'wagtail.core',
]
And now the monkeypatching code:
# In wagtail_patches/monkey_patches.py
RESTRICTION_CHOICES = (
(NONE, \_("Public")),
(LOGIN, \_("Private, accessible to logged-in users")),
(ON_CAMPUS, \_("Private, accessible to users on campus or on VPN")),
(PASSWORD, \_("Private, accessible with the following password")),
(GROUPS, \_("Private, accessible to users in specific groups")),
)
wagtail.core.models.BaseViewRestriction.ON_CAMPUS = 'on_campus'
wagtail.core.models.BaseViewRestriction.RESTRICTION_CHOICES = RESTRICTION_CHOICES
That will add the ON_CAMPUS choice to our form. Since there are no additional parameters needed for this restriction, you wouldn’t think we would have do make any additional changes to the form or form validations. But as of Django 3, we also need to patch the model level validations. We do that like this:
# In wagtail_patches/monkey_patches.py
def patched_PageViewRestriction_clean_fields(self, exclude=None):
"""
Clean all fields and raise a ValidationError containing a dict
of all validation errors if any occur.
"""
if exclude is None:
exclude = []
errors = {}
for f in self._meta.fields:
# BEGIN PATCH
if f.attname == 'restriction_type':
f.choices = RESTRICTION_CHOICES
# END PATCH
if f.name in exclude:
continue
# Skip validation for empty fields with blank=True. The developer
# is responsible for making sure they have a valid value.
raw_value = getattr(self, f.attname)
if f.blank and raw_value in f.empty_values:
continue
try:
setattr(self, f.attname, f.clean(raw_value, self))
except ValidationError as e:
errors[f.name] = e.error_list
if errors:
raise ValidationError(errors)
wagtail.core.models.PageViewRestriction.clean_fields = patched_PageViewRestriction_clean_fields
Enforcing Our New Restriction
In our setup, we have split enforcement into two parts, a middleware that determines if a request is “on campus” or not and then code that uses that information to show or not show the private page or file. We took this approach because we already have shared library that does the “on campus” checking. If you do not need to share the code that checks for on vs off campus, you may want to put that check directly into the code that enforces the rule.
On Campus Middleware
Define the following middleware somewhere in your project - customizing it with your own IP addresses.
class OnCampusMiddleware(MiddlewareMixin):
"""
Middleware sets ON_CAMPUS session variable to True if the request
came from an campus IP or if the user is authenticated.
"""
CAMPUS_ADDRESSES = [
r'192\.168\.\d{1,3}\.\d{1,3}',
]
def check_ip(self, request):
client_ip = get_client_ip(request)
if client_ip:
for ip_regex in self.CAMPUS_ADDRESSES:
if re.match(ip_regex, client_ip):
return True
return False
def process_request(self, request):
# A user is considered "on campus" if they are visiting from a campus IP, or are logged in to the site.
request.session['ON_CAMPUS'] = request.user.is_authenticated or self.check_ip(request)
return None
Then add this to the MIDDLEWARE list in your Django settings file. Since this middleware is a silent pass through in both directions (only the side effect of setting the ON_CAMPUS session variable to True or False matters), you can put this line anywhere in the list.
Updating the Enforcement Code
The meat of the restriction enforcement is in BaseViewRestriction’s accept_request method, so we need to add our new on-campus check:
def patched_accept_request(self, request):
if self.restriction_type == BaseViewRestriction.PASSWORD:
passed_restrictions = request.session.get(self.passed_view_restrictions_session_key, [])
if self.id not in passed_restrictions:
return False
elif self.restriction_type == BaseViewRestriction.LOGIN:
if not request.user.is_authenticated:
return False
# BEGIN PATCH
# Add a privacy mode that allows only on-campus visitors.
elif self.restriction_type == wagtail.core.models.BaseViewRestriction.ON_CAMPUS:
if not request.session['ON_CAMPUS']:
return False
# END PATCH
elif self.restriction_type == BaseViewRestriction.GROUPS:
if not request.user.is_superuser:
current_user_groups = request.user.groups.all()
if not any(group in current_user_groups for group in self.groups.all()):
return False
return True
wagtail.core.models.BaseViewRestriction.accept_request = patched_accept_request
What should happen when accept_request returns False? That depends on which
restriction triggers the failure. For example, if a user fails the LOGIN restriction,
they should be directed to log in - but if they fail the ON_CAMPUS restriction,
they should get an error message. The correct actions for the built-in
restriction types are handled in a before_serve_page
hook called
check_view_restrictions
Since we already have monkey patched some other hooks, what we did was
to monkey patch check_view_restrictions:
# In wagtail_patches/wagtail_hook_patches.py
from django.urls import reverse
from wagtail.core.hooks import _hooks, get_hooks
from wagtail.core.models import PageViewRestriction
from wagtail.core.wagtail_hooks import require_wagtail_login
def patched_check_view_restrictions(page, request, serve_args, serve_kwargs):
"""
Check whether there are any view restrictions on this page which are
not fulfilled by the given request object. If there are, return an
HttpResponse that will notify the user of that restriction (and possibly
include a password / login form that will allow them to proceed). If
there are no such restrictions, return None
"""
for restriction in page.get_view_restrictions():
if not restriction.accept_request(request):
if restriction.restriction_type == PageViewRestriction.PASSWORD:
from wagtail.core.forms import PasswordViewRestrictionForm
form = PasswordViewRestrictionForm(instance=restriction,
initial={'return_url': request.get_full_path()})
action_url = reverse('wagtailcore_authenticate_with_password', args=[restriction.id, page.id])
return page.serve_password_required_response(request, form, action_url)
elif restriction.restriction_type in [PageViewRestriction.LOGIN, PageViewRestriction.GROUPS]:
return require_wagtail_login(next_url=request.get_full_path())
# Begin patch: Added a code path for the on_campus restriction.
elif restriction.restriction_type == PageViewRestriction.ON_CAMPUS:
# We set request.is_preview like Page.serve() would have done, since this code bypasses it.
request.is_preview = getattr(request, 'is_preview', False)
# Render the on_campus_only.html template, instead of the usual page template.
return TemplateResponse(request, 'core/on_campus_only.html', page.get_context(request))
# end patch
def patch_hooks():
"""
This function replaces various wagtail hook implementations with our own versions.
"""
for ndx, _ in enumerate(get_hooks('before_serve_page')):
func = _hooks['before_serve_page'][ndx][0]
if func.__module__ == 'wagtail.core.wagtail_hooks':
_hooks['before_serve_page'][ndx] = (patched_check_view_restrictions, 0)
But looking at the source code for the page serve view, I don’t think
we need to replace the existing check_view_restrictions. I think we
can just add an additional before_serve_page
hook that returns our
“sorry you need to be on campus to see this page” message. If I were
doing this from scratch, I would put the following code into one of my
wagtail_hooks.py files (either in the wagtail_patches app or in that
app that contains most of my page models).
# In wagtail_patches/wagtail_hooks.py
from wagtail.core import hooks
@hooks.register('before_serve_page')
def enforce_on_campus_restriction(page, request, serve_args, serve_kwargs):
if not restriction.accept_request(request):
for restriction in page.get_view_restrictions():
if restriction.restriction_type == PageViewRestriction.ON_CAMPUS:
# We set request.is_preview like Page.serve() would have done, since this code bypasses it.
request.is_preview = getattr(request, 'is_preview', False)
# Render the on_campus_only.html templates
return TemplateResponse(request, 'core/on_campus_only.html', page.get_context(request))