Compare commits

...

2 Commits

Author SHA1 Message Date
shamoon
a86c9d32fe Fix: fix file button hover color in dark mode (#12367) 2026-03-17 09:26:41 -07:00
shamoon
7942edfdf4 Fixhancement: only offer basic auth for appropriate requests (#12362) 2026-03-16 22:07:12 -07:00
6 changed files with 218 additions and 0 deletions

View File

@@ -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<typeof Date.now>
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)
})
})

View File

@@ -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<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
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)
})
)
}
}

View File

@@ -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 },

View File

@@ -153,6 +153,11 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
--bs-list-group-action-active-color: var(--bs-body-color);
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
}
.form-control:hover::file-selector-button {
background-color:var(--pngx-bg-dark) !important
}
.search-container {
input, input:focus, i-bs[name="search"] , ::placeholder {
color: var(--pngx-primary-text-contrast) !important;

View File

@@ -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:

View File

@@ -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