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

oidc: enable mozilla django oidc and hack in login/logout buttons for SSO #2464

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
17 changes: 17 additions & 0 deletions .env.example
Expand Up @@ -115,3 +115,20 @@ OTEL_SERVICE_NAME=
# for your instance:
# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header
HTTP_X_FORWARDED_PROTO=false

# Single-Sign-On with OIDC
# If OIDC_ENABLED is true, the normal login flow will be disabled and all
# user access must be through an external OAuth2/OIDC provider like Keycloak.
# You will need to create a client secret for this server and set the values
# here. This secret must not be checked in a public repo!
OIDC_ENABLED=false
OIDC_CLIENT_ID=bookwyrm
OIDC_CLIENT_SECRET=some-long-secret-value-do-not-checkin
OIDC_OP_BASE_URL=https://login.example.com/realms/example/protocol/openid-connect

# if your OIDC provider has endpoints that do not match the Keycloak scheme,
# you will need to define these values seperately so that bookwrym can find them:
# OIDC_OP_JWKS_ENDPOINT
# OIDC_OP_AUTH_URL
# OIDC_OP_TOKEN_URL
# OIDC_OP_USERINFO_URL
1 change: 1 addition & 0 deletions bookwyrm/context_processors.py
Expand Up @@ -28,4 +28,5 @@ def site_settings(request): # pylint: disable=unused-argument
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
"request_protocol": request_protocol,
"js_cache": settings.JS_CACHE,
"oidc_enabled": settings.OIDC_ENABLED,
}
71 changes: 70 additions & 1 deletion bookwyrm/models/user.py
Expand Up @@ -10,6 +10,7 @@
from django.db import models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from model_utils import FieldTracker
import pytz

Expand All @@ -18,7 +19,14 @@
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status
from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.settings import (
DOMAIN,
ENABLE_PREVIEW_IMAGES,
USE_HTTPS,
LANGUAGES,
OIDC_ENABLED,
OIDC_RP_CLIENT_ID,
)
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, LOW
from bookwyrm.utils import regex
Expand Down Expand Up @@ -526,3 +534,64 @@ def preview_image(instance, *args, **kwargs):

if len(changed_fields) > 0:
generate_user_preview_image_task.delay(instance.id)


class OIDCUser(OIDCAuthenticationBackend):
"""a user defined by an entry in the SSO database"""

def create_user(self, claims):
"""called by mozilla_django_oidc.auth on first login"""
if not OIDC_ENABLED:
return None

print("creating user", claims)
localname = claims.get("preferred_username")
username = f"{localname}@{DOMAIN}"
email = claims.get("email", "")
display_name = claims.get("name", localname)

user = User.objects.create_user(
username,
email,
"passwords-not-supported",
name=display_name,
localname=localname,
local=True,
deactivation_reason=None,
is_active=True,
)

return self.update_user(user, claims)

def update_user(self, user, claims):
"""called by mozilla_django_oidc.auth for existing user"""
# log an error? should not reach here
if not OIDC_ENABLED:
return None

# replace the user's display name
user.name = claims.get("name", user.name)

# this is a hack to synchronize the OIDC role claims
# with the ones in the bookwyrm database.
roles = (
claims.get("resource_access", {})
.get(OIDC_RP_CLIENT_ID, {})
.get("roles", [])
)

# TODO: this should be more general.
for role in ["admin", "moderator"]:
try:
group = Group.objects.get(name=role)
if role in roles:
user.groups.add(group)
else:
user.groups.remove(group)
except Group.DoesNotExist:
print("warning: OIDC claimed role '", role, "' does not exist")

user.save()
print(f"{user.email}: '{user.name}' logged in via OIDC roles {roles}")

return user
31 changes: 31 additions & 0 deletions bookwyrm/settings.py
Expand Up @@ -82,6 +82,7 @@
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"mozilla_django_oidc", # Load after auth
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
Expand All @@ -106,6 +107,7 @@
"bookwyrm.middleware.IPBlocklistMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"mozilla_django_oidc.middleware.SessionRefresh",
]

ROOT_URLCONF = "bookwyrm.urls"
Expand Down Expand Up @@ -277,6 +279,35 @@
},
]

# Warning:
# The OpenID Connect provider (OP) provided client id and secret are secret values.
# DON’T check them into version control–pull them in from the environment.
# If you ever accidentally check them into version control, contact your
# OpenID Connect provider (OP) as soon as you can, disable that set of
# client id and secret, and generate a new set.
OIDC_ENABLED = env.bool("OIDC_ENABLED", False)
OIDC_RP_CLIENT_ID = env("OIDC_CLIENT_ID", "bookwyrm")
OIDC_RP_CLIENT_SECRET = env("OIDC_CLIENT_SECRET", "")
OIDC_RP_SIGN_ALGO = env("OIDC_SIGN_ALGO", "RS256")

