Fix: respect subpath in logout, share link and auto-login redirects (#14476)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 14:28:15 +00:00
1 parent 961f7fd672
commit 309a4b004d
8 files changed
+106 -7

No files matched your search

@@ -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
@@ -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 {
@@ -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')
@@ -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)
}
@@ -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:
+6 -4
View File
@@ -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,
+1 -1
View File
@@ -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(
@@ -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)