From 7942edfdf4fd4cf5e0f3df96725cdf795e389a31 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:07:12 -0700 Subject: [PATCH] Fixhancement: only offer basic auth for appropriate requests (#12362) --- .../auth-expiry.interceptor.spec.ts | 154 ++++++++++++++++++ .../interceptors/auth-expiry.interceptor.ts | 38 +++++ src-ui/src/main.ts | 6 + src/documents/tests/test_api_status.py | 7 + src/paperless/auth.py | 8 + 5 files changed, 213 insertions(+) create mode 100644 src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts create mode 100644 src-ui/src/app/interceptors/auth-expiry.interceptor.ts diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts new file mode 100644 index 000000000..9bc22ca15 --- /dev/null +++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts @@ -0,0 +1,154 @@ +import { HttpErrorResponse, HttpRequest } from '@angular/common/http' +import { TestBed } from '@angular/core/testing' +import { throwError } from 'rxjs' +import * as navUtils from '../utils/navigation' +import { AuthExpiryInterceptor } from './auth-expiry.interceptor' + +describe('AuthExpiryInterceptor', () => { + let interceptor: AuthExpiryInterceptor + let dateNowSpy: jest.SpiedFunction + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [AuthExpiryInterceptor], + }) + + interceptor = TestBed.inject(AuthExpiryInterceptor) + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('reloads when an API request returns 401', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/api/documents/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('does not reload for non-401 errors', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/api/documents/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 500, + url: '/api/documents/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('does not reload for non-api 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/accounts/profile/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/accounts/profile/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('reloads only once even with multiple API 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + const request = new HttpRequest('GET', '/api/documents/') + const handler = { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + } + + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('retries reload after cooldown for repeated API 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + dateNowSpy + .mockReturnValueOnce(1000) + .mockReturnValueOnce(2500) + .mockReturnValueOnce(3501) + + const request = new HttpRequest('GET', '/api/documents/') + const handler = { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + } + + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts new file mode 100644 index 000000000..3be9cc6e0 --- /dev/null +++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts @@ -0,0 +1,38 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http' +import { Injectable } from '@angular/core' +import { catchError, Observable, throwError } from 'rxjs' +import { locationReload } from '../utils/navigation' + +@Injectable() +export class AuthExpiryInterceptor implements HttpInterceptor { + private lastReloadAttempt = Number.NEGATIVE_INFINITY + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + return next.handle(request).pipe( + catchError((error: unknown) => { + if ( + error instanceof HttpErrorResponse && + error.status === 401 && + request.url.includes('/api/') + ) { + const now = Date.now() + if (now - this.lastReloadAttempt >= 2000) { + this.lastReloadAttempt = now + locationReload() + } + } + + return throwError(() => error) + }) + ) + } +} diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 7e57edcea..12fc46428 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -147,6 +147,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { PermissionsGuard } from './app/guards/permissions.guard' import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor' +import { AuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor' import { CsrfInterceptor } from './app/interceptors/csrf.interceptor' import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { FilterPipe } from './app/pipes/filter.pipe' @@ -390,6 +391,11 @@ bootstrapApplication(AppComponent, { useClass: ApiVersionInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthExpiryInterceptor, + multi: true, + }, FilterPipe, DocumentTitlePipe, { provide: NgbDateAdapter, useClass: ISODateAdapter }, diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index 9b7bf37ad..ec3f9e611 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -57,11 +57,18 @@ class TestSystemStatus(APITestCase): """ response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response["WWW-Authenticate"], "Token") normal_user = User.objects.create_user(username="normal_user") self.client.force_login(normal_user) response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_system_status_with_bad_basic_auth_challenges(self) -> None: + self.client.credentials(HTTP_AUTHORIZATION="Basic invalid") + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response["WWW-Authenticate"], 'Basic realm="api"') + def test_system_status_container_detection(self): """ GIVEN: diff --git a/src/paperless/auth.py b/src/paperless/auth.py index c68d63cf0..6857e1087 100644 --- a/src/paperless/auth.py +++ b/src/paperless/auth.py @@ -83,3 +83,11 @@ class PaperlessBasicAuthentication(authentication.BasicAuthentication): raise exceptions.AuthenticationFailed("MFA required") return user_tuple + + def authenticate_header(self, request): + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if auth_header.lower().startswith("basic "): + return super().authenticate_header(request) + + # Still 401 for anonymous API access + return authentication.TokenAuthentication.keyword