From 39782e75e7c04efabd785e86b0ad33bc5eeea51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=B7g?= Date: Sat, 16 Mar 2024 23:42:46 +0100 Subject: [PATCH] Add support for OIDC (#389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added support for oidc auth * fixed oidc usernames * hiding password for users that aren't logged in via local auth * add dependency, update settings * keep change password link * add tests * add docs --------- Co-authored-by: Sascha Ißbrücker --- README.md | 2 +- bookmarks/templates/registration/login.html | 12 +++-- bookmarks/templates/settings/general.html | 4 +- bookmarks/tests/test_login_view.py | 29 ++++++++++++ bookmarks/tests/test_oidc_support.py | 51 +++++++++++++++++++++ bookmarks/utils.py | 10 ++++ docs/Options.md | 25 ++++++++++ requirements.in | 1 + requirements.txt | 17 +++++++ scripts/setup-oicd.sh | 7 +++ siteroot/settings/base.py | 23 ++++++++-- siteroot/urls.py | 28 ++++++++--- 12 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 bookmarks/tests/test_login_view.py create mode 100644 bookmarks/tests/test_oidc_support.py create mode 100644 scripts/setup-oicd.sh diff --git a/README.md b/README.md index e0737c3..6967bfd 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ The name comes from: - Installable as a Progressive Web App (PWA) - Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet - Light and dark themes +- SSO support via OIDC or authentication proxies - REST API for developing 3rd party apps - Admin panel for user self-service and raw data access -- Easy setup using Docker and a SQLite database, with PostgreSQL as an option **Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it) diff --git a/bookmarks/templates/registration/login.html b/bookmarks/templates/registration/login.html index 5cce281..9b69a60 100644 --- a/bookmarks/templates/registration/login.html +++ b/bookmarks/templates/registration/login.html @@ -17,22 +17,24 @@ {% endif %}
- {{ form.username|add_class:'form-input'|attr:"placeholder: " }} + {{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
- {{ form.password|add_class:'form-input'|attr:"placeholder: " }} + {{ form.password|add_class:'form-input'|attr:'placeholder: ' }}

- - + + + {% if enable_oidc %} + Login with OIDC + {% endif %} {% if allow_registration %} Register {% endif %}
- {% endblock %} diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html index b146ba8..fa3f1b9 100644 --- a/bookmarks/templates/settings/general.html +++ b/bookmarks/templates/settings/general.html @@ -150,7 +150,9 @@ Import public bookmarks as shared
- When importing bookmarks from a service that supports marking bookmarks as public or private (using the PRIVATE attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks. + When importing bookmarks from a service that supports marking bookmarks as public or private (using the + PRIVATE attribute), enabling this option will import all bookmarks that are marked as not + private as shared bookmarks. Otherwise, all bookmarks will be imported as private bookmarks.
diff --git a/bookmarks/tests/test_login_view.py b/bookmarks/tests/test_login_view.py new file mode 100644 index 0000000..0e0d05a --- /dev/null +++ b/bookmarks/tests/test_login_view.py @@ -0,0 +1,29 @@ +from django.test import TestCase, override_settings +from django.urls import path, include + +from bookmarks.tests.helpers import HtmlTestMixin +from siteroot.urls import urlpatterns as base_patterns + +# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled +urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls"))] + + +@override_settings(ROOT_URLCONF=__name__) +class LoginViewTestCase(TestCase, HtmlTestMixin): + + def test_should_not_show_oidc_login_by_default(self): + response = self.client.get("/login/") + soup = self.make_soup(response.content.decode()) + + oidc_login_link = soup.find("a", text="Login with OIDC") + + self.assertIsNone(oidc_login_link) + + @override_settings(LD_ENABLE_OIDC=True) + def test_should_show_oidc_login_when_enabled(self): + response = self.client.get("/login/") + soup = self.make_soup(response.content.decode()) + + oidc_login_link = soup.find("a", text="Login with OIDC") + + self.assertIsNotNone(oidc_login_link) diff --git a/bookmarks/tests/test_oidc_support.py b/bookmarks/tests/test_oidc_support.py new file mode 100644 index 0000000..0c937d2 --- /dev/null +++ b/bookmarks/tests/test_oidc_support.py @@ -0,0 +1,51 @@ +import importlib +import os + +from django.test import TestCase, override_settings +from django.urls import URLResolver + + +class OidcSupportTest(TestCase): + def test_should_not_add_oidc_urls_by_default(self): + siteroot_urls = importlib.import_module("siteroot.urls") + importlib.reload(siteroot_urls) + oidc_url_found = any( + isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/" + for urlpattern in siteroot_urls.urlpatterns + ) + + self.assertFalse(oidc_url_found) + + @override_settings(LD_ENABLE_OIDC=True) + def test_should_add_oidc_urls_when_enabled(self): + siteroot_urls = importlib.import_module("siteroot.urls") + importlib.reload(siteroot_urls) + oidc_url_found = any( + isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/" + for urlpattern in siteroot_urls.urlpatterns + ) + + self.assertTrue(oidc_url_found) + + def test_should_not_add_oidc_authentication_backend_by_default(self): + base_settings = importlib.import_module("siteroot.settings.base") + importlib.reload(base_settings) + + self.assertListEqual( + ["django.contrib.auth.backends.ModelBackend"], + base_settings.AUTHENTICATION_BACKENDS, + ) + + def test_should_add_oidc_authentication_backend_when_enabled(self): + os.environ["LD_ENABLE_OIDC"] = "True" + base_settings = importlib.import_module("siteroot.settings.base") + importlib.reload(base_settings) + + self.assertListEqual( + [ + "django.contrib.auth.backends.ModelBackend", + "mozilla_django_oidc.auth.OIDCAuthenticationBackend", + ], + base_settings.AUTHENTICATION_BACKENDS, + ) + del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable diff --git a/bookmarks/utils.py b/bookmarks/utils.py index a09d847..04bb697 100644 --- a/bookmarks/utils.py +++ b/bookmarks/utils.py @@ -1,5 +1,6 @@ import logging import re +import unicodedata from datetime import datetime from typing import Optional @@ -111,3 +112,12 @@ def get_safe_return_url(return_url: str, fallback_url: str): if not return_url or not re.match(r"^/[a-z]+", return_url): return fallback_url return return_url + + +def generate_username(email): + # taken from mozilla-django-oidc docs :) + + # Using Python 3 and Django 1.11+, usernames can contain alphanumeric + # (ascii and unicode), _, @, +, . and - characters. So we normalize + # it and slice at 150 characters. + return unicodedata.normalize("NFKC", email)[:150] diff --git a/docs/Options.md b/docs/Options.md index a018c41..4fd8e0c 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -94,6 +94,31 @@ For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_A By default, the logout redirects to the login URL, which means the user will be automatically authenticated again. Instead, you might want to configure the logout URL of the auth proxy here. +### `LD_ENABLE_OIDC` + +Values: `True`, `False` | Default = `False` + +Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers. +When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider. +Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding. +If there is no user with that email address as username, a new user is created automatically. + +This requires configuring a number of other options, which of those you need depends on which OIDC provider you use and how it is configured. +In general, you should find the required information in the UI of your OIDC provider, or its documentation. + +The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support. +Please check their documentation for more information on the options. + +The following options are available: +- `OIDC_RP_CLIENT_ID` - Required. The client ID of your linkding instance in the OIDC provider. +- `OIDC_OP_AUTHORIZATION_ENDPOINT` - Required. The authorization endpoint of the OIDC provider. +- `OIDC_OP_TOKEN_ENDPOINT` - Required. The token endpoint of the OIDC provider. +- `OIDC_OP_USER_ENDPOINT` - Required. The user info endpoint of the OIDC provider. +- `OIDC_USE_PKCE` - Optional. Whether to use PKCE for the OIDC flow. Default is `True`. If you leave this enabled you should configure your OIDC provider to use the PKCE flow as well. You need to disable this if you want to use an authentication flow with a client secret. +- `OIDC_RP_CLIENT_SECRET` - Optional. The client secret of the OIDC application. You need to disable PKCE if you want to use a client secret. +- `OIDC_RP_SIGN_ALGO` - Optional. The signing algorithm to use for the OIDC flow. Default is `HS256`. +- `OIDC_OP_JWKS_ENDPOINT` - Optional. The JWKS endpoint of the OIDC provider. + ### `LD_CSRF_TRUSTED_ORIGINS` Values: `String` | Default = None diff --git a/requirements.in b/requirements.in index dc8e4d6..e67449c 100644 --- a/requirements.in +++ b/requirements.in @@ -8,6 +8,7 @@ django-widget-tweaks django4-background-tasks djangorestframework Markdown +mozilla-django-oidc psycopg2-binary python-dateutil requests diff --git a/requirements.txt b/requirements.txt index bea3c7e..a1a7096 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,17 +14,25 @@ bleach-allowlist==1.0.3 # via -r requirements.in certifi==2023.11.17 # via requests +cffi==1.16.0 + # via cryptography charset-normalizer==3.3.2 # via requests click==8.1.7 # via waybackpy confusable-homoglyphs==3.2.0 # via django-registration +cryptography==42.0.5 + # via + # josepy + # mozilla-django-oidc + # pyopenssl django==5.0.2 # via # -r requirements.in # django-registration # djangorestframework + # mozilla-django-oidc django-registration==3.4 # via -r requirements.in django-sass-processor==1.4 @@ -37,10 +45,18 @@ djangorestframework==3.14.0 # via -r requirements.in idna==3.6 # via requests +josepy==1.14.0 + # via mozilla-django-oidc markdown==3.5.2 # via -r requirements.in +mozilla-django-oidc==4.0.1 + # via -r requirements.in psycopg2-binary==2.9.9 # via -r requirements.in +pycparser==2.21 + # via cffi +pyopenssl==24.1.0 + # via josepy python-dateutil==2.8.2 # via -r requirements.in pytz==2023.3.post1 @@ -48,6 +64,7 @@ pytz==2023.3.post1 requests==2.31.0 # via # -r requirements.in + # mozilla-django-oidc # waybackpy six==1.16.0 # via diff --git a/scripts/setup-oicd.sh b/scripts/setup-oicd.sh new file mode 100644 index 0000000..cb3b2fd --- /dev/null +++ b/scripts/setup-oicd.sh @@ -0,0 +1,7 @@ +# Example setup for OIDC with Zitadel +export LD_ENABLE_OIDC=True +export OIDC_USE_PKCE=True +export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/oauth/v2/authorize +export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/oauth/v2/token +export OIDC_OP_USER_ENDPOINT=http://localhost:8080/oidc/v1/userinfo +export OIDC_RP_CLIENT_ID=258574559115018243@linkding diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py index a2096e3..373cfa0 100644 --- a/siteroot/settings/base.py +++ b/siteroot/settings/base.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ "rest_framework", "rest_framework.authtoken", "background_task", + "mozilla_django_oidc", ] MIDDLEWARE = [ @@ -182,6 +183,24 @@ MAX_ATTEMPTS = 5 BACKGROUND_TASK_RUN_ASYNC = True BACKGROUND_TASK_ASYNC_THREADS = 2 +# Enable OICD support if configured +LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1") + +AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] + +if LD_ENABLE_OIDC: + AUTHENTICATION_BACKENDS.append("mozilla_django_oidc.auth.OIDCAuthenticationBackend") + + OIDC_USERNAME_ALGO = "bookmarks.utils.generate_username" + OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID") + OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT") + OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT") + OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT") + OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1") + OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET") + OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "HS256") + OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT") + # Enable authentication proxy support if configured LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1") LD_AUTH_PROXY_USERNAME_HEADER = os.getenv( @@ -194,9 +213,7 @@ if LD_ENABLE_AUTH_PROXY: # in the LD_AUTH_PROXY_USERNAME_HEADER request header MIDDLEWARE.append("bookmarks.middlewares.CustomRemoteUserMiddleware") # Configure auth backend that does not require a password credential - AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.RemoteUserBackend", - ] + AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.RemoteUserBackend"] # Configure logout URL if LD_AUTH_PROXY_LOGOUT_URL: LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL diff --git a/siteroot/urls.py b/siteroot/urls.py index 108c353..5431f02 100644 --- a/siteroot/urls.py +++ b/siteroot/urls.py @@ -19,16 +19,27 @@ from django.contrib.auth import views as auth_views from django.urls import path, include from bookmarks.admin import linkding_admin_site -from .settings import ALLOW_REGISTRATION, DEBUG + + +class LinkdingLoginView(auth_views.LoginView): + """ + Custom login view to lazily add additional context data + Allows to override settings in tests + """ + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["allow_registration"] = settings.ALLOW_REGISTRATION + context["enable_oidc"] = settings.LD_ENABLE_OIDC + return context + urlpatterns = [ path("admin/", linkding_admin_site.urls), path( "login/", - auth_views.LoginView.as_view( - redirect_authenticated_user=True, - extra_context=dict(allow_registration=ALLOW_REGISTRATION), - ), + LinkdingLoginView.as_view(redirect_authenticated_user=True), name="login", ), path("logout/", auth_views.LogoutView.as_view(), name="logout"), @@ -45,13 +56,16 @@ urlpatterns = [ path("", include("bookmarks.urls")), ] +if settings.LD_ENABLE_OIDC: + urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls"))) + if settings.LD_CONTEXT_PATH: urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))] -if DEBUG: +if settings.DEBUG: import debug_toolbar urlpatterns.append(path("__debug__/", include(debug_toolbar.urls))) -if ALLOW_REGISTRATION: +if settings.ALLOW_REGISTRATION: urlpatterns.append(path("", include("django_registration.backends.one_step.urls")))