Skip to content

Commit

Permalink
Merge pull request #8414 from cfpb/sso-mozilla-oidc
Browse files Browse the repository at this point in the history
Switch to OIDC for SSO
  • Loading branch information
willbarton committed May 23, 2024
2 parents 5d8f881 + 3950e8e commit 69adbfc
Show file tree
Hide file tree
Showing 32 changed files with 769 additions and 565 deletions.
26 changes: 16 additions & 10 deletions .env_SAMPLE
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,23 @@ export HUD_API_ENDPOINT=http://localhost:8000/hud-api-replace/
#export NEW_RELIC_APP_NAME="cf.gov <your username here> python"

############################################################################
# SAML2 Authentication for local testing
# SSO for local testing
#
# If testing our SAML2 authentication support, these variables are required,
# If testing our SSO authentication support, these variables are required,
# as is running with the production settings.
#
# See cfgov/cfgov/settings/production.py and the djangosaml2 documentation
# at https://djangosaml2.readthedocs.io/contents/setup.html#configuration
# for details.
# See cfgov/cfgov/settings/production.py and the mozilla-django-oidc docs at
# https://mozilla-django-oidc.readthedocs.io for details.
#
# The values below are for using our oidcprovider Docker container for
# development purposes.
############################################################################

# export SAML2_AUTH=True
# export SAML2_ROOT_URL="https://<FQDN of the cf.gov instance>"
# export SAML2_ENTITY_ID="spn:<Entity ID>"
# export SAML2_METADATA_URL="<Metadata URL>"
# export ENABLE_SSO=1
# export OIDC_RP_CLIENT_ID=4
# export OIDC_RP_CLIENT_SECRET=itsasecret
# export OIDC_RP_SIGN_ALGO=HS256
# export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/openid/authorize
# export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/openid/token
# export OIDC_OP_USER_ENDPOINT=http://localhost:8080/openid/userinfo
# export OIDC_OP_JWKS_ENDPOINT=[Not used for test OIDC provider]
# export OIDC_OP_ADMIN_ROLE=[Not supported by test OIDC provider]
106 changes: 71 additions & 35 deletions cfgov/cfgov/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _

from opensearchpy import RequestsHttpConnection
Expand Down Expand Up @@ -73,7 +74,6 @@
"django.contrib.sitemaps",
"django.contrib.staticfiles",
"django.contrib.humanize",
"axes",
"wagtail.search",
"storages",
"data_research",
Expand Down Expand Up @@ -114,6 +114,7 @@
"django_filters",
"django_htmx",
"wagtail_content_audit",
"mozilla_django_oidc",
)

MIDDLEWARE = (
Expand All @@ -132,9 +133,6 @@
"core.middleware.DeactivateTranslationsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# AxesMiddleware should be the last middleware in the MIDDLEWARE list
# that touches authentication.
"axes.middleware.AxesMiddleware",
)

CSP_MIDDLEWARE = ("csp.middleware.CSPMiddleware",)
Expand Down Expand Up @@ -289,8 +287,6 @@
WAGTAILIMAGES_IMAGE_MODEL = "v1.CFGOVImage"
WAGTAILIMAGES_IMAGE_FORM_BASE = "v1.forms.CFGOVImageForm"
TAGGIT_CASE_INSENSITIVE = True

WAGTAILADMIN_USER_LOGIN_FORM = "login.forms.LoginForm"
WAGTAIL_USER_CREATION_FORM = "login.forms.UserCreationForm"
WAGTAIL_USER_EDIT_FORM = "login.forms.UserEditForm"

Expand Down Expand Up @@ -390,6 +386,8 @@

PRIVACY_EMAIL_TARGET = os.environ.get("PRIVACY_EMAIL_TARGET", "test@localhost")

AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",)

# Password Policies
AUTH_PASSWORD_VALIDATORS = [
{
Expand Down Expand Up @@ -435,37 +433,8 @@
"message": "Password must include at least one special character (@#$%&!).",
},
},
{
"NAME": "login.password_validation.HistoryValidator",
"OPTIONS": {
"count": 10,
},
},
{
"NAME": "login.password_validation.AgeValidator",
"OPTIONS": {
"hours": 24,
},
},
]

# Login lockout rules using django-axes
AUTHENTICATION_BACKENDS = (
"axes.backends.AxesStandaloneBackend",
"django.contrib.auth.backends.ModelBackend",
)
AXES_ENABLED = True
AXES_VERBOSE = False
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 2 # Hours
AXES_LOCKOUT_PARAMETERS = ["username"]
AXES_LOCKOUT_CALLABLE = "login.views.lockout"
LOGOUT_REDIRECT_URL = "wagtailadmin_login"

# Initialize our SAML_AUTH variable as false. Our production settings will
# override this based on the SAML_AUTH environment variable.
SAML_AUTH = False

DATE_FORMAT = "n/j/Y"

