Skip to content

Commit ed76498

Browse files
Add SSO auth to Wagtail and Django admins (#14649)
* Add mozilla-django-oidc to the project dependencies * Add SSO support to Bedrock for accessing Wagtail and Django admins * Plumbs in mozilla-django-oidc * Add custom login pages for Wagtail and Django admins that show an SSO button instead of form fields * Retain support for username + password login (for local development) * Tests * Add custom CSRF page to help explain SSO-related session loss, if it occurs Because a renewed/cycled OIDC/SSO session can zap a CSRF token and block a user from submitting a CMS edit, we need to provide a bit more information about what's happened. This changeset adds that, via a new template and a tiny view to serve it, plugged in as Django's default CSRF view Logged out users (who are very unlikely to see this anyway) get a simple version of the message, while logged in users get more detail/context. * Bump SSO lease time to 18 hours - trying to balance awkward signouts with wanting re-checks * Update test.env so that Wagtail and Django admins are available by default when urlconf is generated. Oddly the reload trick didn't work here * Update bedrock/base/templates/403_csrf.html Co-authored-by: Alex Gibson <[email protected]> * Make translation tagging consistent on new login templates * Move new CSRF view to use a CSS bundle, not inline CSS * Remove old, redundant CSRF view It looks like this was no longer in use. It wasn't specified as settings.CSRF_FAILURE_VIEW so wouldn't have been used/found by Django I believe * Drop translation markup from login templates to simplify * Don't count the test 404 and 500 views as nonlocaled, because we do localize them * Update bedrock/admin/templates/wagtailadmin/login.html * Tweak wording re SSO for login pages --------- Co-authored-by: Alex Gibson <[email protected]>
1 parent e4c24b8 commit ed76498

File tree

19 files changed

+567
-114
lines changed

19 files changed

+567
-114
lines changed

.env-dist

+9
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,12 @@ WAGTAIL_ENABLE_ADMIN=True
1717
# GS_PROJECT_ID="meao-stevejalim-dev-sandbox"
1818
# # export this before starting the django runserver:
1919
# # GOOGLE_APPLICATION_CREDENTIALS="./local-credentials/name-of-credentials-file.json"
20+
21+
# Change to True if you want to use SSO locally, else you'll use username+password auth
22+
USE_SSO_AUTH=False
23+
24+
# If USE_SSO_AUTH is True, you'll be using Mozilla OpenID Connect via Auth0
25+
# Get from IAM creentials from an appropriate person within the org to set here
26+
# in your .env
27+
OIDC_RP_CLIENT_ID=setme
28+
OIDC_RP_CLIENT_SECRET=setme
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{% extends "admin/login.html" %}
2+
{% comment %} SKIP LICENSE INSERTION {% endcomment %}
3+
{% comment %}
4+
This Source Code Form is subject to the terms of the Mozilla Public
5+
License, v. 2.0. If a copy of the MPL was not distributed with this
6+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
{% endcomment %}
8+
9+
{% load auth_tags %}
10+
11+
{% block content %}
12+
13+
{% should_use_sso_auth as sso_auth_enabled %}
14+
15+
{% if sso_auth_enabled %}
16+
17+
{% if form.errors and not form.non_field_errors %}
18+
<p class="errornote">
19+
Please correct the error{{form.errors.items|length|pluralize}} below.
20+
</p>
21+
{% endif %}
22+
23+
{% if form.non_field_errors %}
24+
{% for error in form.non_field_errors %}
25+
<p class="errornote">
26+
{{ error }}
27+
</p>
28+
{% endfor %}
29+
{% endif %}
30+
31+
<div id="content-main">
32+
33+
{% if user.is_authenticated %}
34+
<p class="errornote">
35+
You are authenticated as {{ username }}, but are not authorized to
36+
access this page. Would you like to login to a different account?
37+
</p>
38+
{% endif %}
39+
40+
<p class="module">
41+
<a class="button" href="{% url 'oidc_authentication_init' %}">
42+
Sign in with Mozilla SSO
43+
</a>
44+
</p>
45+
<p>
46+
{% url 'admin:index' as admin_url %}
47+
Note that after sign-in, you will be sent back to the CMS admin. Please re-access {{admin_url}} manually.
48+
</p>
49+
<p>
50+
<em>
51+
If you lack SSO access to this site, please ask your manager or in #www.
52+
</em>
53+
</p>
54+
55+
</div>
56+
57+
{% else %}
58+
59+
{{block.super}}
60+
61+
{% endif %}
62+
63+
{% endblock content %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{% extends "wagtailadmin/login.html" %}
2+
{% comment %} SKIP LICENSE INSERTION {% endcomment %}
3+
{% comment %}
4+
This Source Code Form is subject to the terms of the Mozilla Public
5+
License, v. 2.0. If a copy of the MPL was not distributed with this
6+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
{% endcomment %}
8+
9+
{% load auth_tags %}
10+
11+
{% block login_form %}
12+
{% should_use_sso_auth as sso_auth_enabled %}
13+
14+
{% if sso_auth_enabled %}
15+
<a href="{% url 'oidc_authentication_init' %}" class="button button-longrunning" data-clicked-text="Signing in...">
16+
<span class="icon icon-spinner"></span>
17+
<em>
18+
Sign in with Mozilla SSO
19+
</em>
20+
</a>
21+
<h3>If you lack SSO access to this site, please ask your manager or in #www</h3>
22+
23+
{% else %}
24+
{{block.super}}
25+
{% endif %}
26+
27+
{% endblock login_form %}
28+
29+
30+
{% block submit_buttons %}
31+
{% should_use_sso_auth as sso_auth_enabled %}
32+
33+
{% if sso_auth_enabled %}
34+
{# No need to show the button content if SSO is enabled#}
35+
{% else %}
36+
{{block.super}}
37+
{% endif %}
38+
39+
{% endblock submit_buttons %}

bedrock/base/templates/403_csrf.html

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{#
2+
This Source Code Form is subject to the terms of the Mozilla Public
3+
License, v. 2.0. If a copy of the MPL was not distributed with this
4+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
#}
6+
7+
<!DOCTYPE html>
8+
<html lang="en" dir="ltr">
9+
<head>
10+
<meta charset="utf-8" />
11+
<title>CSRF mismatch detected.</title>
12+
<meta name="viewport" content="width=device-width, initial-scale=1">
13+
{{ css_bundle('csrf-failure') }}
14+
</head>
15+
<body>
16+
<h1>Access denied</h1>
17+
<p>
18+
Cross-site request forgery (CSRF) mismatch detected.
19+
</p>
20+
{% if request.user.is_staff %}
21+
<p>
22+
This is most likely because your SSO session expired or had to be renewed.
23+
</p>
24+
<p>
25+
It's likely and regrettable that you have lost work since your last save.
26+
<br>
27+
If this is happening a lot, please <a href="https://github.com/mozilla/bedrock">open a bug report</a>.
28+
</p>
29+
{% endif %}
30+
<p>
31+
Please go back using your browser's back button and try again. Reloading this page will not fix the problem.
32+
</p>
33+
</body>
34+
</html>
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
from django.conf import settings
6+
from django.template import Library
7+
8+
register = Library()
9+
10+
11+
@register.simple_tag
12+
def should_use_sso_auth():
13+
return settings.USE_SSO_AUTH is True

bedrock/base/tests/test_auth_tags.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
from django.test import override_settings
6+
7+
import pytest
8+
9+
from bedrock.base.templatetags.auth_tags import should_use_sso_auth
10+
11+
12+
@pytest.mark.parametrize(
13+
"settings_val, expected",
14+
(
15+
(True, True),
16+
(False, False),
17+
(None, False),
18+
),
19+
)
20+
def test_should_use_simple_auth(settings_val, expected):
21+
with override_settings(USE_SSO_AUTH=settings_val):
22+
assert should_use_sso_auth() == expected

bedrock/base/tests/test_views.py

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import datetime
66
from unittest.mock import patch
77

8+
from django.conf import settings
89
from django.test import RequestFactory, TestCase
910
from django.utils.timezone import now as tz_now
1011

@@ -73,3 +74,8 @@ def test_get_contentful_sync_info(mock_timeago_format, mock_tz_now):
7374
# Also check the no-data context dict:
7475
ContentfulEntry.objects.all().delete()
7576
assert get_contentful_sync_info() == {}
77+
78+
79+
@pytest.mark.django_db
80+
def test_csrf_view_is_custom_one():
81+
assert settings.CSRF_FAILURE_VIEW == "bedrock.base.views.csrf_failure"

bedrock/base/views.py

+4
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,7 @@ def server_error_view(request, template_name="500.html"):
172172
def page_not_found_view(request, exception=None, template_name="404.html"):
173173
"""404 error handler that runs context processors."""
174174
return l10n_utils.render(request, template_name, ftl_files=["404", "500"], status=404)
175+
176+
177+
def csrf_failure(request, reason="CSRF failure", template_name="403_csrf.html"):
178+
return render(request, template_name, status=403)

bedrock/cms/tests/test_auth.py

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
from importlib import reload
6+
from unittest import mock
7+
8+
from django.contrib.auth.models import User
9+
from django.test import TestCase, override_settings
10+
from django.urls import reverse
11+
12+
13+
class LoginTestBase(TestCase):
14+
TEST_ADMIN_PASSWORD = "admin12345"
15+
16+
def setUp(self):
17+
self.wagtail_login_url = reverse("wagtailadmin_login")
18+
self.django_admin_login_url = reverse("admin:login")
19+
20+
def _create_admin(self):
21+
# create an admin user
22+
admin = User.objects.create_superuser(
23+
username="admin",
24+
25+
password=self.TEST_ADMIN_PASSWORD,
26+
)
27+
assert admin.is_active is True
28+
assert admin.has_usable_password() is True
29+
assert admin.check_password(self.TEST_ADMIN_PASSWORD) is True
30+
assert admin.is_staff is True
31+
assert admin.is_superuser is True
32+
33+
return admin
34+
35+
36+
class ConventionalLoginDeniedTest(LoginTestBase):
37+
"""Tests to show that the standard way to sign in to Wagtail and the Django
38+
Admin just do not work (which is good, because everyone should use SSO
39+
in production)"""
40+
41+
@override_settings(
42+
WAGTAIL_ENABLE_ADMIN=True,
43+
USE_SSO_AUTH=True,
44+
AUTHENTICATION_BACKENDS=("mozilla_django_oidc.auth.OIDCAuthenticationBackend",),
45+
)
46+
def test_login_page_contains_no_form(self):
47+
for url in (self.wagtail_login_url, self.django_admin_login_url):
48+
with self.subTest(url=url):
49+
response = self.client.get(url)
50+
assert response.status_code == 200
51+
# Check for the form field attrs that would normally be present
52+
self.assertNotContains(response, b'name="username"')
53+
self.assertNotContains(response, b'name="password"')
54+
# No CSRF token == no go, anyway
55+
self.assertNotContains(response, b"csrfmiddlewaretoken")
56+
# Confirm SSO link
57+
self.assertContains(response, b"Sign in with Mozilla SSO")
58+
59+
@override_settings(
60+
WAGTAIL_ENABLE_ADMIN=True,
61+
USE_SSO_AUTH=True,
62+
AUTHENTICATION_BACKENDS=("mozilla_django_oidc.auth.OIDCAuthenticationBackend",),
63+
)
64+
def test_posting_to_login_denied(self):
65+
admin = self._create_admin()
66+
67+
for url, error_message, expected_template in (
68+
(
69+
self.wagtail_login_url,
70+
b"Your username and password didn&#x27;t match.",
71+
"wagtailadmin/login.html",
72+
),
73+
(
74+
self.django_admin_login_url,
75+
b"Please enter the correct username and password for a staff account.",
76+
"admin/login.html",
77+
),
78+
):
79+
payload = {
80+
"username": admin.username,
81+
"password": self.TEST_ADMIN_PASSWORD,
82+
}
83+
with self.subTest(
84+
url=url,
85+
error_message=error_message,
86+
expected_template=expected_template,
87+
):
88+
response = self.client.post(url, data=payload, follow=True)
89+
self.assertEqual(
90+
response.status_code,
91+
200, # 200 is what comes back after the redirect
92+
)
93+
# Show that while we provided valid credentials, we still get
94+
# treated as if they are not the correct ones.
95+
self.assertContains(response, error_message)
96+
self.assertContains(response, b"Sign in with Mozilla SSO")
97+
self.assertTemplateUsed(response, expected_template)
98+
99+
100+
class AuthenticationBackendSelectionTests(TestCase):
101+
# We have to force the USE_SSO_AUTH to True at the environment level
102+
# then import the settings to trigger the appropriate if/else branch
103+
# that sets the right auth backend.
104+
105+
@mock.patch.dict("os.environ", {"USE_SSO_AUTH": "True"})
106+
def test_only_sso_backend_enabled_if_USE_SSO_AUTH_is_True(self):
107+
from bedrock.settings import base as base_settings
108+
109+
reloaded_settings = reload(base_settings)
110+
111+
self.assertEqual(
112+
reloaded_settings.AUTHENTICATION_BACKENDS,
113+
("mozilla_django_oidc.auth.OIDCAuthenticationBackend",),
114+
)
115+
116+
@mock.patch.dict("os.environ", {"USE_SSO_AUTH": "False"})
117+
def test_only_model_backend_enabled_if_USE_SSO_AUTH_is_False(self):
118+
from bedrock.settings import base as base_settings
119+
120+
reloaded_settings = reload(base_settings)
121+
122+
self.assertEqual(
123+
reloaded_settings.AUTHENTICATION_BACKENDS,
124+
("django.contrib.auth.backends.ModelBackend",),
125+
)
126+
127+
128+
class ConventionalLoginAllowedTest(LoginTestBase):
129+
"""If certain settings are set in settings.local, regular
130+
username + password sign-in functionality is restored
131+
"""
132+
133+
@override_settings(WAGTAIL_ENABLE_ADMIN=True, USE_SSO_AUTH=False)
134+
def test_login_page_contains_form(self):
135+
for url in (self.wagtail_login_url, self.django_admin_login_url):
136+
with self.subTest(url=url):
137+
response = self.client.get(url)
138+
assert response.status_code == 200
139+
# Check for the form field attrs that would normally be present
140+
self.assertContains(response, b'name="username"', 1)
141+
self.assertContains(response, b'name="password"', 1)
142+
self.assertContains(response, b"csrfmiddlewaretoken", 1)
143+
self.assertNotContains(response, b"Sign in with Mozilla SSO")
144+
145+
@override_settings(
146+
AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend",),
147+
WAGTAIL_ENABLE_ADMIN=True,
148+
USE_SSO_AUTH=False,
149+
)
150+
def test_posting_to_login_works_if_the_modelbackend_is_configured(self):
151+
# Only relevant to local usage, but good to confirm
152+
admin = self._create_admin()
153+
for url, expected_template in (
154+
(self.wagtail_login_url, "wagtailadmin/home.html"),
155+
(
156+
self.django_admin_login_url,
157+
"wagtailadmin/home.html",
158+
# That expected template is correct. Signing in to Django Admin
159+
# redirects to Wagtail's Admin, because that's what
160+
# LOGIN_REDIRECT_URL points to
161+
),
162+
):
163+
payload = {
164+
"username": admin.username,
165+
"password": self.TEST_ADMIN_PASSWORD,
166+
}
167+
with self.subTest(url=url, expected_template=expected_template):
168+
response = self.client.post(url, data=payload, follow=True)
169+
self.assertEqual(response.status_code, 200)
170+
self.assertNotContains(response, b"Sign in")
171+
self.assertTemplateUsed(response, expected_template)

0 commit comments

Comments
 (0)