diff --git a/docs/integrations.md b/docs/integrations.md index 0a4f29760..5413e535c 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -49,3 +49,7 @@ You an use the endpoint below to confirm the ID works. ```python mailchimp.lists.list_interest_category_interests(settings.MAILCHIMP_LIST_ID, MAILCHIMP_PROGRAMMES_INTEREST_CATEGORY_ID) ``` + +## Azure AD SSO + +To add SSO functionality, we used `social-auth-app-django`. All of the configurations and necessary keys can be seen in the `Azure AD (SSO)` and `Social Auth (SSO)` sections in the base settings. Logging in via SSO is optional. Users can still login via the standard Wagtail login form. diff --git a/docs/support-runbook.md b/docs/support-runbook.md index 7d6943819..76907aeb5 100644 --- a/docs/support-runbook.md +++ b/docs/support-runbook.md @@ -22,6 +22,12 @@ See also our [incident process](https://intranet.torchbox.com/propositions/desig - `buckup-rca-staging` - `buckup-rca-development` +## SSO + +SSO has been added using `social-auth-app-django`. This handles logging in and creating of the necessary user -- see `SOCIAL_AUTH_PIPELINE` in `rca.settings.base`. + +The Azure environment is handled by RCA. If we need to update keys or redirect URLs, we'll need to contact RCA. + ## Enquire to study form throwing 500s: ### 1. Check the data diff --git a/docs/upgrading.md b/docs/upgrading.md index 7b41076c3..47ac74e90 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -104,6 +104,15 @@ As well as testing the critical paths, these areas of functionality should be ch 1. The site has custom code to show/hide fields and panels depending on if a StudentPage is viewed by an admin (superuser) or a Student (none superuser). 2. The site has custom code to show/hide sidebar elements depending on if a Student is viewing the editor vs an admin (superuser). +### Users who logged in via SSO automatically gets 'Editor' permissions. + +1. When users log in via SSO, they are automatically made editors. This is done via `rca.utils.pipeline.make_sso_users_editors` and added to the `SOCIAL_AUTH_PIPELINE`. RCA will manually elevate permissiosn if necessary. + +### Users who logged in via SSO is redirected to a logout confirmation when they logout. + +1. The site overrides the `/admin/logout/` endpoint to redirect users who logged in to `/logout/`. This is a confirmation screen that users will still need to manually log out of their SSO accounts. This is done with `rca.account_management.views.CustomLogoutView` and `rca.account_management.views.SSOLogoutConfirmationView`. +2. Users who did not log in via SSO should be able to log out without seeing any confirmation screen. + --- ## Overridden core Wagtail templates @@ -113,6 +122,7 @@ The following templates are overridden and should be checked for changes when up Last checked against Wagtail version: 6.1 - `rca/account_management/templates/wagtailadmin/base.html` +- `rca/project_styleguide/templates/patterns/pages/auth/login.html` - This was overridden to add the "Sign in with single sign-on" button to the login template. - ~~`rca/users/templates/wagtailusers/users/list.html`~~ This template was deleted in 2645c204425b8fa3409a110f46b2822a1953fe49 because as of Wagtail 6.1, [it's no longer used](https://github.com/wagtail/wagtail/commit/7b1644eb37b6b6cf7800276acf9abef5254fc096). Please note that the `user_listing_buttons` template tag was used in this template, and it has since been [deprecated](https://docs.wagtail.org/en/latest/releases/6.1.html#deprecation-of-user-listing-buttons-template-tag). !!! warning "Technical Debt - to be addressed in Wagtail 6.2" diff --git a/poetry.lock b/poetry.lock index bb5bac2b5..ea64c3385 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2073,6 +2073,23 @@ files = [ plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pymdown-extensions" version = "10.8.1" @@ -2148,6 +2165,24 @@ requests = ">=2.31.0" requests-oauthlib = ">=0.7.0" six = ">=1.12.0" +[[package]] +name = "python3-openid" +version = "3.2.0" +description = "OpenID support for modern servers and consumers." +optional = false +python-versions = "*" +files = [ + {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, + {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, +] + +[package.dependencies] +defusedxml = "*" + +[package.extras] +mysql = ["mysql-connector-python"] +postgresql = ["psycopg2"] + [[package]] name = "pytz" version = "2022.6" @@ -2577,6 +2612,47 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "social-auth-app-django" +version = "5.4.2" +description = "Python Social Authentication, Django integration." +optional = false +python-versions = ">=3.8" +files = [ + {file = "social-auth-app-django-5.4.2.tar.gz", hash = "sha256:c8832c6cf13da6ad76f5613bcda2647d89ae7cfbc5217fadd13477a3406feaa8"}, + {file = "social_auth_app_django-5.4.2-py3-none-any.whl", hash = "sha256:0c041a31707921aef9a930f143183c65d8c7b364381364a50f3f7c6fcc9d62f6"}, +] + +[package.dependencies] +Django = ">=3.2" +social-auth-core = ">=4.4.1" + +[[package]] +name = "social-auth-core" +version = "4.5.4" +description = "Python social authentication made simple." +optional = false +python-versions = ">=3.8" +files = [ + {file = "social-auth-core-4.5.4.tar.gz", hash = "sha256:d3dbeb0999ffd0e68aa4bd73f2ac698a18133fd11b3fc890e1366f18c8889fac"}, + {file = "social_auth_core-4.5.4-py3-none-any.whl", hash = "sha256:33cf970a623c442376f9d4a86fb187579e4438649daa5b5be993d05e74d7b2db"}, +] + +[package.dependencies] +cryptography = ">=1.4" +defusedxml = ">=0.5.0rc1" +oauthlib = ">=1.0.3" +PyJWT = ">=2.7.0" +python3-openid = ">=3.0.10" +requests = ">=2.9.1" +requests-oauthlib = ">=0.6.1" + +[package.extras] +all = ["cryptography (>=2.1.1)", "python3-saml (>=1.5.0)"] +allpy3 = ["cryptography (>=2.1.1)", "python3-saml (>=1.5.0)"] +azuread = ["cryptography (>=2.1.1)"] +saml = ["python3-saml (>=1.5.0)"] + [[package]] name = "soupsieve" version = "2.3.2.post1" @@ -3253,4 +3329,4 @@ gunicorn = ["gunicorn"] [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "33eb0382836cb0b0c327350fbb8e0332016b36fec5133b83bc5576c1310106e0" +content-hash = "5f4c07c24964b077a15be88ec29a4159e45378b65a5819cc6267b3904f5b311d" diff --git a/pyproject.toml b/pyproject.toml index 021b95b45..7d7731370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ sentry-sdk = "^2.12.0" tblib = "^3.0.0" urllib3 = "<2" whitenoise = "~6.6" +social-auth-app-django = "^5.4.2" [tool.poetry.extras] gunicorn = ["gunicorn"] diff --git a/rca/account_management/views.py b/rca/account_management/views.py index ca34b4cf3..d397c87d5 100644 --- a/rca/account_management/views.py +++ b/rca/account_management/views.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import Group from django.core.exceptions import PermissionDenied from django.core.mail import send_mail +from django.shortcuts import redirect, render from django.template.loader import render_to_string from django.urls import reverse from django.utils.decorators import method_decorator @@ -12,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.generic import FormView from wagtail.admin import messages -from wagtail.admin.views.account import LoginView +from wagtail.admin.views.account import LoginView, LogoutView from rca.people.models import StudentIndexPage, StudentPage from rca.users.models import User @@ -164,7 +165,7 @@ def form_valid(self, form): class CustomLoginView(LoginView): """Custom login view to redirect students to their profile page""" - template_name = "wagtailadmin/login.html" + template_name = "patterns/pages/auth/login.html" def get_success_url(self): """Override the success URL if the user meets the following: @@ -190,3 +191,35 @@ def get_success_url(self): "wagtailadmin_pages:edit", kwargs={"page_id": student_page.id} ) return super().get_success_url() + + +class CustomLogoutView(LogoutView): + """Check if user is signed in via SSO. If yes, redirect them to a confirmation logout page.""" + + def dispatch(self, request, *args, **kwargs): + if ( + self.request.session.get("_auth_user_backend", "") + == "social_core.backends.azuread_tenant.AzureADTenantOAuth2" + ): + # redirect to `sso_logout_confirmation` + return redirect("sso_logout_confirmation") + + return super().dispatch(request, *args, **kwargs) + + +class SSOLogoutConfirmationView(LogoutView): + template_name = "patterns/pages/auth/logout_confirmation.html" + + def dispatch(self, request, *args, **kwargs): + if ( + self.request.session.get("_auth_user_backend", "") + == "social_core.backends.azuread_tenant.AzureADTenantOAuth2" + ): + # If the request is a POST, log out the user + if request.method == "POST": + return super().dispatch(request, *args, **kwargs) + + # If the request is GET, render the confirmation page + return render(request, self.template_name) + + return super().dispatch(request, *args, **kwargs) diff --git a/rca/project_styleguide/templates/patterns/pages/auth/login.html b/rca/project_styleguide/templates/patterns/pages/auth/login.html new file mode 100644 index 000000000..9ca14b1e5 --- /dev/null +++ b/rca/project_styleguide/templates/patterns/pages/auth/login.html @@ -0,0 +1,80 @@ +{% extends "wagtailadmin/admin_base.html" %} +{% load i18n wagtailadmin_tags %} +{% block titletag %}{% trans "Sign in" %}{% endblock %} +{% block bodyclass %}login{% endblock %} + +{% block furniture %} +
+