# These values are specific to your OpenID Connect provider (OP)–consult
# their documentation for the appropriate values. If you are using Keycloak,
# it should be enough to set just the base URL which will set the rest of
# the links for the certs, auth, token, and userinfo URLs
OIDC_OP_BASE_URL = env(
"OIDC_OP_BASE_URL", "http://example.com/auth/realms/example/protocol/openid-connect"
)
OIDC_OP_JWKS_ENDPOINT = env("OIDC_OP_JWKS_ENDPOINT", OIDC_OP_BASE_URL + "/certs")
OIDC_OP_AUTHORIZATION_ENDPOINT = env("OIDC_OP_AUTH_URL", OIDC_OP_BASE_URL + "/auth")
OIDC_OP_TOKEN_ENDPOINT = env("OIDC_OP_TOKEN_URL", OIDC_OP_BASE_URL + "/token")
OIDC_OP_USER_ENDPOINT = env("OIDC_OP_USERINFO_URL", OIDC_OP_BASE_URL + "/userinfo")
LOGIN_REDIRECT_URL = env("OIDC_REDIRECT_URL", "/")

# If OIDC is enabled, use the 'mozilla_django_oidc' authentication backend,
# which is sub-classed to update the bookwrym specific fields
if OIDC_ENABLED:
AUTHENTICATION_BACKENDS = ("bookwyrm.models.user.OIDCUser",)


# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
Expand Down
5 changes: 5 additions & 0 deletions bookwyrm/templates/landing/login.html
Expand Up @@ -11,6 +11,10 @@ <h1 class="title">{% trans "Log in" %}</h1>
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}

{% if oidc_enabled %}
<a href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
{% else %}

{% if show_confirmed_email %}
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
{% endif %}
Expand Down Expand Up @@ -40,6 +44,7 @@ <h1 class="title">{% trans "Log in" %}</h1>
</div>
</div>
</form>
{% endif %}
</div>

{% if site.allow_registration %}
Expand Down
6 changes: 5 additions & 1 deletion bookwyrm/templates/layout.html
Expand Up @@ -128,7 +128,11 @@
{% endwith %}
</a>
</div>
{% else %}
{% elif oidc_enabled %}
<div class="navbar-item pt-5 pb-0">
<a href="{% url 'oidc_authentication_init' %}">Login via OIDC</a>
</div>
{% else %}
<div class="navbar-item pt-5 pb-0">
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
Expand Down
4 changes: 4 additions & 0 deletions bookwyrm/templates/preferences/layout.html
Expand Up @@ -15,6 +15,9 @@ <h2 class="menu-label">{% trans "Account" %}</h2>
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Edit Profile" %}</a>
{% block profile-tabs %}{% endblock %}
</li>
{% if oidc_enabled %}
<!-- passwords, 2fa and deletion not available with OIDC enabled -->
{% else %}
<li>
{% url 'prefs-password' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
Expand All @@ -23,6 +26,7 @@ <h2 class="menu-label">{% trans "Account" %}</h2>
{% url 'prefs-2fa' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
</li>
{% endif %}
<li>
{% url 'prefs-delete' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
Expand Down
3 changes: 2 additions & 1 deletion bookwyrm/urls.py
@@ -1,7 +1,7 @@
""" url routing for the app and api """
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, re_path
from django.urls import path, re_path, include
from django.views.generic.base import TemplateView

from bookwyrm import settings, views
Expand Down Expand Up @@ -62,6 +62,7 @@
re_path(r"^setup/?$", views.InstanceConfig.as_view(), name="setup"),
re_path(r"^setup/admin/?$", views.CreateAdmin.as_view(), name="setup-admin"),
# authentication
path("oidc/", include("mozilla_django_oidc.urls")),
re_path(r"^login/?$", views.Login.as_view(), name="login"),
re_path(r"^login/(?P<confirmed>confirmed)/?$", views.Login.as_view(), name="login"),
re_path(r"^register/?$", views.Register.as_view()),
Expand Down
11 changes: 11 additions & 0 deletions bookwyrm/views/setup.py
Expand Up @@ -55,6 +55,17 @@ def get(self, request):
"""Create admin user form"""
# only allow this view when an instance is being configured
site = models.SiteSettings.objects.get()

# when OIDC is enabled the install mode is skipped
# and user registrations / invites are turned off by default
# since the only way to create users is with the SSO system
if settings.OIDC_ENABLED:
site.install_mode = False
site.allow_registrations = False
site.allow_invite_requests = False
site.save()
return redirect("settings-site")

if not site.install_mode:
raise PermissionDenied()

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Expand Up @@ -31,6 +31,7 @@ opentelemetry-sdk==1.11.1
protobuf==3.20.*
pyotp==2.6.0
qrcode==7.3.1
mozilla-django-oidc

# Dev
pytest-django==4.1.0
Expand Down