Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to OIDC for SSO #8414

Merged
merged 6 commits into from
May 23, 2024
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
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")
willbarton marked this conversation as resolved.
Show resolved Hide resolved

# 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"
chosak marked this conversation as resolved.
Show resolved Hide resolved

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
Loading