From 40d1e18faf39dd50dd41c5e94ab13350df384e51 Mon Sep 17 00:00:00 2001
From: Antoine Martin <dev@ayakael.net>
Date: Wed, 14 Jun 2023 10:20:42 -0400
Subject: [PATCH 1/1] Add OpenID Connect SSO support via django-allauth (#1746)

---
 src/documents/templates/account/base.html     |  14 +
 src/documents/templates/account/login.html    |   1 +
 src/documents/templates/account/logout.html   |   1 +
 .../templates/registration/login.html         |  72 ++-
 .../socialaccount/authentication_error.html   |   1 +
 src/documents/templatetags/django_settings.py |   9 +
 src/paperless/allauth_custom.py               |  73 +++
 src/paperless/settings.py                     | 114 +++++
 src/paperless/urls.py                         |   9 +-
 10 files changed, 711 insertions(+), 18 deletions(-)
 create mode 100644 0001-Add-OpenID-Connect-SSO-support-via-django-allauth-17.patch
 create mode 100644 src/documents/templates/account/base.html
 create mode 120000 src/documents/templates/account/login.html
 create mode 120000 src/documents/templates/account/logout.html
 create mode 120000 src/documents/templates/socialaccount/authentication_error.html
 create mode 100644 src/documents/templatetags/django_settings.py
 create mode 100644 src/paperless/allauth_custom.py

diff --git a/src/documents/templates/account/base.html b/src/documents/templates/account/base.html
new file mode 100644
index 00000000..0912c609
--- /dev/null
+++ b/src/documents/templates/account/base.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+<html lang="en">
+  <head>
+    <title>{% translate "Redirecting" %}</title>
+    <meta http-equiv="refresh" content="0;URL='{% url "base" %}'" />
+    <style type="text/css">
+      body { background-color: #fff; }
+      a { color: #ccc; }
+    </style>
+  </head>
+  <body>
+    <a href="{% url "base" %}">{% translate "Redirecting" %}...</a>
+  </body>
+</html>
diff --git a/src/documents/templates/account/login.html b/src/documents/templates/account/login.html
new file mode 120000
index 00000000..03fd169e
--- /dev/null
+++ b/src/documents/templates/account/login.html
@@ -0,0 +1 @@
+../registration/login.html
\ No newline at end of file
diff --git a/src/documents/templates/account/logout.html b/src/documents/templates/account/logout.html
new file mode 120000
index 00000000..7cad0aa6
--- /dev/null
+++ b/src/documents/templates/account/logout.html
@@ -0,0 +1 @@
+../registration/logged_out.html
\ No newline at end of file
diff --git a/src/documents/templates/registration/login.html b/src/documents/templates/registration/login.html
index d9ff86a7..404f37e6 100644
--- a/src/documents/templates/registration/login.html
+++ b/src/documents/templates/registration/login.html
@@ -1,4 +1,7 @@
 <!doctype html>
+{% load django_settings %}
+{% load socialaccount %}
+{% settings_value "LOGIN_HIDE_PASSWORD_FORM" as hide_password_form %}
 
 {% load static %}
 {% load i18n %}
@@ -38,8 +41,7 @@
   </head>
 
   <body class="text-center">
-    <form class="form-signin" method="post">
-			{% csrf_token %}
+    <div class="form-signin">
       <svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
         <path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
         <g class="text" style="fill:#000">
@@ -58,19 +60,55 @@
           <path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
         </g>
       </svg>
-			<p>{% translate "Please sign in." %}</p>
-			{% if form.errors %}
-				<div class="alert alert-danger" role="alert">
-					{% translate "Your username and password didn't match. Please try again." %}
-				</div>
-			{% endif %}
-			{% translate "Username" as i18n_username %}
-			{% translate "Password" as i18n_password %}
-			<label for="inputUsername" class="sr-only">{{ i18n_username }}</label>
-			<input type="text" name="username" id="inputUsername" class="form-control" placeholder="{{ i18n_username }}" autocorrect="off" autocapitalize="none" required autofocus>
-			<label for="inputPassword" class="sr-only">{{ i18n_password }}</label>
-			<input type="password" name="password" id="inputPassword" class="form-control" placeholder="{{ i18n_password }}" required>
-			<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Sign in" %}</button>
-		</form>
-	</body>
+		  {% if auth_error %}
+      <div class="alert alert-danger" role="alert">
+        {% translate "An authentication error occurred. Please try again." %}
+      </div>
+      {% endif %}
+      {% if not hide_password_form %}
+      <p>{% translate "Please sign in." %}</p>
+      <form method="post">
+        {% csrf_token %}
+        {% if form.errors %}
+        <div class="alert alert-danger" role="alert">
+          {% translate "Your username and password didn't match. Please try again." %}
+        </div>
+        {% endif %}
+        {% translate "Username" as i18n_username %}
+        {% translate "Password" as i18n_password %}
+        <label for="inputUsername" class="sr-only">{{ i18n_username }}</label>
+        <input type="text" name="username" id="inputUsername" class="form-control" placeholder="{{ i18n_username }}" autocorrect="off" autocapitalize="none" required autofocus>
+        <label for="inputPassword" class="sr-only">{{ i18n_password }}</label>
+        <input type="password" name="password" id="inputPassword" class="form-control" placeholder="{{ i18n_password }}" required>
+        <button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Sign in" %}</button>
+      </form>
+      {% endif %}
+      {% get_providers as socialaccount_providers %}
+      {% if socialaccount_providers %}
+        {% if not hide_password_form %}
+          <p></p>
+          <p>- {% translate "or" %} -</p>
+        {% endif %}
+
+        {% for provider in socialaccount_providers %}
+          {% if provider.id == "openid" %}
+            {% for brand in provider.get_brands %}
+            <form method="post" action="{% provider_login_url provider.id openid=brand.openid_url process=process %}">
+              {% csrf_token %}
+              <p>
+                <input class="btn btn-lg btn-primary btn-block" type="submit" value="Log in with {{ brand.name }}">
+              </p>
+            </form>
+            {% endfor %}
+          {% endif %}
+          <form method="post" action="{% provider_login_url provider.id process=process %}">
+            {% csrf_token %}
+            <p>
+              <input class="btn btn-lg btn-primary btn-block" type="submit" value="Log in with {{ provider.name }}">
+            </p>
+          </form>
+        {% endfor %}
+      {% endif %}
+    </div>
+  </body>
 </html>
diff --git a/src/documents/templates/socialaccount/authentication_error.html b/src/documents/templates/socialaccount/authentication_error.html
new file mode 120000
index 00000000..b2ef5434
--- /dev/null
+++ b/src/documents/templates/socialaccount/authentication_error.html
@@ -0,0 +1 @@
+../account/login.html
\ No newline at end of file
diff --git a/src/documents/templatetags/django_settings.py b/src/documents/templatetags/django_settings.py
new file mode 100644
index 00000000..cf415d5c
--- /dev/null
+++ b/src/documents/templatetags/django_settings.py
@@ -0,0 +1,9 @@
+from django import template
+from django.conf import settings
+
+register = template.Library()
+
+
+@register.simple_tag
+def settings_value(name):
+    return getattr(settings, name, "")
diff --git a/src/paperless/allauth_custom.py b/src/paperless/allauth_custom.py
new file mode 100644
index 00000000..0e716346
--- /dev/null
+++ b/src/paperless/allauth_custom.py
@@ -0,0 +1,73 @@
+import logging
+
+from allauth.account.adapter import DefaultAccountAdapter
+from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
+from django.conf import settings
+from django.http import Http404
+from django.urls import include
+from django.urls import path
+from django.urls import re_path
+from django.urls import reverse
+from django.urls import reverse_lazy
+from django.views.generic import RedirectView
+
+logger = logging.getLogger("paperless.allauth")
+
+
+def raise_404(*args, **kwargs):
+    raise Http404
+
+
+class CustomAccountAdapter(DefaultAccountAdapter):
+    def get_login_redirect_url(self, request):
+        return reverse("base")
+
+    def get_signup_redirect_url(self, request):
+        return self.get_login_redirect_url(request)
+
+    def is_open_for_signup(self, request):
+        return getattr(settings, "LOGIN_ENABLE_SIGNUP", False)
+
+
+class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
+    def authentication_error(
+        self,
+        request,
+        provider_id,
+        error=None,
+        exception=None,
+        extra_context=None,
+    ):
+        logger.error(f"Authentication error: {exception}")
+        return super().authentication_error(
+            request,
+            provider_id,
+            error,
+            exception,
+            extra_context,
+        )
+
+    def is_auto_signup_allowed(self, *args, **kwargs):
+        if getattr(settings, "SSO_AUTO_LINK_MULTIPLE", True):
+            # Skip allauth default logic of checking for an existing user with
+            # the same email address. This requires paperless administrators to
+            # trust the SSO providers connected to paperless.
+            return True
+        return super().is_auto_signup_allowed(*args, **kwargs)
+
+    def is_open_for_signup(self, request, sociallogin):
+        # True indicates a user should be automatically created on successful
+        # login via configured external provider
+        return getattr(settings, "SSO_AUTO_LINK", True)
+
+
+base_url = reverse_lazy("base")
+urlpatterns = [
+    # Override allauth URLs to disable features we don't want
+    path("signup/", RedirectView.as_view(url=base_url)),
+    re_path("confirm-email/.*", RedirectView.as_view(url=base_url)),
+    re_path("email/.*", RedirectView.as_view(url=base_url)),
+    re_path("password/.*", RedirectView.as_view(url=base_url)),
+    # Import allauth-provided URL patterns
+    path("", include("allauth.urls")),
+]
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index d3c239b4..a2cba20b 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -18,6 +18,7 @@ from urllib.parse import urlparse
 
 from celery.schedules import crontab
 from concurrent_log_handler.queue import setup_logging_queues
+from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 from dotenv import load_dotenv
 
@@ -91,6 +92,22 @@ def __get_list(
         return []
 
 
+def __get_list(
+    key: str,
+    sep: str = ",",
+    default: Optional[List[str]] = None,
+) -> List[str]:
+    """
+    Return a list of strings based on the environment variable or an given default
+    list, if provided or an empty list
+    """
+    if key in os.environ:
+        os.getenv(key).split(sep)
+    elif default is not None:
+        return default
+    return []
+
+
 def _parse_redis_url(env_redis: Optional[str]) -> Tuple[str]:
     """
     Gets the Redis information from the environment or a default and handles
@@ -256,6 +273,40 @@ SCRATCH_DIR = __get_path(
     Path(tempfile.gettempdir()) / "paperless",
 )
 
+###############################################################################
+# SSO Configuration
+###############################################################################
+
+
+def _get_oidc_server() -> Optional[Dict]:
+    config_id = os.environ.get("PAPERLESS_SSO_OIDC_ID")
+    name = os.environ.get("PAPERLESS_SSO_OIDC_NAME")
+    url = os.environ.get("PAPERLESS_SSO_OIDC_URL")
+    client_id = os.environ.get("PAPERLESS_SSO_OIDC_CLIENT_ID")
+    secret = os.environ.get("PAPERLESS_SSO_OIDC_SECRET")
+    if name and url and client_id and secret:
+        return {
+            "id": config_id or slugify(name)[:30],
+            "name": name,
+            "server_url": url,
+            "APP": {
+                "client_id": client_id,
+                "secret": secret,
+            },
+        }
+    return None
+
+
+_allauth_provider_modules = set(__get_list("PAPERLESS_SSO_MODULES"))
+_oidc_server = _get_oidc_server()
+if _oidc_server:
+    _allauth_provider_modules.add("openid_connect")
+
+SSO_ENABLED = __get_boolean(
+    "PAPERLESS_SSO_ENABLED",
+    str(bool(_allauth_provider_modules)),
+)
+
 ###############################################################################
 # Application Definition                                                      #
 ###############################################################################
@@ -282,9 +333,18 @@ INSTALLED_APPS = [
     "django_filters",
     "django_celery_results",
     "guardian",
+    "allauth",
+    "allauth.account",
+    "allauth.socialaccount",
     *env_apps,
 ]
 
+if SSO_ENABLED:
+    INSTALLED_APPS += [
+        f"allauth.socialaccount.providers.{provider}"
+        for provider in _allauth_provider_modules
+    ]
+
 if DEBUG:
     INSTALLED_APPS.append("channels")
 
@@ -394,6 +454,10 @@ HTTP_REMOTE_USER_HEADER_NAME = os.getenv(
 if ENABLE_HTTP_REMOTE_USER:
     MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
     AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend")
+    if SSO_ENABLED:
+        AUTHENTICATION_BACKENDS.append(
+            "allauth.account.auth_backends.AuthenticationBackend",
+        )
     REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append(
         "rest_framework.authentication.RemoteUserAuthentication",
     )
@@ -949,3 +1013,53 @@ def _get_nltk_language_setting(ocr_lang: str) -> Optional[str]:
 NLTK_ENABLED: Final[bool] = __get_boolean("PAPERLESS_ENABLE_NLTK", "yes")
 
 NLTK_LANGUAGE: Optional[str] = _get_nltk_language_setting(OCR_LANGUAGE)
+
+
+###############################################################################
+# Single Sign-On (SSO)
+###############################################################################
+
+
+if SSO_ENABLED:
+    SSO_AUTO_LINK = __get_boolean("PAPERLESS_SSO_AUTO_LINK", "yes")
+    SSO_AUTO_LINK_MULTIPLE = __get_boolean(
+        "PAPERLESS_SSO_AUTO_LINK_MULTIPLE",
+        "yes",
+    )
+
+    # TODO This setting is unused and not part of django-allauth
+    SSO_SIGNUP_ONLY = __get_boolean(
+        "PAPERLESS_SSO_SIGNUP_ONLY",
+        "yes",
+    )
+    ACCOUNT_ADAPTER = "paperless.allauth_custom.CustomAccountAdapter"
+    ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
+    ACCOUNT_EMAIL_VERIFICATION = "none"
+    LOGIN_ENABLE_SIGNUP = __get_boolean("PAPERLESS_LOGIN_ENABLE_SIGNUP", "no")
+    LOGIN_HIDE_PASSWORD_FORM = __get_boolean(
+        "PAPERLESS_LOGIN_HIDE_PASSWORD_FORM",
+        "no",
+    )
+    ACCOUNT_LOGOUT_ON_GET = True
+
+    # Disable all allauth forms except for the login form
+    class AllauthFormsOverride(dict):
+        def get(self, key, default=None):
+            return super().get(key, "paperless.allauth_custom.raise_404")
+
+    ACCOUNT_FORMS = AllauthFormsOverride(
+        login="allauth.account.forms.LoginForm",
+    )
+
+    SOCIALACCOUNT_ADAPTER = "paperless.allauth_custom.CustomSocialAccountAdapter"
+    SOCIALACCOUNT_LOGIN_ON_GET = __get_boolean(
+        "PAPERLESS_SSO_LOGIN_ON_GET",
+        "no",
+    )
+    SOCIALACCOUNT_PROVIDERS = json.loads(
+        os.environ.get("PAPERLESS_SSO_PROVIDERS", "{}"),
+    )
+    if _oidc_server:
+        SOCIALACCOUNT_PROVIDERS.setdefault("openid_connect", {})
+        SOCIALACCOUNT_PROVIDERS["openid_connect"].setdefault("SERVERS", [])
+        SOCIALACCOUNT_PROVIDERS["openid_connect"]["SERVERS"] += [_oidc_server]
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index c2b72d7b..3a3bf342 100644
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -154,7 +154,14 @@ urlpatterns = [
     ),
     # TODO: with localization, this is even worse! :/
     # login, logout
-    path("accounts/", include("django.contrib.auth.urls")),
+    path(
+        "accounts/",
+        include(
+            "paperless.allauth_custom"
+            if settings.SSO_ENABLED
+            else "django.contrib.auth.urls",
+        ),
+    ),
     # Root of the Frontent
     re_path(r".*", login_required(IndexView.as_view()), name="base"),
 ]
-- 
2.40.1

