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

Add support for oidc #389

Merged
merged 9 commits into from
Mar 16, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions bookmarks/templates/registration/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,24 @@ <h2>Login</h2>
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
</div>

<br/>
<div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
<input type="hidden" name="next" value="{{ next }}"/>
{% if enable_oidc %}
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
{% endif %}
{% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
{% endif %}
</div>

</form>
</section>
{% endblock %}
4 changes: 3 additions & 1 deletion bookmarks/templates/settings/general.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ <h2>Import</h2>
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> 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
<code>PRIVATE</code> 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.
</div>
</div>
Expand Down
29 changes: 29 additions & 0 deletions bookmarks/tests/test_login_view.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions bookmarks/tests/test_oidc_support.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions bookmarks/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import re
import unicodedata
from datetime import datetime
from typing import Optional

Expand Down Expand Up @@ -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]
25 changes: 25 additions & 0 deletions docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ django-widget-tweaks
django4-background-tasks
djangorestframework
Markdown
mozilla-django-oidc
psycopg2-binary
python-dateutil
requests
Expand Down
17 changes: 17 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,17 +45,26 @@ 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
# via djangorestframework
requests==2.31.0
# via
# -r requirements.in
# mozilla-django-oidc
# waybackpy
six==1.16.0
# via
Expand Down
7 changes: 7 additions & 0 deletions scripts/setup-oicd.sh
Original file line number Diff line number Diff line change
@@ -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
23 changes: 20 additions & 3 deletions siteroot/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"rest_framework",
"rest_framework.authtoken",
"background_task",
"mozilla_django_oidc",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -182,6 +183,24 @@
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(
Expand All @@ -194,9 +213,7 @@
# 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
Expand Down
28 changes: 21 additions & 7 deletions siteroot/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,27 @@
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"),
Expand All @@ -45,13 +56,16 @@
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")))