diff --git a/Pipfile b/Pipfile index afc294412..6d5c7f861 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,7 @@ dateparser = "~=1.2" # WARNING: django does not use semver. # Only patch versions are guaranteed to not introduce breaking changes. django = "~=4.2.9" +django-allauth = "*" django-auditlog = "*" django-celery-results = "*" django-compression-middleware = "*" diff --git a/Pipfile.lock b/Pipfile.lock index ceef1c8a9..d177517c2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -452,6 +452,14 @@ "markers": "python_version >= '3.8'", "version": "==4.2.9" }, + "django-allauth": { + "hashes": [ + "sha256:ec19efb80b34d2f18bd831eab9b10b6301f58d1cce9f39af35f497b7e5b0a141" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==0.59.0" + }, "django-auditlog": { "hashes": [ "sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7", diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 04626fe41..46d9c2b4b 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -640,3 +640,42 @@ single-sided split marker page, the split document(s) will have an empty page at whatever else was on the backside of the split marker page.) You can work around that by having a split marker page that has the split barcode on _both_ sides. This way, the extra page will get automatically removed. + +## SSO and third party authentication with Paperless-ngx + +Paperless-ngx has a built-in authentication system from Django but you can easily integrate an +external authentication solution using one of the following methods: + +### Remote User authentication + +This is a simple option that uses remote user authentication made available by certain SSO +applications. See the relevant configuration options for more information: +[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and +[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) + +### OpenID Connect and social authentication + +Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via +the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users +can either log in or (optionally) sign up using any third party systems you integrate. See the +relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and +[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html) +for more information. + +As an example, to set up login via Github, the following environment variables would need to be +set: + +```conf +PAPERLESS_APPS="allauth.socialaccount.providers.github" +PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "","secret": ""}]}}' +``` + +Or, to use OpenID Connect ("OIDC"), via Keycloak in this example: + +```conf +PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect" +PAPERLESS_SOCIALACCOUNT_PROVIDERS=' +{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "","settings": { "server_url": "https:///realms//.well-known/openid-configuration"}}]}}' +``` + +More details about configuration option for various providers can be found in the allauth documentation: https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics diff --git a/docs/configuration.md b/docs/configuration.md index e99e0a085..3d1b1d1d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -535,6 +535,42 @@ This is for use with self-signed certificates against local IMAP servers. Settings this value has security implications for the security of your email. Understand what it does and be sure you need to before setting. +#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS} + +: This variable is used to setup login and signup via social account providers which are compatible with django-allauth. +See the corresponding [django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/providers/index.html) +for a list of provider configurations. You will also likely need to include the relevant Django 'application' inside the +[PAPERLESS_APPS](#PAPERLESS_APPS) setting. + + Defaults to None, which does not enable any third party authentication systems. + +#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP} + +: Attempt to signup the user using retrieved email, username etc from the third party authentication +system. See the corresponding +[django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/configuration.html) + + Defaults to False + +#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS} + +: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems. + + Defaults to True + +#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS} + +: Allow users to signup for a new Paperless-ngx account. + + Defaults to False + +#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL} + +: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding +[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html) + + Defaults to 'https' + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) @@ -905,6 +941,14 @@ documents. Default is none, which disables the temporary directory. +#### [`PAPERLESS_APPS=`](#PAPERLESS_APPS) {#PAPERLESS_APPS} + +: A comma-separated list of Django apps to be included in Django's +[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should +be used with caution! + + Defaults to None, which does not add any additional apps. + ## Document Consumption {#consume_config} #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 9f163e3b8..f2b356d9a 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -502,7 +502,7 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 55 + 92 src/app/components/document-detail/document-detail.component.html @@ -1563,7 +1563,7 @@ src/app/components/app-frame/app-frame.component.ts - 121 + 140 @@ -1938,7 +1938,7 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 145 + 159 @@ -2405,21 +2405,21 @@ Sidebar views updated src/app/components/app-frame/app-frame.component.ts - 263 + 282 Error updating sidebar views src/app/components/app-frame/app-frame.component.ts - 266 + 285 An error occurred while saving update checking settings. src/app/components/app-frame/app-frame.component.ts - 287 + 306 @@ -2523,7 +2523,7 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 54 + 91 src/app/components/common/select-dialog/select-dialog.component.html @@ -4103,39 +4103,88 @@ 50 + + Connected social accounts + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 54 + + + + Set a password before disconnecting social account. + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 58 + + + + Disconnect social account + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 68 + + + + Disconnect + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 69 + + + + Warning: disconnecting social accounts cannot be undone + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 74 + + + + Connect new social account + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 79 + + Emails must match src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 94 + 108 Passwords must match src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 122 + 136 Profile updated successfully src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 142 + 156 Error saving profile src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 154 + 168 Error generating auth token src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 171 + 185 + + + + Error disconnecting social account + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts + 210 diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index 64877bb09..e1a553047 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -21,6 +21,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { of, throwError } from 'rxjs' import { ToastService } from 'src/app/services/toast.service' +import { + DjangoMessageLevel, + DjangoMessagesService, +} from 'src/app/services/django-messages.service' import { environment } from 'src/environments/environment' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { ActivatedRoute, Router } from '@angular/router' @@ -83,6 +87,7 @@ describe('AppFrameComponent', () => { let permissionsService: PermissionsService let remoteVersionService: RemoteVersionService let toastService: ToastService + let messagesService: DjangoMessagesService let openDocumentsService: OpenDocumentsService let searchService: SearchService let documentListViewService: DocumentListViewService @@ -123,6 +128,7 @@ describe('AppFrameComponent', () => { RemoteVersionService, IfPermissionsDirective, ToastService, + DjangoMessagesService, OpenDocumentsService, SearchService, NgbModal, @@ -151,6 +157,7 @@ describe('AppFrameComponent', () => { permissionsService = TestBed.inject(PermissionsService) remoteVersionService = TestBed.inject(RemoteVersionService) toastService = TestBed.inject(ToastService) + messagesService = TestBed.inject(DjangoMessagesService) openDocumentsService = TestBed.inject(OpenDocumentsService) searchService = TestBed.inject(SearchService) documentListViewService = TestBed.inject(DocumentListViewService) @@ -393,4 +400,19 @@ describe('AppFrameComponent', () => { backdrop: 'static', }) }) + + it('should show toasts for django messages', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + jest.spyOn(messagesService, 'get').mockReturnValue([ + { level: DjangoMessageLevel.WARNING, message: 'Test warning' }, + { level: DjangoMessageLevel.ERROR, message: 'Test error' }, + { level: DjangoMessageLevel.SUCCESS, message: 'Test success' }, + { level: DjangoMessageLevel.INFO, message: 'Test info' }, + { level: DjangoMessageLevel.DEBUG, message: 'Test debug' }, + ]) + component.ngOnInit() + expect(toastErrorSpy).toHaveBeenCalledTimes(2) + expect(toastInfoSpy).toHaveBeenCalledTimes(3) + }) }) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts index cfc9740a4..ab9322380 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.ts @@ -12,6 +12,10 @@ import { } from 'rxjs/operators' import { Document } from 'src/app/data/document' import { OpenDocumentsService } from 'src/app/services/open-documents.service' +import { + DjangoMessageLevel, + DjangoMessagesService, +} from 'src/app/services/django-messages.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SearchService } from 'src/app/services/rest/search.service' import { environment } from 'src/environments/environment' @@ -73,7 +77,8 @@ export class AppFrameComponent public tasksService: TasksService, private readonly toastService: ToastService, private modalService: NgbModal, - permissionsService: PermissionsService + public permissionsService: PermissionsService, + private djangoMessagesService: DjangoMessagesService ) { super() @@ -92,6 +97,20 @@ export class AppFrameComponent this.checkForUpdates() } this.tasksService.reload() + + this.djangoMessagesService.get().forEach((message) => { + switch (message.level) { + case DjangoMessageLevel.ERROR: + case DjangoMessageLevel.WARNING: + this.toastService.showError(message.message) + break + case DjangoMessageLevel.SUCCESS: + case DjangoMessageLevel.INFO: + case DjangoMessageLevel.DEBUG: + this.toastService.showInfo(message.message) + break + } + }) } toggleSlimSidebar(): void { diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html index 394ba4449..6b06dfa8e 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html @@ -49,6 +49,43 @@
Warning: changing the token cannot be undone
+ @if (socialAccounts?.length > 0) { +
+

Connected social accounts

+
    + @for (account of socialAccounts; track account.id) { +
  • + {{account.name}} ({{account.provider}}) + +
  • + } +
+
Warning: disconnecting social accounts cannot be undone
+
+ } + @if (socialAccountProviders?.length > 0) { +
+

Connect new social account

+
+ @for (provider of socialAccountProviders; track provider.name) { + + {{provider.name}}  + + } +
+
+ } diff --git a/src/documents/templates/socialaccount/login.html b/src/documents/templates/socialaccount/login.html new file mode 100644 index 000000000..f135a77fe --- /dev/null +++ b/src/documents/templates/socialaccount/login.html @@ -0,0 +1,52 @@ + + +{% load static %} +{% load i18n %} +{% load allauth %} + + + + + + + + + + {% translate "Paperless-ngx social account sign in" %} + + + + + + +
+ +
+ + diff --git a/src/documents/templates/socialaccount/signup.html b/src/documents/templates/socialaccount/signup.html new file mode 100644 index 000000000..ef208d8ad --- /dev/null +++ b/src/documents/templates/socialaccount/signup.html @@ -0,0 +1,77 @@ + + +{% load static %} +{% load i18n %} + + + + + + + + + + {% translate "Paperless-ngx social account sign up" %} + + + + + + +
+ +
+ + diff --git a/src/documents/tests/test_api_profile.py b/src/documents/tests/test_api_profile.py index 9e12b1ed3..eede0d2b0 100644 --- a/src/documents/tests/test_api_profile.py +++ b/src/documents/tests/test_api_profile.py @@ -1,3 +1,7 @@ +from unittest import mock + +from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.models import SocialApp from django.contrib.auth.models import User from rest_framework import status from rest_framework.authtoken.models import Token @@ -6,6 +10,44 @@ from rest_framework.test import APITestCase from documents.tests.utils import DirectoriesMixin +# see allauth.socialaccount.providers.openid.provider.OpenIDProvider +class MockOpenIDProvider: + id = "openid" + name = "OpenID" + + def get_brands(self): + default_servers = [ + dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"), + dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"), + ] + return default_servers + + def get_login_url(self, request, **kwargs): + return "openid/login/" + + +# see allauth.socialaccount.providers.openid_connect.provider.OpenIDConnectProviderAccount +class MockOpenIDConnectProviderAccount: + def __init__(self, mock_social_account_dict): + self.account = mock_social_account_dict + + def to_str(self): + return self.account["name"] + + +# see allauth.socialaccount.providers.openid_connect.provider.OpenIDConnectProvider +class MockOpenIDConnectProvider: + id = "openid_connect" + name = "OpenID Connect" + + def __init__(self, app=None): + self.app = app + self.name = app.name + + def get_login_url(self, request, **kwargs): + return f"{self.app.provider_id}/login/?process=connect" + + class TestApiProfile(DirectoriesMixin, APITestCase): ENDPOINT = "/api/profile/" @@ -19,6 +61,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase): ) self.client.force_authenticate(user=self.user) + def setupSocialAccount(self): + SocialApp.objects.create( + name="Keycloak", + provider="openid_connect", + provider_id="keycloak-test", + ) + self.user.socialaccount_set.add( + SocialAccount(uid="123456789", provider="keycloak-test"), + bulk=False, + ) + def test_get_profile(self): """ GIVEN: @@ -28,7 +81,6 @@ class TestApiProfile(DirectoriesMixin, APITestCase): THEN: - Profile is returned """ - response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -37,6 +89,52 @@ class TestApiProfile(DirectoriesMixin, APITestCase): self.assertEqual(response.data["first_name"], self.user.first_name) self.assertEqual(response.data["last_name"], self.user.last_name) + @mock.patch( + "allauth.socialaccount.models.SocialAccount.get_provider_account", + ) + @mock.patch( + "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers", + ) + def test_get_profile_w_social(self, mock_list_providers, mock_get_provider_account): + """ + GIVEN: + - Configured user and setup social account + WHEN: + - API call is made to get profile + THEN: + - Profile is returned with social accounts + """ + self.setupSocialAccount() + + openid_provider = ( + MockOpenIDConnectProvider( + app=SocialApp.objects.get(provider_id="keycloak-test"), + ), + ) + mock_list_providers.return_value = [ + openid_provider, + ] + mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount( + mock_social_account_dict={ + "name": openid_provider[0].name, + }, + ) + + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual( + response.data["social_accounts"], + [ + { + "id": 1, + "provider": "keycloak-test", + "name": "Keycloak", + }, + ], + ) + def test_update_profile(self): """ GIVEN: @@ -103,3 +201,101 @@ class TestApiProfile(DirectoriesMixin, APITestCase): response = self.client.post(f"{self.ENDPOINT}generate_auth_token/") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @mock.patch( + "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers", + ) + def test_get_social_account_providers( + self, + mock_list_providers, + ): + """ + GIVEN: + - Configured user + WHEN: + - API call is made to get social account providers + THEN: + - Social account providers are returned + """ + self.setupSocialAccount() + + mock_list_providers.return_value = [ + MockOpenIDConnectProvider( + app=SocialApp.objects.get(provider_id="keycloak-test"), + ), + ] + + response = self.client.get(f"{self.ENDPOINT}social_account_providers/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data[0]["name"], + "Keycloak", + ) + self.assertIn( + "keycloak-test/login/?process=connect", + response.data[0]["login_url"], + ) + + @mock.patch( + "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers", + ) + def test_get_social_account_providers_openid( + self, + mock_list_providers, + ): + """ + GIVEN: + - Configured user and openid social account provider + WHEN: + - API call is made to get social account providers + THEN: + - Brands for openid provider are returned + """ + + mock_list_providers.return_value = [ + MockOpenIDProvider(), + ] + + response = self.client.get(f"{self.ENDPOINT}social_account_providers/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + len(response.data), + 2, + ) + + def test_disconnect_social_account(self): + """ + GIVEN: + - Configured user + WHEN: + - API call is made to disconnect a social account + THEN: + - Social account is deleted from the user or request fails + """ + self.setupSocialAccount() + + # Test with invalid id + response = self.client.post( + f"{self.ENDPOINT}disconnect_social_account/", + {"id": -1}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Test with valid id + social_account_id = self.user.socialaccount_set.all()[0].pk + + response = self.client.post( + f"{self.ENDPOINT}disconnect_social_account/", + {"id": social_account_id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, social_account_id) + + self.assertEqual( + len(self.user.socialaccount_set.filter(pk=social_account_id)), + 0, + ) diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 888572b58..226b89694 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -177,9 +177,9 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): os.path.join(self.dirs.media_dir, "documents"), ) - manifest = self._do_export(use_filename_format=use_filename_format) + num_permission_objects = Permission.objects.count() - self.assertEqual(len(manifest), 190) + manifest = self._do_export(use_filename_format=use_filename_format) # dont include consumer or AnonymousUser users self.assertEqual( @@ -273,7 +273,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec") self.assertEqual(GroupObjectPermission.objects.count(), 1) self.assertEqual(UserObjectPermission.objects.count(), 1) - self.assertEqual(Permission.objects.count(), 136) + self.assertEqual(Permission.objects.count(), num_permission_objects) messages = check_sanity() # everything is alright after the test self.assertEqual(len(messages), 0) @@ -753,15 +753,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): os.path.join(self.dirs.media_dir, "documents"), ) - self.assertEqual(ContentType.objects.count(), 34) - self.assertEqual(Permission.objects.count(), 136) + num_content_type_objects = ContentType.objects.count() + num_permission_objects = Permission.objects.count() manifest = self._do_export() with paperless_environment(): self.assertEqual( len(list(filter(lambda e: e["model"] == "auth.permission", manifest))), - 136, + num_permission_objects, ) # add 1 more to db to show objects are not re-created by import Permission.objects.create( @@ -769,7 +769,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): codename="test_perm", content_type_id=1, ) - self.assertEqual(Permission.objects.count(), 137) + self.assertEqual(Permission.objects.count(), num_permission_objects + 1) # will cause an import error self.user.delete() @@ -778,5 +778,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): with self.assertRaises(IntegrityError): call_command("document_importer", "--no-progress-bar", self.target) - self.assertEqual(ContentType.objects.count(), 34) - self.assertEqual(Permission.objects.count(), 137) + self.assertEqual(ContentType.objects.count(), num_content_type_objects) + self.assertEqual(Permission.objects.count(), num_permission_objects + 1) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 0c7242462..7ee0c0d8b 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-02-02 20:17-0800\n" +"POT-Creation-Date: 2024-02-07 06:20+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -777,15 +777,136 @@ msgstr "" msgid "Invalid color." msgstr "" -#: documents/serialisers.py:1049 +#: documents/serialisers.py:1060 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:1152 +#: documents/serialisers.py:1163 msgid "Invalid variable detected." msgstr "" +#: documents/templates/account/login.html:14 +msgid "Paperless-ngx sign in" +msgstr "" + +#: documents/templates/account/login.html:47 +msgid "Please sign in." +msgstr "" + +#: documents/templates/account/login.html:50 +msgid "Your username and password didn't match. Please try again." +msgstr "" + +#: documents/templates/account/login.html:54 +msgid "Share link was not found." +msgstr "" + +#: documents/templates/account/login.html:58 +msgid "Share link has expired." +msgstr "" + +#: documents/templates/account/login.html:61 +#: documents/templates/socialaccount/signup.html:56 +msgid "Username" +msgstr "" + +#: documents/templates/account/login.html:62 +msgid "Password" +msgstr "" + +#: documents/templates/account/login.html:72 +msgid "Sign in" +msgstr "" + +#: documents/templates/account/login.html:76 +msgid "Forgot your password?" +msgstr "" + +#: documents/templates/account/login.html:83 +msgid "or sign in via" +msgstr "" + +#: documents/templates/account/password_reset.html:15 +msgid "Paperless-ngx reset password request" +msgstr "" + +#: documents/templates/account/password_reset.html:43 +msgid "" +"Enter your email address below, and we'll email instructions for setting a " +"new one." +msgstr "" + +#: documents/templates/account/password_reset.html:46 +msgid "An error occurred. Please try again." +msgstr "" + +#: documents/templates/account/password_reset.html:49 +#: documents/templates/socialaccount/signup.html:57 +msgid "Email" +msgstr "" + +#: documents/templates/account/password_reset.html:56 +msgid "Send me instructions!" +msgstr "" + +#: documents/templates/account/password_reset_done.html:14 +msgid "Paperless-ngx reset password sent" +msgstr "" + +#: documents/templates/account/password_reset_done.html:40 +msgid "Check your inbox." +msgstr "" + +#: documents/templates/account/password_reset_done.html:41 +msgid "" +"We've emailed you instructions for setting your password. You should receive " +"the email shortly!" +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:15 +msgid "Paperless-ngx reset password confirmation" +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:44 +msgid "request a new password reset" +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:46 +msgid "Set a new password." +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:50 +msgid "Passwords did not match or too weak. Try again." +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:53 +msgid "New Password" +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:54 +msgid "Confirm Password" +msgstr "" + +#: documents/templates/account/password_reset_from_key.html:65 +msgid "Change my password" +msgstr "" + +#: documents/templates/account/password_reset_from_key_done.html:14 +msgid "Paperless-ngx reset password complete" +msgstr "" + +#: documents/templates/account/password_reset_from_key_done.html:40 +msgid "Password reset complete." +msgstr "" + +#: documents/templates/account/password_reset_from_key_done.html:42 +#, python-format +msgid "" +"Your new password has been set. You can now log " +"in" +msgstr "" + #: documents/templates/index.html:79 msgid "Paperless-ngx is loading..." msgstr "" @@ -798,131 +919,40 @@ msgstr "" msgid "Here's a link to the docs." msgstr "" -#: documents/templates/registration/logged_out.html:14 -msgid "Paperless-ngx signed out" +#: documents/templates/socialaccount/authentication_error.html:15 +#: documents/templates/socialaccount/login.html:15 +msgid "Paperless-ngx social account sign in" msgstr "" -#: documents/templates/registration/logged_out.html:40 -msgid "You have been successfully logged out. Bye!" -msgstr "" - -#: documents/templates/registration/logged_out.html:41 -msgid "Sign in again" -msgstr "" - -#: documents/templates/registration/login.html:14 -msgid "Paperless-ngx sign in" -msgstr "" - -#: documents/templates/registration/login.html:41 -msgid "Please sign in." -msgstr "" - -#: documents/templates/registration/login.html:44 -msgid "Your username and password didn't match. Please try again." -msgstr "" - -#: documents/templates/registration/login.html:48 -msgid "Share link was not found." -msgstr "" - -#: documents/templates/registration/login.html:52 -msgid "Share link has expired." -msgstr "" - -#: documents/templates/registration/login.html:55 -msgid "Username" -msgstr "" - -#: documents/templates/registration/login.html:56 -msgid "Password" -msgstr "" - -#: documents/templates/registration/login.html:66 -msgid "Sign in" -msgstr "" - -#: documents/templates/registration/login.html:70 -msgid "Forgot your password?" -msgstr "" - -#: documents/templates/registration/password_reset_complete.html:14 -msgid "Paperless-ngx reset password complete" -msgstr "" - -#: documents/templates/registration/password_reset_complete.html:40 -msgid "Password reset complete." -msgstr "" - -#: documents/templates/registration/password_reset_complete.html:42 +#: documents/templates/socialaccount/authentication_error.html:43 #, python-format msgid "" -"Your new password has been set. You can now log " -"in" +"An error occurred while attempting to login via your social network account. " +"Back to the login page" msgstr "" -#: documents/templates/registration/password_reset_confirm.html:14 -msgid "Paperless-ngx reset password confirmation" +#: documents/templates/socialaccount/login.html:44 +#, python-format +msgid "You are about to connect a new third-party account from %(provider)s." msgstr "" -#: documents/templates/registration/password_reset_confirm.html:42 -msgid "Set a new password." +#: documents/templates/socialaccount/login.html:47 +msgid "Continue" msgstr "" -#: documents/templates/registration/password_reset_confirm.html:46 -msgid "Passwords did not match or too weak. Try again." +#: documents/templates/socialaccount/signup.html:14 +msgid "Paperless-ngx social account sign up" msgstr "" -#: documents/templates/registration/password_reset_confirm.html:49 -msgid "New Password" -msgstr "" - -#: documents/templates/registration/password_reset_confirm.html:50 -msgid "Confirm Password" -msgstr "" - -#: documents/templates/registration/password_reset_confirm.html:61 -msgid "Change my password" -msgstr "" - -#: documents/templates/registration/password_reset_confirm.html:65 -msgid "request a new password reset" -msgstr "" - -#: documents/templates/registration/password_reset_done.html:14 -msgid "Paperless-ngx reset password sent" -msgstr "" - -#: documents/templates/registration/password_reset_done.html:40 -msgid "Check your inbox." -msgstr "" - -#: documents/templates/registration/password_reset_done.html:41 +#: documents/templates/socialaccount/signup.html:53 +#, python-format msgid "" -"We've emailed you instructions for setting your password. You should receive " -"the email shortly!" +"You are about to use your %(provider_name)s account to login to\n" +"%(site_name)s. As a final step, please complete the following form:" msgstr "" -#: documents/templates/registration/password_reset_form.html:14 -msgid "Paperless-ngx reset password request" -msgstr "" - -#: documents/templates/registration/password_reset_form.html:41 -msgid "" -"Enter your email address below, and we'll email instructions for setting a " -"new one." -msgstr "" - -#: documents/templates/registration/password_reset_form.html:44 -msgid "An error occurred. Please try again." -msgstr "" - -#: documents/templates/registration/password_reset_form.html:47 -msgid "Email" -msgstr "" - -#: documents/templates/registration/password_reset_form.html:54 -msgid "Send me instructions!" +#: documents/templates/socialaccount/signup.html:72 +msgid "Sign up" msgstr "" #: documents/validators.py:17 @@ -1088,135 +1118,135 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings.py:617 +#: paperless/settings.py:658 msgid "English (US)" msgstr "" -#: paperless/settings.py:618 +#: paperless/settings.py:659 msgid "Arabic" msgstr "" -#: paperless/settings.py:619 +#: paperless/settings.py:660 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:620 +#: paperless/settings.py:661 msgid "Belarusian" msgstr "" -#: paperless/settings.py:621 +#: paperless/settings.py:662 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:622 +#: paperless/settings.py:663 msgid "Catalan" msgstr "" -#: paperless/settings.py:623 +#: paperless/settings.py:664 msgid "Czech" msgstr "" -#: paperless/settings.py:624 +#: paperless/settings.py:665 msgid "Danish" msgstr "" -#: paperless/settings.py:625 +#: paperless/settings.py:666 msgid "German" msgstr "" -#: paperless/settings.py:626 +#: paperless/settings.py:667 msgid "Greek" msgstr "" -#: paperless/settings.py:627 +#: paperless/settings.py:668 msgid "English (GB)" msgstr "" -#: paperless/settings.py:628 +#: paperless/settings.py:669 msgid "Spanish" msgstr "" -#: paperless/settings.py:629 +#: paperless/settings.py:670 msgid "Finnish" msgstr "" -#: paperless/settings.py:630 +#: paperless/settings.py:671 msgid "French" msgstr "" -#: paperless/settings.py:631 +#: paperless/settings.py:672 msgid "Hungarian" msgstr "" -#: paperless/settings.py:632 +#: paperless/settings.py:673 msgid "Italian" msgstr "" -#: paperless/settings.py:633 +#: paperless/settings.py:674 msgid "Japanese" msgstr "" -#: paperless/settings.py:634 +#: paperless/settings.py:675 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:635 +#: paperless/settings.py:676 msgid "Norwegian" msgstr "" -#: paperless/settings.py:636 +#: paperless/settings.py:677 msgid "Dutch" msgstr "" -#: paperless/settings.py:637 +#: paperless/settings.py:678 msgid "Polish" msgstr "" -#: paperless/settings.py:638 +#: paperless/settings.py:679 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:639 +#: paperless/settings.py:680 msgid "Portuguese" msgstr "" -#: paperless/settings.py:640 +#: paperless/settings.py:681 msgid "Romanian" msgstr "" -#: paperless/settings.py:641 +#: paperless/settings.py:682 msgid "Russian" msgstr "" -#: paperless/settings.py:642 +#: paperless/settings.py:683 msgid "Slovak" msgstr "" -#: paperless/settings.py:643 +#: paperless/settings.py:684 msgid "Slovenian" msgstr "" -#: paperless/settings.py:644 +#: paperless/settings.py:685 msgid "Serbian" msgstr "" -#: paperless/settings.py:645 +#: paperless/settings.py:686 msgid "Swedish" msgstr "" -#: paperless/settings.py:646 +#: paperless/settings.py:687 msgid "Turkish" msgstr "" -#: paperless/settings.py:647 +#: paperless/settings.py:688 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:648 +#: paperless/settings.py:689 msgid "Chinese Simplified" msgstr "" -#: paperless/urls.py:214 +#: paperless/urls.py:224 msgid "Paperless-ngx administration" msgstr "" diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py new file mode 100644 index 000000000..98b0f11ba --- /dev/null +++ b/src/paperless/adapter.py @@ -0,0 +1,30 @@ +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings +from django.urls import reverse + + +class CustomAccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + allow_signups = super().is_open_for_signup(request) + # Override with setting, otherwise default to super. + return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups) + + +class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request, sociallogin): + allow_signups = super().is_open_for_signup(request, sociallogin) + # Override with setting, otherwise default to super. + return getattr(settings, "SOCIALACCOUNT_ALLOW_SIGNUPS", allow_signups) + + def get_connect_redirect_url(self, request, socialaccount): + """ + Returns the default URL to redirect to after successfully + connecting a social account. + """ + url = reverse("base") + return url + + def populate_user(self, request, sociallogin, data): + # TODO: If default global permissions are implemented, should also be here + return super().populate_user(request, sociallogin, data) # pragma: no cover diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index b724dd451..4c9ed5641 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -1,5 +1,6 @@ import logging +from allauth.socialaccount.models import SocialAccount from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User @@ -105,10 +106,30 @@ class GroupSerializer(serializers.ModelSerializer): ) +class SocialAccountSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + + class Meta: + model = SocialAccount + fields = ( + "id", + "provider", + "name", + ) + + def get_name(self, obj): + return obj.get_provider_account().to_str() + + class ProfileSerializer(serializers.ModelSerializer): email = serializers.EmailField(allow_null=False) password = ObfuscatedUserPasswordField(required=False, allow_null=False) auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key") + social_accounts = SocialAccountSerializer( + many=True, + read_only=True, + source="socialaccount_set", + ) class Meta: model = User @@ -118,6 +139,8 @@ class ProfileSerializer(serializers.ModelSerializer): "first_name", "last_name", "auth_token", + "social_accounts", + "has_usable_password", ) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index d485415ca..d51ba9020 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -303,6 +303,9 @@ INSTALLED_APPS = [ "django_filters", "django_celery_results", "guardian", + "allauth", + "allauth.account", + "allauth.socialaccount", *env_apps, ] @@ -339,6 +342,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", ] # Optional to enable compression @@ -350,6 +354,7 @@ ROOT_URLCONF = "paperless.urls" FORCE_SCRIPT_NAME = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME") BASE_URL = (FORCE_SCRIPT_NAME or "") + "/" LOGIN_URL = BASE_URL + "accounts/login/" +LOGIN_REDIRECT_URL = "/dashboard" LOGOUT_REDIRECT_URL = os.getenv("PAPERLESS_LOGOUT_REDIRECT_URL") WSGI_APPLICATION = "paperless.wsgi.application" @@ -410,8 +415,28 @@ CHANNEL_LAYERS = { AUTHENTICATION_BACKENDS = [ "guardian.backends.ObjectPermissionBackend", "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] +ACCOUNT_LOGOUT_ON_GET = True +ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv( + "PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL", + "https", +) + +ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter" +ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS") + +SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter" +SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean( + "PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS", + "yes", +) +SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP") +SOCIALACCOUNT_PROVIDERS = json.loads( + os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"), +) + AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME") if AUTO_LOGIN_USERNAME: diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py new file mode 100644 index 000000000..ca79cbce0 --- /dev/null +++ b/src/paperless/tests/test_adapter.py @@ -0,0 +1,43 @@ +from allauth.account.adapter import get_adapter +from allauth.socialaccount.adapter import get_adapter as get_social_adapter +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + + +class TestCustomAccountAdapter(TestCase): + def test_is_open_for_signup(self): + adapter = get_adapter() + + # Test when ACCOUNT_ALLOW_SIGNUPS is True + settings.ACCOUNT_ALLOW_SIGNUPS = True + self.assertTrue(adapter.is_open_for_signup(None)) + + # Test when ACCOUNT_ALLOW_SIGNUPS is False + settings.ACCOUNT_ALLOW_SIGNUPS = False + self.assertFalse(adapter.is_open_for_signup(None)) + + +class TestCustomSocialAccountAdapter(TestCase): + def test_is_open_for_signup(self): + adapter = get_social_adapter() + + # Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True + settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True + self.assertTrue(adapter.is_open_for_signup(None, None)) + + # Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False + settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False + self.assertFalse(adapter.is_open_for_signup(None, None)) + + def test_get_connect_redirect_url(self): + adapter = get_social_adapter() + request = None + socialaccount = None + + # Test the default URL + expected_url = reverse("base") + self.assertEqual( + adapter.get_connect_redirect_url(request, socialaccount), + expected_url, + ) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index d45a7bf22..74f6fc108 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -41,10 +41,12 @@ from documents.views import WorkflowTriggerViewSet from documents.views import WorkflowViewSet from paperless.consumers import StatusConsumer from paperless.views import ApplicationConfigurationViewSet +from paperless.views import DisconnectSocialAccountView from paperless.views import FaviconView from paperless.views import GenerateAuthTokenView from paperless.views import GroupViewSet from paperless.views import ProfileView +from paperless.views import SocialAccountProvidersView from paperless.views import UserViewSet from paperless_mail.views import MailAccountTestView from paperless_mail.views import MailAccountViewSet @@ -132,6 +134,14 @@ urlpatterns = [ name="bulk_edit_object_permissions", ), path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()), + path( + "profile/disconnect_social_account/", + DisconnectSocialAccountView.as_view(), + ), + path( + "profile/social_account_providers/", + SocialAccountProvidersView.as_view(), + ), re_path( "^profile/", ProfileView.as_view(), @@ -192,7 +202,7 @@ urlpatterns = [ ), # TODO: with localization, this is even worse! :/ # login, logout - path("accounts/", include("django.contrib.auth.urls")), + path("accounts/", include("allauth.urls")), # Root of the Frontend re_path( r".*", diff --git a/src/paperless/views.py b/src/paperless/views.py index 0f417b9ab..1151ceed5 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -1,10 +1,13 @@ import os from collections import OrderedDict +from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.models import SocialAccount from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.db.models.functions import Lower from django.http import HttpResponse +from django.http import HttpResponseBadRequest from django.views.generic import View from django_filters.rest_framework import DjangoFilterBackend from rest_framework.authtoken.models import Token @@ -14,6 +17,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet from documents.permissions import PaperlessObjectPermissions @@ -168,3 +172,54 @@ class ApplicationConfigurationViewSet(ModelViewSet): serializer_class = ApplicationConfigurationSerializer permission_classes = (IsAuthenticated, DjangoObjectPermissions) + + +class DisconnectSocialAccountView(GenericAPIView): + """ + Disconnects a social account provider from the user account + """ + + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + user = self.request.user + + try: + account = user.socialaccount_set.get(pk=request.data["id"]) + account_id = account.id + account.delete() + return Response(account_id) + except SocialAccount.DoesNotExist: + return HttpResponseBadRequest("Social account not found") + + +class SocialAccountProvidersView(APIView): + """ + List of social account providers + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + adapter = get_adapter() + providers = adapter.list_providers(request) + resp = [ + {"name": p.name, "login_url": p.get_login_url(request, process="connect")} + for p in providers + if p.id != "openid" + ] + + for openid_provider in filter(lambda p: p.id == "openid", providers): + resp += [ + { + "name": b["name"], + "login_url": openid_provider.get_login_url( + request, + process="connect", + openid=b["openid_url"], + ), + } + for b in openid_provider.get_brands() + ] + + return Response(sorted(resp, key=lambda p: p["name"]))