Skip to content

Commit

Permalink
Add support for processing OIDC claims
Browse files Browse the repository at this point in the history
This allows us to handle group/admin assignment, first name/last name
synchronizing, etc.
  • Loading branch information
willbarton committed May 17, 2024
1 parent 973e7bd commit 84d0462
Show file tree
Hide file tree
Showing 6 changed files with 442 additions and 8 deletions.
3 changes: 3 additions & 0 deletions .env_SAMPLE
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@ export HUD_API_ENDPOINT=http://localhost:8000/hud-api-replace/
# 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]
# export OIDC_OP_USER_ROLE=
# export OIDC_OP_ADMIN_ROLE=
# export SSO_DEFAULT_GROUP=
45 changes: 40 additions & 5 deletions cfgov/cfgov/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,29 +741,64 @@
# 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
# - OIDC_USER_GROUP: The group claim for regular users
#
# 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 = os.environ.get("ENABLE_SSO") == "True"
if ENABLE_SSO:
INSTALLED_APPS += ("mozilla_django_oidc",)
AUTHENTICATION_BACKENDS += (
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
)

# 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_URL = "oidc_authentication_init"
LOGIN_REDIRECT_URL = reverse_lazy("wagtailadmin_home")
LOGOUT_REDIRECT_URL = "/"
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"]
OIDC_RP_IDP_SIGN_KEY = os.environ.get("OIDC_RP_IDP_SIGN_KEY")
OIDC_OP_JWKS_ENDPOINT = os.environ.get("OIDC_OP_JWKS_ENDPOINT")
print(OIDC_RP_SIGN_ALGO)
# 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"

# For each OIDC claim we get we might want to perform some action to
# map the claim to something in Django or Wagtail.
OIDC_CLAIMS_PROCESSORS = {
"roles": "login.auth.process_roles_claim",
"given_name": "login.auth.process_given_name_claim",
"family_name": "login.auth.process_family_name_claim",
}

# 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")

# Upstream "role" assigned for regular users
OIDC_OP_USER_ROLE = os.environ.get("OIDC_OP_USER_ROLE")

# Default group that regular users get assigned to
SSO_DEFAULT_GROUP = os.environ.get("SSO_DEFAULT_GROUP", "All Users")
158 changes: 158 additions & 0 deletions cfgov/login/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import logging

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.encoding import smart_str
from django.utils.module_loading import import_string

from mozilla_django_oidc.auth import OIDCAuthenticationBackend


User = get_user_model()

logger = logging.getLogger(__name__)


def username_from_email(email, claims=None):
"""Generate a username for new users from their email address
If this function is called, it's already tried to look up an email
address to match a user and failed. But if the user portion of the email
address already exists as a user, this function will try to find an
unused username by appending a counter to the end."""
email_username = smart_str(email.split("@")[0])

username = email_username
counter = 1
while User.objects.filter(username=username).exists():
username = email_username + str(counter)
counter += 1

return username


def process_roles_admin(user, roles):
"""Adjust a user's is_superuser property based on OIDC roles claim"""
user_modified = False

if settings.OIDC_OP_ADMIN_ROLE is None:
logger.debug("OIDC_OP_ADMIN_ROLE not set, not modifying is_superuser")
return

is_admin = settings.OIDC_OP_ADMIN_ROLE in roles

if is_admin and not user.is_superuser:
logger.debug(f"Setting is_superuser for {user.username}.")
user.is_superuser = True
user_modified = True
elif not is_admin and user.is_superuser:
logger.debug(f"Removing is_superuser from {user.username}.")
user.is_superuser = False
user_modified = True

return user_modified


def process_roles_user(user, roles):
"""Adjust user group assignment based on OIDC roles"""
user_modified = False

if settings.OIDC_OP_USER_ROLE is None:
logger.debug("OIDC_OP_USER_ROLE not set, not modifying user groups")
return

if settings.SSO_DEFAULT_GROUP is None:
logger.debug("SSO_DEFAULT_GROUP not set, not modifying user groups")
return

# All SSO users will belong to a group that can log into the admin
group = Group.objects.get_or_create(name=settings.SSO_DEFAULT_GROUP)[0]

# Make sure this group has permission to log into the admin
permission = Permission.objects.get(
codename="access_admin",
content_type=ContentType.objects.get(
app_label="wagtailadmin", model="admin"
),
)
if not group.permissions.contains(permission):
group.permissions.add(permission)

# Modify the user's membership in the group based on the roles claim
is_user = settings.OIDC_OP_USER_ROLE in roles

if is_user and not user.groups.contains(group):
logger.debug(f"Adding {user.username} to {group.name}.")
user.groups.add(group)
user_modified = True
elif not is_user and user.groups.contains(group):
logger.debug(f"Removing {user.username} from {group.name}.")
user.groups.remove(group)
user_modified = True

return user_modified


def process_roles_claim(user, roles):
"""Adjust is_superuser and user group assignment based on OIDC roles"""
user_modified = process_roles_user(user, roles)
user_modified = process_roles_admin(user, roles) or user_modified
return user_modified


def process_given_name_claim(user, given_name):
"""Set a Django user's first_name to an OIDC given_name"""
if user.first_name != given_name:
user.first_name = given_name
return True


def process_family_name_claim(user, family_name):
"""Set a Django user's last_name to an OIDC family_name"""
if user.last_name != family_name:
user.last_name = family_name
return True


class CFPBOIDCAuthenticationBackend(OIDCAuthenticationBackend):

def get_userinfo(self, access_token, id_token, payload):
# Roles are part of the parent payload here, and not the userinfo.
# Preserve them if we can.
roles = payload.get("roles", [])
userinfo = super().get_userinfo(access_token, id_token, payload)
userinfo["roles"] = roles
return userinfo

def process_claims(self, user, claims):
"""Run a configured callable for each OIDC claim received
If a claim processor modifies a user it should return True, and the
user will be saved once all claims are processed."""
user_modified = False

claim_processors = getattr(settings, "OIDC_CLAIMS_PROCESSORS", {})
for claim, processor_string in claim_processors.items():
if claim not in claims:
continue

processor = import_string(processor_string)
processor_modified_user = processor(user, claims[claim])
user_modified = user_modified or processor_modified_user

if user_modified:
user.save()

return user

def update_user(self, user, claims):
"""Update user access based on claims"""
user = super().update_user(user, claims)
return self.process_claims(user, claims)

def create_user(self, claims):
"""Return object for a newly created user account."""
user = super().create_user(claims)
return self.update_user(user, claims)
Loading

0 comments on commit 84d0462

Please sign in to comment.