# CDNs
Expand Down Expand Up @@ -753,3 +722,70 @@
)

DRAFTAIL_ANCHORS_RENDERER = "wagtail_draftail_anchors.rich_text.render_span"

# Two abbreviations to note:
# - OP indicates the OIDC identity provider
# - RP indicates the OIDC relying party, the client, this application
#
# Requires the following environment variables to be defined:
#
# - ENABLE_SSO: Enables SSO authentication if defined.
# - OIDC_RP_CLIENT_ID: OIDC client identifier provided by the OP
# - OIDC_RP_CLIENT_SECRET: OIDC client secret provided by the OP
# - OIDC_RP_SIGN_ALGO: The algorithm used to sign ID tokens
#
# Endpoints on the OIDC provider (with typical last path components):
# - OIDC_OP_AUTHORIZATION_ENDPOINT: authorization endpoint (/authorize)
# - OIDC_OP_TOKEN_ENDPOINT: token endpoint (/token)
# - OIDC_OP_USER_ENDPOINT: userinfo endpoint (/userinfo)
# - OIDC_OP_JWKS_ENDPOINT: JWKS endpoint (alternative to OIDC_RP_IDP_SIGN_KEY)
#
# Optional environment variables:
# - OIDC_RP_IDP_SIGN_KEY: The key (PEM) to sign ID tokens when
# OIDC_RP_SIGN_ALGO is RS256 (default: None)
# - OIDC_ADMIN_GROUP: The group claim for admins
#
# See the mozilla-django-oidc documentation for more details about the
# settings below:
# https://mozilla-django-oidc.readthedocs.io/en/stable/settings.html
ENABLE_SSO = bool(os.environ.get("ENABLE_SSO"))
if ENABLE_SSO:
# Add our OIDC authentication backend, a subclass of
# mozilla_django_oidc.auth.OIDCAuthenticationBackend
AUTHENTICATION_BACKENDS += ("login.auth.CFPBOIDCAuthenticationBackend",)

# Add OIDC middleware that refreshes sessions from the provider
MIDDLEWARE += ("mozilla_django_oidc.middleware.SessionRefresh",)

# Configure login/out URLs for OIDC
LOGIN_REDIRECT_URL = reverse_lazy("wagtailadmin_home")
LOGOUT_REDIRECT_URL = reverse_lazy("cfgov_login")
ALLOW_LOGOUT_GET_METHOD = True

# This OIDC client's id and secret
OIDC_RP_CLIENT_ID = os.environ["OIDC_RP_CLIENT_ID"]
OIDC_RP_CLIENT_SECRET = os.environ["OIDC_RP_CLIENT_SECRET"]

# The OIDC provider's signing algorithms and key/key endpoint
OIDC_RP_SIGN_ALGO = os.environ["OIDC_RP_SIGN_ALGO"]

# Because only one of these two values is required if
# OIDC_RP_SIGN_ALGO="RS256", we allow them to be None, and the OIDC
# library will raise an error if neither are defined.
OIDC_RP_IDP_SIGN_KEY = os.environ.get("OIDC_RP_IDP_SIGN_KEY")
OIDC_OP_JWKS_ENDPOINT = os.environ.get("OIDC_OP_JWKS_ENDPOINT")

# OIDC provider endpoints
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ[
"OIDC_OP_AUTHORIZATION_ENDPOINT"
]
OIDC_OP_TOKEN_ENDPOINT = os.environ["OIDC_OP_TOKEN_ENDPOINT"]
OIDC_OP_USER_ENDPOINT = os.environ["OIDC_OP_USER_ENDPOINT"]

# For users created just-in-time, assign a username based on the
# username portion of their email address.
OIDC_USERNAME_ALGO = "login.auth.username_from_email"

# Now we do some role/group-mapping for admins and regular users
# Upstream "role" for users who get is_superuser
OIDC_OP_ADMIN_ROLE = os.environ.get("OIDC_OP_ADMIN_ROLE")
85 changes: 9 additions & 76 deletions cfgov/cfgov/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from django.urls import reverse_lazy
from django.utils.text import format_lazy

import saml2

from cfgov.settings.base import *
from cfgov.util import environment_json

Expand Down Expand Up @@ -103,80 +101,6 @@
),
)