{% block branding_login %}{% trans "Sign in to Wagtail" %}{% endblock %}

+ +
+ {# Always show messages div so it can be appended to by JS #} + {% if messages or form.errors %} + + {% endif %} +
+ + {% block above_login %}{% endblock %} + +
+ {% block login_form %} + {% csrf_token %} + + {% url 'wagtailadmin_home' as home_url %} + + + {% block fields %} + {% formattedfield form.username %} + {% formattedfield form.password %} + + {% if show_password_reset %} + {% trans "Forgotten password?" %} + {% endif %} + + {% block extra_fields %} + {% for field_name, field in form.extra_fields %} + {% formattedfield field %} + {% endfor %} + {% endblock extra_fields %} + + {% include "wagtailadmin/shared/forms/single_checkbox.html" with label_classname="remember-me" name="remember" text=_("Remember me") %} + {% endblock %} + {% endblock %} + +
+ + {% block below_login %}{% endblock %} + + {% block branding_logo %} + + {% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/rca/project_styleguide/templates/patterns/pages/auth/logout_confirmation.html b/rca/project_styleguide/templates/patterns/pages/auth/logout_confirmation.html new file mode 100644 index 000000000..9b5953041 --- /dev/null +++ b/rca/project_styleguide/templates/patterns/pages/auth/logout_confirmation.html @@ -0,0 +1,38 @@ +{% extends "wagtailadmin/admin_base.html" %} +{% load i18n wagtailadmin_tags %} +{% block titletag %}{% trans "Log out" %}{% endblock %} +{% block bodyclass %}login{% endblock %} + +{% block furniture %} +
+

{% block branding_login %}{% trans "Logout from Wagtail" %}{% endblock %}

+ +
+ {% block login_form %} + {% csrf_token %} + +
+

+ You have signed in using Single Sign-On (SSO). Logging out here will only end your session for this application. To completely sign out, make sure to log out from your SSO provider as well. +

+
+ + + +

+ + + {% endblock %} +
+ + {% block branding_logo %} + + {% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/rca/settings/base.py b/rca/settings/base.py index 9d3f76535..c950ba3e2 100644 --- a/rca/settings/base.py +++ b/rca/settings/base.py @@ -112,6 +112,7 @@ "wagtail_rangefilter", "rangefilter", "wagtail_modeladmin", + "social_django", ] # Middleware classes @@ -150,6 +151,9 @@ # This is a custom context processor that lets us add custom # global variables to all the templates. "rca.utils.context_processors.global_vars", + # Social auth context_processors + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", ], "builtins": ["pattern_library.loader_tags"], }, @@ -860,3 +864,35 @@ ) # Vepple VEPPLE_API_URL = env.get("VEPPLE_API_URL", "https://editor.rca.rvhosted.com") + +# Azure AD (SSO) +AUTHENTICATION_BACKENDS = [ + "social_core.backends.azuread_tenant.AzureADTenantOAuth2", + "django.contrib.auth.backends.ModelBackend", +] + +# Social Auth (SSO) +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY = env.get( + "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY", None +) +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET = env.get( + "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET", None +) +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID = env.get( + "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID", None +) +SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ["username", "first_name", "last_name", "email"] +SOCIAL_AUTH_JSONFIELD_ENABLED = True +SOCIAL_AUTH_USER_MODEL = "users.User" +SOCIAL_AUTH_PIPELINE = ( + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.social_auth.associate_by_email", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", + "rca.utils.pipeline.make_sso_users_editors", +) diff --git a/rca/urls.py b/rca/urls.py index ba27d8d4b..3b7833e2c 100644 --- a/rca/urls.py +++ b/rca/urls.py @@ -11,7 +11,11 @@ from wagtail.documents import urls as wagtaildocs_urls from wagtail.utils.urlpatterns import decorate_urlpatterns -from rca.account_management.views import CustomLoginView +from rca.account_management.views import ( + CustomLoginView, + CustomLogoutView, + SSOLogoutConfirmationView, +) from rca.search import views as search_views from rca.utils.cache import get_default_cache_control_decorator from rca.wagtailapi.api import api_router @@ -23,6 +27,10 @@ private_urlpatterns = [ path("admin/login/", CustomLoginView.as_view(), name="wagtailcore_login"), path("admin/_util/login/", CustomLoginView.as_view(), name="wagtailcore_login"), + path("admin/logout/", CustomLogoutView.as_view(), name="wagtailcore_logout"), + path( + "logout/", SSOLogoutConfirmationView.as_view(), name="sso_logout_confirmation" + ), path("django-admin/", admin.site.urls), path("admin/", include(wagtailadmin_urls)), path("documents2/", include(wagtaildocs_urls)), @@ -37,7 +45,10 @@ # Public URLs that are meant to be cached. -urlpatterns = [path("sitemap.xml", sitemap)] +urlpatterns = [ + path("sitemap.xml", sitemap), + path("", include("social_django.urls", namespace="social")), +] if settings.DEBUG: diff --git a/rca/utils/pipeline.py b/rca/utils/pipeline.py new file mode 100644 index 000000000..ab2960b89 --- /dev/null +++ b/rca/utils/pipeline.py @@ -0,0 +1,6 @@ +from django.contrib.auth.models import Group + + +def make_sso_users_editors(backend, user, response, *args, **kwargs): + editors = Group.objects.get(name="Editors") + user.groups.add(editors)