From 309a4b004d8a3ca8e1db1abcfa706e0f30929fb2 Mon Sep 17 00:00:00 2001 From: Jaime Velaz Dominguez Date: Sun, 18 Oct 2026 16:28:15 +0200 Subject: [PATCH] Fix: respect subpath in logout, share link and auto-login redirects (#14476) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .../users-groups.component.spec.ts | 25 +++++++++++++++ .../users-groups/users-groups.component.ts | 3 +- .../profile-edit-dialog.component.spec.ts | 32 +++++++++++++++++++ .../profile-edit-dialog.component.ts | 3 +- .../tests/test_share_link_bundles.py | 20 ++++++++++++ src/documents/views.py | 10 +++--- src/paperless/auth.py | 2 +- src/paperless/tests/test_auth_middleware.py | 18 +++++++++++ 8 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts index a06852994..0c6dea611 100644 --- a/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts @@ -154,6 +154,31 @@ describe('UsersAndGroupsComponent', () => { jest.useRealTimers() }) + it('should logout current user under base href if password changed', () => { + jest.useFakeTimers() + const base = document.createElement('base') + base.href = '/paperless/' + document.head.appendChild(base) + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.editUser(users[0]) + const navSpy = jest + .spyOn(navUtils, 'setLocationHref') + .mockImplementation(() => {}) + const editDialog = modal.componentInstance as UserEditDialogComponent + editDialog.passwordIsSet = true + settingsService.currentUser.set(users[0]) // simulate logged in as same user + editDialog.succeeded.emit(users[0]) + fixture.detectChanges() + jest.advanceTimersByTime(2600) + expect(navSpy).toHaveBeenCalledWith( + `${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/` + ) + base.remove() + jest.useRealTimers() + }) + it('should support edit / create group, show error if needed', () => { completeSetup() let modal: NgbModalRef diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts index b7eea7ff0..5430b9e2a 100644 --- a/src-ui/src/app/components/admin/users-groups/users-groups.component.ts +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts @@ -116,8 +116,9 @@ export class UsersAndGroupsComponent $localize`Password has been changed, you will be logged out momentarily.` ) setTimeout(() => { + const basePath = new URL(document.baseURI).pathname setLocationHref( - `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` + `${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}` ) }, 2500) } else { diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts index 8c5ecddd1..be704527b 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts @@ -218,6 +218,38 @@ describe('ProfileEditDialogComponent', () => { jest.useRealTimers() }) + it('should logout under base href on save if password changed', () => { + jest.useFakeTimers() + const base = document.createElement('base') + base.href = '/paperless/' + document.head.appendChild(base) + const getSpy = jest.spyOn(profileService, 'get') + getSpy.mockReturnValue(of(profile)) + const getProvidersSpy = jest.spyOn( + profileService, + 'getSocialAccountProviders' + ) + getProvidersSpy.mockReturnValue(of(socialAccountProviders)) + component.ngOnInit() + component['newPassword'] = 'new*pass' + component.form.get('password').patchValue('new*pass') + component.form.get('password_confirm').patchValue('new*pass') + + const updateSpy = jest.spyOn(profileService, 'update') + updateSpy.mockReturnValue(of(null)) + const navSpy = jest + .spyOn(navUtils, 'setLocationHref') + .mockImplementation(() => {}) + component.save() + expect(updateSpy).toHaveBeenCalled() + jest.advanceTimersByTime(2600) + expect(navSpy).toHaveBeenCalledWith( + `${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/` + ) + base.remove() + jest.useRealTimers() + }) + it('should support auth token copy', () => { jest.useFakeTimers() const getSpy = jest.spyOn(profileService, 'get') diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts index 2f1515ad3..6c9ce6c72 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts @@ -210,8 +210,9 @@ export class ProfileEditDialogComponent $localize`Password has been changed, you will be logged out momentarily.` ) setTimeout(() => { + const basePath = new URL(document.baseURI).pathname setLocationHref( - `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` + `${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}` ) }, 2500) } diff --git a/src/documents/tests/test_share_link_bundles.py b/src/documents/tests/test_share_link_bundles.py index 3c1320c9f..6bf064c27 100644 --- a/src/documents/tests/test_share_link_bundles.py +++ b/src/documents/tests/test_share_link_bundles.py @@ -7,6 +7,7 @@ from unittest import mock from django.conf import settings from django.contrib.auth.models import User +from django.test import override_settings from django.utils import timezone from rest_framework import serializers from rest_framework import status @@ -225,6 +226,25 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertIn("sharelink_notfound=1", response["Location"]) + @override_settings(LOGIN_URL="/paperless/accounts/login/") + def test_unknown_share_link_redirects_under_subpath(self) -> None: + """ + GIVEN: + - Paperless is served under a subpath + WHEN: + - An unknown public share link is requested + THEN: + - The redirect to the login page keeps the subpath + """ + self.client.logout() + response = self.client.get("/share/unknownsharelink/") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual( + response["Location"], + "/paperless/accounts/login/?sharelink_notfound=1", + ) + def test_share_link_missing_file_redirects(self) -> None: """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index 30d790a07..aaeec8ade 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -4911,7 +4911,7 @@ class SharedLinkView(View): share_link.expiration is not None and share_link.expiration < timezone.now() ): - return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") + return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1") try: return serve_file( doc=share_link.document, @@ -4920,14 +4920,16 @@ class SharedLinkView(View): disposition="inline", ) except FileNotFoundError: - return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1") + return HttpResponseRedirect( + f"{settings.LOGIN_URL}?sharelink_notfound=1", + ) bundle = ShareLinkBundle.objects.filter(slug=slug).first() if bundle is None: - return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1") + return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_notfound=1") if bundle.expiration is not None and bundle.expiration < timezone.now(): - return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") + return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1") if bundle.status in { ShareLinkBundle.Status.PENDING, diff --git a/src/paperless/auth.py b/src/paperless/auth.py index e9a49b7b7..898b1737b 100644 --- a/src/paperless/auth.py +++ b/src/paperless/auth.py @@ -16,7 +16,7 @@ logger = logging.getLogger("paperless.auth") class AutoLoginMiddleware(MiddlewareMixin): def process_request(self, request: HttpRequest) -> None: # Dont use auto-login with token request - if request.path.startswith("/api/token/") and request.method == "POST": + if request.path_info.startswith("/api/token/") and request.method == "POST": return None try: request.user = User.objects.get( diff --git a/src/paperless/tests/test_auth_middleware.py b/src/paperless/tests/test_auth_middleware.py index a6b6a783a..94ad5d730 100644 --- a/src/paperless/tests/test_auth_middleware.py +++ b/src/paperless/tests/test_auth_middleware.py @@ -51,3 +51,21 @@ class TestAutoLoginMiddleware(TestCase): self._process(request) self.assertFalse(request.user.is_authenticated) + + @override_settings(FORCE_SCRIPT_NAME="/paperless") + def test_token_request_is_not_logged_in_under_subpath(self) -> None: + """ + GIVEN: + - Paperless is served under a subpath + WHEN: + - A token is requested via POST + THEN: + - The request is left anonymous so the posted credentials are used + """ + UserFactory(username="autologin") + + request = self.factory.post("/api/token/") + request.user = AnonymousUser() + self._process(request) + + self.assertFalse(request.user.is_authenticated)