mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-26 22:49:01 -06:00
Enhancement: Add support for app oidc (#11756)
--------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
10
docs/api.md
10
docs/api.md
@@ -8,7 +8,7 @@ Further documentation is provided here for some endpoints and features.
|
|||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
The REST api provides four different forms of authentication.
|
The REST api provides five different forms of authentication.
|
||||||
|
|
||||||
1. Basic authentication
|
1. Basic authentication
|
||||||
|
|
||||||
@@ -52,6 +52,14 @@ The REST api provides four different forms of authentication.
|
|||||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
you can authenticate against the API using Remote User auth.
|
you can authenticate against the API using Remote User auth.
|
||||||
|
|
||||||
|
5. Headless OIDC via [`django-allauth`](https://codeberg.org/allauth/django-allauth)
|
||||||
|
|
||||||
|
`django-allauth` exposes API endpoints under `api/auth/` which enable tools
|
||||||
|
like third-party apps to authenticate with social accounts that are
|
||||||
|
configured. See
|
||||||
|
[here](advanced_usage.md#openid-connect-and-social-authentication) for more
|
||||||
|
information on social accounts.
|
||||||
|
|
||||||
## Searching for documents
|
## Searching for documents
|
||||||
|
|
||||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
|
from allauth.headless.tokens.sessions import SessionTokenStrategy
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from paperless.signals import handle_social_account_updated
|
from paperless.signals import handle_social_account_updated
|
||||||
@@ -159,3 +162,11 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
exception,
|
exception,
|
||||||
extra_context,
|
extra_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DrfTokenStrategy(SessionTokenStrategy):
|
||||||
|
def create_access_token(self, request: HttpRequest) -> str | None:
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return None
|
||||||
|
token, _ = Token.objects.get_or_create(user=request.user)
|
||||||
|
return token.key
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ INSTALLED_APPS = [
|
|||||||
"allauth.account",
|
"allauth.account",
|
||||||
"allauth.socialaccount",
|
"allauth.socialaccount",
|
||||||
"allauth.mfa",
|
"allauth.mfa",
|
||||||
|
"allauth.headless",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"drf_spectacular_sidecar",
|
"drf_spectacular_sidecar",
|
||||||
"treenode",
|
"treenode",
|
||||||
@@ -539,6 +540,7 @@ SOCIALACCOUNT_PROVIDERS = json.loads(
|
|||||||
)
|
)
|
||||||
SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
|
SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
|
||||||
SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
||||||
|
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
||||||
|
|
||||||
MFA_TOTP_ISSUER = "Paperless-ngx"
|
MFA_TOTP_ISSUER = "Paperless-ngx"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from allauth.account.adapter import get_adapter
|
|||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
@@ -11,6 +12,9 @@ from django.http import HttpRequest
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from paperless.adapter import DrfTokenStrategy
|
||||||
|
|
||||||
|
|
||||||
class TestCustomAccountAdapter(TestCase):
|
class TestCustomAccountAdapter(TestCase):
|
||||||
@@ -181,3 +185,74 @@ class TestCustomSocialAccountAdapter(TestCase):
|
|||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("Test authentication error" in message for message in log_cm.output),
|
any("Test authentication error" in message for message in log_cm.output),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrfTokenStrategy(TestCase):
|
||||||
|
def test_create_access_token_creates_new_token(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user with no existing DRF token
|
||||||
|
WHEN:
|
||||||
|
- create_access_token is called
|
||||||
|
THEN:
|
||||||
|
- A new token is created and its key is returned
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = User.objects.create_user("testuser")
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
strategy = DrfTokenStrategy()
|
||||||
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
|
# Verify a token was created
|
||||||
|
self.assertIsNotNone(token_key)
|
||||||
|
self.assertTrue(Token.objects.filter(user=user).exists())
|
||||||
|
|
||||||
|
# Verify the returned key matches the created token
|
||||||
|
token = Token.objects.get(user=user)
|
||||||
|
self.assertEqual(token_key, token.key)
|
||||||
|
|
||||||
|
def test_create_access_token_returns_existing_token(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user with an existing DRF token
|
||||||
|
WHEN:
|
||||||
|
- create_access_token is called again
|
||||||
|
THEN:
|
||||||
|
- The same token key is returned (no new token created)
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = User.objects.create_user("testuser")
|
||||||
|
existing_token = Token.objects.create(user=user)
|
||||||
|
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
strategy = DrfTokenStrategy()
|
||||||
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
|
# Verify the existing token key is returned
|
||||||
|
self.assertEqual(token_key, existing_token.key)
|
||||||
|
|
||||||
|
# Verify only one token exists (no duplicate created)
|
||||||
|
self.assertEqual(Token.objects.filter(user=user).count(), 1)
|
||||||
|
|
||||||
|
def test_create_access_token_returns_none_for_unauthenticated_user(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An unauthenticated request
|
||||||
|
WHEN:
|
||||||
|
- create_access_token is called
|
||||||
|
THEN:
|
||||||
|
- None is returned and no token is created
|
||||||
|
"""
|
||||||
|
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
|
strategy = DrfTokenStrategy()
|
||||||
|
token_key = strategy.create_access_token(request)
|
||||||
|
|
||||||
|
self.assertIsNone(token_key)
|
||||||
|
self.assertEqual(Token.objects.count(), 0)
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ urlpatterns = [
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
re_path("^auth/headless/", include("allauth.headless.urls")),
|
||||||
re_path(
|
re_path(
|
||||||
"^$", # Redirect to the API swagger view
|
"^$", # Redirect to the API swagger view
|
||||||
RedirectView.as_view(url="schema/view/"),
|
RedirectView.as_view(url="schema/view/"),
|
||||||
|
|||||||
Reference in New Issue
Block a user