# SAML2 Authentication
#
# Requires the SAML_AUTH, SAML_ROOT_URL, SAML_ENTITY_ID, and SAML_METADATA_URL
# environment variables to be defined.
#
# - SAML_AUTH: Enable SAML2 authentication with a value of "True".
# - SAML_ROOT_URL: The URL that browsers will use to access this site. The
# SAML2 relay URL will be constructed from this, and it must match what is
# configured for this site in the SAML2 identity provider.
# - SAML_ENTITY_ID: The entity id configured for this service provider in the
# SAML2 identity provider.
# - SAML_METADATA_URL: The remote URL of the identity provider's metadata for
# this service provider.
#
# See the djangosaml2 documentation at
# https://djangosaml2.readthedocs.io/contents/setup.html#configuration
# and the pySAML2 documentation at https://pysaml2.readthedocs.io/
# for more details about the configuration below.
SAML_AUTH = os.environ.get("SAML_AUTH") == "True"
if SAML_AUTH:
# Update built-in Django settings for SAML authetnication
INSTALLED_APPS += ("djangosaml2",)
MIDDLEWARE += ("djangosaml2.middleware.SamlSessionMiddleware",)
AUTHENTICATION_BACKENDS += ("djangosaml2.backends.Saml2Backend",)
LOGIN_URL = "/saml2/login/"
SESSION_EXPIRE_AT_BROWSER_CLOSE = True

# Map Django attributes to our SAML identity provider
SAML_DJANGO_USER_MAIN_ATTRIBUTE = "email"
SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = "__iexact"
SAML_ATTRIBUTE_MAPPING = {
"emailAddress": ("email",),
}
SAML_CREATE_UNKNOWN_USER = False

# URL lookups
SAML_ROOT_URL = os.environ["SAML_ROOT_URL"]
ACS_URL = format_lazy(
"{root_url}{acs_path}",
root_url=SAML_ROOT_URL,
acs_path=reverse_lazy("saml2_acs"),
)
ACS_DEFAULT_REDIRECT_URL = reverse_lazy("wagtailadmin_home")

# Configure PySAML2 for our identity provider
SAML_CONFIG = {
"debug": DEBUG,
"xmlsec_binary": "/usr/bin/xmlsec1",
"entityid": os.environ["SAML_ENTITY_ID"],
"metadata": {
"remote": [{"url": os.environ["SAML_METADATA_URL"]}],
},
"service": {
"sp": {
"endpoints": {
"assertion_consumer_service": [
(ACS_URL, saml2.BINDING_HTTP_REDIRECT),
(ACS_URL, saml2.BINDING_HTTP_POST),
],
},
"allow_unsolicited": True,
"authn_requests_signed": False,
"logout_requests_signed": True,
"want_assertions_signed": True,
"want_response_signed": False,
},
},
}

# Add logging
LOGGING["loggers"]["saml2"] = {
"level": "INFO",
}

# Django baseline required settings
SECURE_REFERRER_POLICY = "same-origin"
SESSION_COOKIE_SAMESITE = "Strict"
Expand Down Expand Up @@ -211,3 +135,12 @@
),
default="[]",
)

if ENABLE_SSO:
# We need the OIDC identity provider to be able to send the sessionid to
# the OIDC callback view in order to validate the OIDC state
SESSION_COOKIE_SAMESITE = "Lax"

LOGGING["loggers"]["mozilla_django_oidc"] = {
"level": "INFO",
}
4 changes: 0 additions & 4 deletions cfgov/cfgov/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,4 @@

DEPLOY_ENVIRONMENT = "test"

# Axes requires a request for authentication, which breaks uses of Django's
# test client .login() method. This disables it when running tests.
AXES_ENABLED = False

INSTALLED_APPS += ("tccp.tests.testapp",)
1 change: 1 addition & 0 deletions cfgov/cfgov/tests/test_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"^django-admin/",
"^login",
"^logout",
"^oidc/",
"^password/",
"^tasks/",
]
Expand Down
4 changes: 3 additions & 1 deletion cfgov/core/testutils/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def setup_databases(self, **kwargs):
# happen if e.g. we're only running tests that don't require one.
if dbs:
# Some required Wagtail data (like the default site) are created in
# Wagtail migrations. If we're skipping migrations when running
# data migrations. If we're skipping migrations when running
# tests, we need to create this data ourselves.
if settings.SKIP_DJANGO_MIGRATIONS:
self.initial_wagtail_data()
Expand All @@ -57,6 +57,7 @@ def initial_wagtail_data(self):
- wagtailcore.0001_squashed_0016_change_page_url_path_to_text_field
- wagtailcore.0025_collection_initial_data
- wagtailcore.0054_initial_locale
- login.0005_add_default_user_group
"""
# Create default Locale object.
Locale.objects.create(
Expand Down Expand Up @@ -110,6 +111,7 @@ def initial_wagtail_data(self):
# Create default groups.
Group.objects.get_or_create(name="Editors")
Group.objects.get_or_create(name="Moderators")
Group.objects.get_or_create(name="Wagtail Users")


class StdoutCapturingTestRunner(TestRunner):
Expand Down
10 changes: 2 additions & 8 deletions cfgov/login/apps.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
from django.apps import AppConfig
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save

from . import checks # noqa F401


class LoginConfig(AppConfig):
name = "login"

def ready(self):
from login.signals import user_save_callback

user_model = get_user_model()
post_save.connect(user_save_callback, sender=user_model)
Loading

0 comments on commit 69adbfc

Please sign in to comment.