Compare commits
39 Commits
74d91eb4b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
60ce69e115
|
|||
|
d6d0735ff8
|
|||
|
72088dba9a
|
|||
|
6f1fffd6e8
|
|||
|
490cbbb812
|
|||
|
4fe22e3539
|
|||
|
3f7cdee995
|
|||
|
1a04b23e89
|
|||
|
2a5dfc3713
|
|||
|
1cd7d6541d
|
|||
|
ec6a20ebd1
|
|||
|
83d6a4f367
|
|||
|
8cf3748015
|
|||
|
daa11cb768
|
|||
|
8f2c357bfc
|
|||
|
d50169b883
|
|||
|
b5b74845f2
|
|||
|
0acce2e260
|
|||
|
b86223f943
|
|||
|
8dc4013e76
|
|||
|
668c22f692
|
|||
|
89ec3584f9
|
|||
|
8dded6383e
|
|||
|
c47fc48533
|
|||
|
b6d470590e
|
|||
|
41bbe87b4c
|
|||
|
6fba581865
|
|||
|
4b34d6153c
|
|||
|
700f0d6d79
|
|||
|
3cccf2e0e8
|
|||
|
26eae1a09b
|
|||
|
a9333ec973
|
|||
|
8eaaa01186
|
|||
|
eae7afd36e
|
|||
|
874597e40b
|
|||
|
32b4589b28
|
|||
|
4c27fd6483
|
|||
|
9cbbd80f47
|
|||
|
aba320b617
|
21
.env.example
21
.env.example
@@ -4,6 +4,9 @@
|
|||||||
# Development defaults (HTTP local stack)
|
# Development defaults (HTTP local stack)
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
HOST_BIND_IP=127.0.0.1
|
HOST_BIND_IP=127.0.0.1
|
||||||
|
# Optional host directory for persistent bind mounts in docker-compose.yml.
|
||||||
|
# Defaults to ./data when unset.
|
||||||
|
# DCM_DATA_DIR=./data
|
||||||
|
|
||||||
POSTGRES_USER=dcm
|
POSTGRES_USER=dcm
|
||||||
POSTGRES_PASSWORD=ChangeMe-Postgres-Secret
|
POSTGRES_PASSWORD=ChangeMe-Postgres-Secret
|
||||||
@@ -19,6 +22,15 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
|
|||||||
AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password
|
AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password
|
||||||
AUTH_BOOTSTRAP_USER_USERNAME=user
|
AUTH_BOOTSTRAP_USER_USERNAME=user
|
||||||
AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password
|
AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password
|
||||||
|
AUTH_LOGIN_FAILURE_LIMIT=5
|
||||||
|
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
|
||||||
|
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
|
||||||
|
# Optional cookie controls for split frontend/api hosts:
|
||||||
|
# Leave AUTH_COOKIE_DOMAIN empty unless you explicitly need a parent-domain CSRF cookie mirror.
|
||||||
|
# Host-only auth cookies are issued automatically for the API host.
|
||||||
|
# AUTH_COOKIE_DOMAIN=docs.lan
|
||||||
|
# AUTH_COOKIE_SAMESITE=auto
|
||||||
|
|
||||||
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
|
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
|
||||||
TYPESENSE_API_KEY=ChangeMe-Typesense-Key
|
TYPESENSE_API_KEY=ChangeMe-Typesense-Key
|
||||||
@@ -35,7 +47,13 @@ PROVIDER_BASE_URL_ALLOWLIST=[]
|
|||||||
|
|
||||||
PUBLIC_BASE_URL=http://localhost:8000
|
PUBLIC_BASE_URL=http://localhost:8000
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
# Leave empty to use same-origin /api/v1 through the frontend proxy.
|
||||||
|
# Set an absolute URL only when you intentionally want split-origin frontend/API traffic.
|
||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
|
# Development-only Vite proxy target. Docker compose sets this to http://api:8000 automatically.
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||||
|
# Development-only Vite host allowlist override.
|
||||||
|
VITE_ALLOWED_HOSTS=
|
||||||
|
|
||||||
# Production baseline overrides (set explicitly for live deployments):
|
# Production baseline overrides (set explicitly for live deployments):
|
||||||
# APP_ENV=production
|
# APP_ENV=production
|
||||||
@@ -43,9 +61,12 @@ VITE_API_BASE=
|
|||||||
# REDIS_URL=rediss://:<strong-password>@redis.example.internal:6379/0
|
# REDIS_URL=rediss://:<strong-password>@redis.example.internal:6379/0
|
||||||
# REDIS_SECURITY_MODE=strict
|
# REDIS_SECURITY_MODE=strict
|
||||||
# REDIS_TLS_MODE=required
|
# REDIS_TLS_MODE=required
|
||||||
|
# AUTH_COOKIE_DOMAIN=example.com
|
||||||
|
# AUTH_COOKIE_SAMESITE=none
|
||||||
# PROVIDER_BASE_URL_ALLOW_HTTP=false
|
# PROVIDER_BASE_URL_ALLOW_HTTP=false
|
||||||
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
|
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
|
||||||
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]
|
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]
|
||||||
# PUBLIC_BASE_URL=https://api.example.com
|
# PUBLIC_BASE_URL=https://api.example.com
|
||||||
# CORS_ORIGINS=["https://app.example.com"]
|
# CORS_ORIGINS=["https://app.example.com"]
|
||||||
# VITE_API_BASE=https://api.example.com/api/v1
|
# VITE_API_BASE=https://api.example.com/api/v1
|
||||||
|
# VITE_ALLOWED_HOSTS=app.example.com
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,9 +20,8 @@ build/
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Data and generated artifacts (runtime only)
|
# Data and generated artifacts (runtime only)
|
||||||
data/postgres/
|
data/
|
||||||
data/redis/
|
typesense-data/
|
||||||
data/storage/
|
|
||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Stack Snapshot
|
## Stack Snapshot
|
||||||
- DMS monorepo with FastAPI API + RQ worker (`backend/`) and React + Vite + TypeScript frontend (`frontend/`).
|
- DMS monorepo with FastAPI API + RQ worker (`backend/`) and React + Vite + TypeScript frontend (`frontend/`).
|
||||||
- Services in `docker-compose.yml`: `api`, `worker`, `frontend`, `db` (Postgres), `redis`, `typesense`.
|
- Services in `docker-compose.yml`: `api`, `worker`, `frontend`, `db` (Postgres), `redis`, `typesense`.
|
||||||
- Runtime persistence uses Docker named volumes (`db-data`, `redis-data`, `dcm-storage`, `typesense-data`).
|
- Runtime persistence uses host bind mounts under `${DCM_DATA_DIR:-./data}` (`db-data`, `redis-data`, `storage`, `typesense-data`).
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
- Backend app code: `backend/app/` (`api/`, `services/`, `db/`, `models/`, `schemas/`, `worker/`).
|
- Backend app code: `backend/app/` (`api/`, `services/`, `db/`, `models/`, `schemas/`, `worker/`).
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -3,18 +3,5 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initialized `CHANGELOG.md` with Keep a Changelog structure for ongoing release-note tracking.
|
- Initial release
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Refreshed `README.md` with current stack details, runtime services, setup commands, configuration notes, and manual validation guidance.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|||||||
211
README.md
211
README.md
@@ -1,79 +1,91 @@
|
|||||||
# LedgerDock
|
# LedgerDock
|
||||||
|
|
||||||
LedgerDock is a self-hosted document management system (DMS) for ingesting, processing, organizing, and searching files.
|
LedgerDock is a private document workspace you can run on your own computer or server.
|
||||||
|
It helps teams collect files, process text from documents, and find information quickly with search.
|
||||||
|
|
||||||
## Core Capabilities
|
## What LedgerDock Is For
|
||||||
|
|
||||||
- Drag and drop upload from anywhere in the UI
|
- Upload files and folders from one place
|
||||||
- File and folder upload with path preservation
|
- Keep documents organized and searchable
|
||||||
- Asynchronous extraction and OCR for PDF, images, DOCX, XLSX, TXT, and ZIP
|
- Extract text from scans and images (OCR)
|
||||||
- Metadata and full-text search
|
- Download originals or extracted text
|
||||||
- Routing suggestions based on previous decisions
|
|
||||||
- Original file download and extracted markdown export
|
|
||||||
|
|
||||||
## Technology Stack
|
## Before You Start
|
||||||
|
|
||||||
- Backend: FastAPI, SQLAlchemy, RQ worker (`backend/`)
|
You need:
|
||||||
- Frontend: React, Vite, TypeScript (`frontend/`)
|
|
||||||
- Infrastructure: PostgreSQL, Redis, Typesense (`docker-compose.yml`)
|
|
||||||
|
|
||||||
## Runtime Services
|
- Docker Desktop (Windows or macOS) or Docker Engine + Docker Compose (Linux)
|
||||||
|
- A terminal app
|
||||||
|
- The project folder on your machine
|
||||||
|
- Internet access the first time you build containers
|
||||||
|
|
||||||
The default `docker compose` stack includes:
|
## Install With Docker Compose
|
||||||
|
|
||||||
- `frontend` - React UI (`http://localhost:5173`)
|
Follow these steps from the project folder (where `docker-compose.yml` is located).
|
||||||
- `api` - FastAPI backend (`http://localhost:8000`, docs at `/docs`)
|
|
||||||
- `worker` - background processing jobs
|
|
||||||
- `db` - PostgreSQL (internal service network)
|
|
||||||
- `redis` - queue backend (internal service network)
|
|
||||||
- `typesense` - search index (internal service network)
|
|
||||||
|
|
||||||
## Requirements
|
1. Create your local settings file from the template.
|
||||||
|
|
||||||
- Docker Engine
|
```bash
|
||||||
- Docker Compose plugin
|
cp .env.example .env
|
||||||
- Internet access for first-time image build
|
```
|
||||||
|
|
||||||
## Quick Start
|
2. Open `.env` in a text editor and set your own passwords and keys.
|
||||||
|
3. Start LedgerDock.
|
||||||
From repository root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Before first run, set required secrets and connection values in `.env` (or your shell):
|
4. Wait until startup is complete, then open the app:
|
||||||
|
- LedgerDock web app: `http://localhost:5173`
|
||||||
|
- Health check: `http://localhost:8000/api/v1/health`
|
||||||
|
5. Sign in with the admin username and password you set in `.env`.
|
||||||
|
|
||||||
- `POSTGRES_USER`
|
## `.env` Settings Explained In Plain Language
|
||||||
- `POSTGRES_PASSWORD`
|
|
||||||
- `POSTGRES_DB`
|
|
||||||
- `DATABASE_URL`
|
|
||||||
- `REDIS_PASSWORD`
|
|
||||||
- `REDIS_URL`
|
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
|
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
|
|
||||||
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
|
|
||||||
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
|
|
||||||
- `APP_SETTINGS_ENCRYPTION_KEY`
|
|
||||||
- `TYPESENSE_API_KEY`
|
|
||||||
|
|
||||||
Start from `.env.example` to avoid missing required variables.
|
LedgerDock reads settings from `.env`. Some values are required and some are optional.
|
||||||
|
|
||||||
Open:
|
### Required: Change These Before First Use
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
- `POSTGRES_PASSWORD`: Password for the internal database.
|
||||||
- API docs: `http://localhost:8000/docs`
|
- `REDIS_PASSWORD`: Password for the internal queue service.
|
||||||
- Health: `http://localhost:8000/api/v1/health`
|
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`: First admin login password.
|
||||||
|
- `APP_SETTINGS_ENCRYPTION_KEY`: Secret used to protect saved app settings.
|
||||||
|
- `TYPESENSE_API_KEY`: Secret key for the search engine.
|
||||||
|
|
||||||
Use bootstrap credentials (`AUTH_BOOTSTRAP_ADMIN_USERNAME` and `AUTH_BOOTSTRAP_ADMIN_PASSWORD`) to sign in from the frontend login screen.
|
Use long, unique values for each one. Do not reuse personal passwords.
|
||||||
|
|
||||||
Stop the stack:
|
### Required: Usually Keep Defaults Unless You Know You Need Changes
|
||||||
|
|
||||||
```bash
|
- `POSTGRES_USER`: Database username.
|
||||||
docker compose down
|
- `POSTGRES_DB`: Database name.
|
||||||
```
|
- `DATABASE_URL`: Connection string to the database service.
|
||||||
|
- `REDIS_URL`: Connection string to the Redis service.
|
||||||
|
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`: First admin username (default `admin`).
|
||||||
|
|
||||||
## Common Operations
|
If you change passwords, make sure matching URLs use the same new password.
|
||||||
|
|
||||||
|
### Optional User Account (Can Be Left Empty)
|
||||||
|
|
||||||
|
- `AUTH_BOOTSTRAP_USER_USERNAME`
|
||||||
|
- `AUTH_BOOTSTRAP_USER_PASSWORD`
|
||||||
|
|
||||||
|
These create an extra non-admin account on first startup.
|
||||||
|
|
||||||
|
### Network and Access Settings
|
||||||
|
|
||||||
|
- `HOST_BIND_IP`: Where services listen. Keep `127.0.0.1` for local-only access.
|
||||||
|
- `PUBLIC_BASE_URL`: Backend base URL. Local default is `http://localhost:8000`.
|
||||||
|
- `CORS_ORIGINS`: Allowed frontend origins. Keep local defaults for single-machine use.
|
||||||
|
- `VITE_API_BASE`: Frontend API URL override. Leave empty unless you know you need it.
|
||||||
|
|
||||||
|
### Environment Mode
|
||||||
|
|
||||||
|
- `APP_ENV=development`: Local mode (default).
|
||||||
|
- `APP_ENV=production`: Use when running as a real shared deployment with HTTPS and tighter security settings.
|
||||||
|
- Frontend runtime switches to a static build served by Nginx in this mode.
|
||||||
|
|
||||||
|
## Daily Use Commands
|
||||||
|
|
||||||
Start or rebuild:
|
Start or rebuild:
|
||||||
|
|
||||||
@@ -87,97 +99,50 @@ Stop:
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Tail logs:
|
View logs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
Tail API and worker logs only:
|
View backend logs only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f api worker
|
docker compose logs -f api worker
|
||||||
```
|
```
|
||||||
|
|
||||||
Reset all runtime data (destructive):
|
## Where Your Data Is Stored
|
||||||
|
|
||||||
|
LedgerDock stores persistent runtime data in host bind mounts. By default the host root is `./data`, or set `DCM_DATA_DIR` to move it:
|
||||||
|
|
||||||
|
- `${DCM_DATA_DIR:-./data}/db-data` for PostgreSQL data
|
||||||
|
- `${DCM_DATA_DIR:-./data}/redis-data` for Redis data
|
||||||
|
- `${DCM_DATA_DIR:-./data}/storage` for uploaded files and app storage
|
||||||
|
- `${DCM_DATA_DIR:-./data}/typesense-data` for the search index
|
||||||
|
|
||||||
|
On startup, Compose runs a one-shot `storage-init` service that creates the storage tree and applies write access for the backend runtime user `uid=10001`. If you want to inspect or repair it manually, use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
mkdir -p ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chown -R 10001:10001 ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chmod -R u+rwX,g+rwX ${DCM_DATA_DIR:-./data}/storage
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend-Only Local Workflow
|
To remove everything, including data:
|
||||||
|
|
||||||
If backend services are already running, you can run frontend tooling locally:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm run dev
|
docker compose down
|
||||||
cd frontend && npm run build
|
rm -rf ${DCM_DATA_DIR:-./data}
|
||||||
cd frontend && npm run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run preview` serves the built app on port `4173`.
|
Warning: this permanently deletes your LedgerDock data on this machine.
|
||||||
|
|
||||||
## Configuration
|
## First Checks After Install
|
||||||
|
|
||||||
Main runtime variables are defined in `docker-compose.yml`:
|
- Open `http://localhost:5173` and confirm the login page appears.
|
||||||
|
- Open `http://localhost:8000/api/v1/health` and confirm you get `{"status":"ok"}`.
|
||||||
|
- Upload one sample file and confirm it appears in search.
|
||||||
|
|
||||||
- API and worker: `DATABASE_URL`, `REDIS_URL`, `REDIS_SECURITY_MODE`, `REDIS_TLS_MODE`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `AUTH_BOOTSTRAP_*`, `PROCESSING_LOG_STORE_*`, `CONTENT_EXPORT_*`, `TYPESENSE_*`, `APP_SETTINGS_ENCRYPTION_KEY`
|
## Need Technical Documentation?
|
||||||
- Frontend: optional `VITE_API_BASE`
|
|
||||||
|
|
||||||
When `VITE_API_BASE` is unset, the frontend uses `http://<current-hostname>:8000/api/v1`.
|
Developer and operator docs are in `doc/`, starting at `doc/README.md`.
|
||||||
|
|
||||||
Application settings saved from the UI persist at:
|
|
||||||
|
|
||||||
- `<STORAGE_ROOT>/settings.json` (inside the storage volume)
|
|
||||||
|
|
||||||
Provider API keys are persisted encrypted at rest (`api_key_encrypted`) and are no longer written as plaintext values.
|
|
||||||
|
|
||||||
Settings endpoints:
|
|
||||||
|
|
||||||
- `GET/PATCH /api/v1/settings`
|
|
||||||
- `POST /api/v1/settings/reset`
|
|
||||||
- `PATCH /api/v1/settings/handwriting`
|
|
||||||
- `POST /api/v1/processing/logs/trim` (admin only)
|
|
||||||
|
|
||||||
Auth endpoints:
|
|
||||||
|
|
||||||
- `POST /api/v1/auth/login`
|
|
||||||
- `GET /api/v1/auth/me`
|
|
||||||
- `POST /api/v1/auth/logout`
|
|
||||||
|
|
||||||
Detailed DEV and LIVE environment guidance, including HTTPS reverse-proxy deployment values, is documented in `doc/operations-and-configuration.md` and `.env.example`.
|
|
||||||
|
|
||||||
## Data Persistence
|
|
||||||
|
|
||||||
Docker named volumes used by the stack:
|
|
||||||
|
|
||||||
- `db-data`
|
|
||||||
- `redis-data`
|
|
||||||
- `dcm-storage`
|
|
||||||
- `typesense-data`
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
After setup or config changes, verify:
|
|
||||||
|
|
||||||
- `GET /api/v1/health` returns `{"status":"ok"}`
|
|
||||||
- Upload and processing complete successfully
|
|
||||||
- Search returns expected results
|
|
||||||
- Preview and download work for uploaded documents
|
|
||||||
- `docker compose logs -f api worker` has no failures
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
- `backend/` - FastAPI API, services, models, worker
|
|
||||||
- `frontend/` - React application
|
|
||||||
- `doc/` - technical documentation for architecture, API, data model, and operations
|
|
||||||
- `docker-compose.yml` - local runtime topology
|
|
||||||
|
|
||||||
## Documentation Index
|
|
||||||
|
|
||||||
- `doc/README.md` - technical documentation entrypoint
|
|
||||||
- `doc/architecture-overview.md` - service and runtime architecture
|
|
||||||
- `doc/api-contract.md` - endpoint and payload contract
|
|
||||||
- `doc/data-model-reference.md` - persistence model reference
|
|
||||||
- `doc/operations-and-configuration.md` - runtime operations and configuration
|
|
||||||
- `doc/frontend-design-foundation.md` - frontend design rules
|
|
||||||
|
|||||||
149
REPORT.md
149
REPORT.md
@@ -1,149 +0,0 @@
|
|||||||
# Security Production Readiness Report
|
|
||||||
|
|
||||||
Date: 2026-03-01
|
|
||||||
Repository: /Users/bedas/Developer/GitHub/dcm
|
|
||||||
Review type: Static code and configuration review (no runtime penetration testing)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- Backend API and worker: `backend/app`
|
|
||||||
- Frontend API client/auth transport: `frontend/src`
|
|
||||||
- Compose and environment defaults: `docker-compose.yml`, `.env`
|
|
||||||
|
|
||||||
## Method and Limits
|
|
||||||
- Reviewed source and configuration files in the current checkout.
|
|
||||||
- Verified findings with direct file evidence.
|
|
||||||
- Did not run dynamic security testing, dependency CVE scanning, or infrastructure perimeter testing.
|
|
||||||
|
|
||||||
## Confirmed Product Security Findings
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
|
|
||||||
1. Browser-exposed shared bearer token path (`VITE_API_TOKEN` fallback)
|
|
||||||
- Severity: Critical
|
|
||||||
- Why this is a product issue: The frontend code supports a build-time token fallback and injects it into all API requests. This creates a shared credential model in browser code.
|
|
||||||
- Impact: Any user with browser access can recover and reuse the token, collapsing auth boundaries and auditability.
|
|
||||||
- Exploit path: Open app -> inspect runtime/bundle or intercepted request -> replay bearer token against protected API endpoints.
|
|
||||||
- Evidence:
|
|
||||||
- `frontend/src/lib/api.ts:39`
|
|
||||||
- `frontend/src/lib/api.ts:98`
|
|
||||||
- `frontend/src/lib/api.ts:111`
|
|
||||||
- `frontend/src/lib/api.ts:155`
|
|
||||||
- `docker-compose.yml:123`
|
|
||||||
- `backend/app/api/router.py:25`
|
|
||||||
- `backend/app/api/router.py:37`
|
|
||||||
- Production recommendation:
|
|
||||||
- Remove browser-side static token fallback.
|
|
||||||
- Use per-user server-issued auth (session or short-lived JWT) with role-bound authorization.
|
|
||||||
|
|
||||||
### High
|
|
||||||
|
|
||||||
1. CORS policy is effectively any HTTP/HTTPS origin, with credentials enabled
|
|
||||||
- Severity: High
|
|
||||||
- Why this is a product issue: CORS middleware enables `allow_origin_regex` that matches broad web origins and sets `allow_credentials=True`.
|
|
||||||
- Impact: If credentials are present, cross-origin access risk increases and token abuse becomes easier from arbitrary origins.
|
|
||||||
- Exploit path: Malicious origin performs cross-origin requests with available credentials and can read API responses under permissive CORS policy.
|
|
||||||
- Evidence:
|
|
||||||
- `backend/app/main.py:21`
|
|
||||||
- `backend/app/main.py:41`
|
|
||||||
- `backend/app/main.py:42`
|
|
||||||
- `backend/app/main.py:44`
|
|
||||||
- Production recommendation:
|
|
||||||
- Replace regex-based broad origin acceptance with explicit trusted origin allowlist.
|
|
||||||
- Keep `allow_credentials=False` unless strictly required for cookie-based flows.
|
|
||||||
|
|
||||||
### Medium
|
|
||||||
|
|
||||||
1. Sensitive processing content is persisted in logs by default
|
|
||||||
- Severity: Medium
|
|
||||||
- Why this is a product issue: Pipeline logging records OCR text, extraction text, prompts, and LLM outputs into persistent processing logs.
|
|
||||||
- Impact: Increased confidentiality risk and larger data-retention blast radius if logs are queried or exfiltrated.
|
|
||||||
- Exploit path: Access to admin log endpoints or database allows retrieval of sensitive operational content.
|
|
||||||
- Evidence:
|
|
||||||
- `backend/app/worker/tasks.py:619`
|
|
||||||
- `backend/app/worker/tasks.py:638`
|
|
||||||
- `backend/app/services/routing_pipeline.py:789`
|
|
||||||
- `backend/app/services/routing_pipeline.py:802`
|
|
||||||
- `backend/app/services/routing_pipeline.py:814`
|
|
||||||
- `backend/app/core/config.py:45`
|
|
||||||
- Production recommendation:
|
|
||||||
- Default to metadata-only logs.
|
|
||||||
- Disable persistent storage of prompt/response/raw extracted text unless temporary debug mode is explicitly enabled with strict TTL.
|
|
||||||
|
|
||||||
2. Markdown export endpoint is unbounded and memory-amplifiable
|
|
||||||
- Severity: Medium
|
|
||||||
- Why this is a product issue: Export loads all matching documents and builds ZIP in-memory with `BytesIO`, without hard limits on selection size.
|
|
||||||
- Impact: Authenticated users can trigger high memory use and service degradation.
|
|
||||||
- Exploit path: Repeated wide `path_prefix` exports cause large in-memory archive construction.
|
|
||||||
- Evidence:
|
|
||||||
- `backend/app/api/routes_documents.py:402`
|
|
||||||
- `backend/app/api/routes_documents.py:412`
|
|
||||||
- `backend/app/api/routes_documents.py:416`
|
|
||||||
- `backend/app/api/routes_documents.py:418`
|
|
||||||
- `backend/app/api/routes_documents.py:421`
|
|
||||||
- `backend/app/api/routes_documents.py:425`
|
|
||||||
- Production recommendation:
|
|
||||||
- Enforce max export document count and total bytes.
|
|
||||||
- Stream archive generation to temp files.
|
|
||||||
- Add endpoint rate limiting.
|
|
||||||
|
|
||||||
## Risks Requiring Product Decision or Further Verification
|
|
||||||
|
|
||||||
1. Authorization model appears role-based without per-document ownership boundaries
|
|
||||||
- Evidence:
|
|
||||||
- `backend/app/models/document.py:29`
|
|
||||||
- `backend/app/api/router.py:19`
|
|
||||||
- `backend/app/api/router.py:31`
|
|
||||||
- Question: Is this intentionally single-operator, or should production support multi-user/tenant data isolation?
|
|
||||||
|
|
||||||
2. Worker startup command uses raw Redis URL string and bypasses in-code URL security validator at startup
|
|
||||||
- Evidence:
|
|
||||||
- `docker-compose.yml:81`
|
|
||||||
- `backend/app/worker/queue.py:15`
|
|
||||||
- Question: Should worker startup also enforce `validate_redis_url_security` before consuming jobs?
|
|
||||||
|
|
||||||
3. Provider key encryption uses custom cryptographic construction
|
|
||||||
- Evidence:
|
|
||||||
- `backend/app/services/app_settings.py:131`
|
|
||||||
- `backend/app/services/app_settings.py:154`
|
|
||||||
- `backend/app/services/app_settings.py:176`
|
|
||||||
- Question: Are compliance or internal policy requirements demanding standardized AEAD primitives from vetted cryptography libraries?
|
|
||||||
|
|
||||||
## User-Managed Configuration Observations (Not Product Defects)
|
|
||||||
|
|
||||||
These are deployment/operator choices and should be tracked separately from code defects.
|
|
||||||
|
|
||||||
1. Development-mode posture in local `.env`
|
|
||||||
- Evidence:
|
|
||||||
- `.env:1`
|
|
||||||
- `.env:3`
|
|
||||||
- Notes: `APP_ENV=development` and anonymous development access are enabled.
|
|
||||||
|
|
||||||
2. Local `.env` includes placeholder shared API token values
|
|
||||||
- Evidence:
|
|
||||||
- `.env:15`
|
|
||||||
- `.env:16`
|
|
||||||
- `.env:31`
|
|
||||||
- Notes: If replaced with real values and reused, this increases operational risk. This is operator responsibility.
|
|
||||||
|
|
||||||
3. Compose defaults allow permissive provider egress controls
|
|
||||||
- Evidence:
|
|
||||||
- `docker-compose.yml:51`
|
|
||||||
- `docker-compose.yml:52`
|
|
||||||
- `.env:21`
|
|
||||||
- `.env:22`
|
|
||||||
- `.env:23`
|
|
||||||
- Notes: Allowing HTTP/private-network provider targets is a deployment policy choice.
|
|
||||||
|
|
||||||
4. Internal service transport defaults are plaintext in local stack
|
|
||||||
- Evidence:
|
|
||||||
- `docker-compose.yml:56`
|
|
||||||
- `.env:11`
|
|
||||||
- Notes: `http`/`redis://` may be acceptable for isolated local dev, but not for exposed production networks.
|
|
||||||
|
|
||||||
## Production Readiness Priority Order
|
|
||||||
|
|
||||||
1. Remove browser static token model and adopt per-user auth.
|
|
||||||
2. Tighten CORS to explicit trusted origins only.
|
|
||||||
3. Reduce persistent sensitive logging to metadata by default.
|
|
||||||
4. Add hard limits and streaming behavior for markdown export.
|
|
||||||
5. Resolve product decisions on tenant isolation, worker Redis security enforcement, and cryptography standardization.
|
|
||||||
@@ -8,6 +8,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
|
|||||||
AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password
|
AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password
|
||||||
AUTH_BOOTSTRAP_USER_USERNAME=user
|
AUTH_BOOTSTRAP_USER_USERNAME=user
|
||||||
AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password
|
AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password
|
||||||
|
AUTH_LOGIN_FAILURE_LIMIT=5
|
||||||
|
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
|
||||||
|
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
|
||||||
APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key
|
APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key
|
||||||
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
|
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
|
||||||
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false
|
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from datetime import datetime
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
import hmac
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,7 +15,26 @@ from app.models.auth import UserRole
|
|||||||
from app.services.authentication import resolve_auth_session
|
from app.services.authentication import resolve_auth_session
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import Cookie, Header
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
|
||||||
|
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def Header(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
SESSION_COOKIE_NAME = "dcm_session"
|
||||||
|
CSRF_COOKIE_NAME = "dcm_csrf"
|
||||||
|
CSRF_HEADER_NAME = "x-csrf-token"
|
||||||
|
CSRF_PROTECTED_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,8 +48,36 @@ class AuthContext:
|
|||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_csrf_validation(method: str) -> bool:
|
||||||
|
"""Returns whether an HTTP method should be protected by cookie CSRF validation."""
|
||||||
|
|
||||||
|
return method.upper() in CSRF_PROTECTED_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cookie_values(request: Request, cookie_name: str) -> tuple[str, ...]:
|
||||||
|
"""Extracts all values for one cookie name from raw Cookie header order."""
|
||||||
|
|
||||||
|
request_headers = getattr(request, "headers", None)
|
||||||
|
raw_cookie_header = request_headers.get("cookie", "") if request_headers is not None else ""
|
||||||
|
if not raw_cookie_header:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
extracted_values: list[str] = []
|
||||||
|
for cookie_pair in raw_cookie_header.split(";"):
|
||||||
|
normalized_pair = cookie_pair.strip()
|
||||||
|
if not normalized_pair or "=" not in normalized_pair:
|
||||||
|
continue
|
||||||
|
key, value = normalized_pair.split("=", 1)
|
||||||
|
if key.strip() != cookie_name:
|
||||||
|
continue
|
||||||
|
normalized_value = value.strip()
|
||||||
|
if normalized_value:
|
||||||
|
extracted_values.append(normalized_value)
|
||||||
|
return tuple(extracted_values)
|
||||||
|
|
||||||
|
|
||||||
def _raise_unauthorized() -> None:
|
def _raise_unauthorized() -> None:
|
||||||
"""Raises a 401 challenge response for missing or invalid bearer sessions."""
|
"""Raises a 401 challenge response for missing or invalid auth sessions."""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -38,20 +86,60 @@ def _raise_unauthorized() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_csrf_rejected() -> None:
|
||||||
|
"""Raises a forbidden response for CSRF validation failure."""
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid CSRF token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_request_auth_context(
|
def get_request_auth_context(
|
||||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
|
request: Request,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_auth),
|
||||||
|
csrf_header: str | None = Header(None, alias=CSRF_HEADER_NAME),
|
||||||
|
csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME),
|
||||||
|
session_cookie: str | None = Cookie(None, alias=SESSION_COOKIE_NAME),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
"""Authenticates bearer session token and returns role-bound request identity context."""
|
"""Authenticates auth session token and validates CSRF for cookie sessions."""
|
||||||
|
|
||||||
if credentials is None:
|
token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
|
||||||
_raise_unauthorized()
|
using_cookie_session = False
|
||||||
|
session_candidates: list[str] = []
|
||||||
|
|
||||||
token = credentials.credentials.strip()
|
|
||||||
if not token:
|
if not token:
|
||||||
|
using_cookie_session = True
|
||||||
|
session_candidates = [candidate for candidate in _extract_cookie_values(request, SESSION_COOKIE_NAME) if candidate]
|
||||||
|
normalized_session_cookie = (session_cookie or "").strip()
|
||||||
|
if normalized_session_cookie and normalized_session_cookie not in session_candidates:
|
||||||
|
session_candidates.append(normalized_session_cookie)
|
||||||
|
if not session_candidates:
|
||||||
_raise_unauthorized()
|
_raise_unauthorized()
|
||||||
|
|
||||||
|
if _requires_csrf_validation(request.method) and using_cookie_session:
|
||||||
|
normalized_csrf_header = (csrf_header or "").strip()
|
||||||
|
csrf_candidates = [candidate for candidate in _extract_cookie_values(request, CSRF_COOKIE_NAME) if candidate]
|
||||||
|
normalized_csrf_cookie = (csrf_cookie or "").strip()
|
||||||
|
if normalized_csrf_cookie and normalized_csrf_cookie not in csrf_candidates:
|
||||||
|
csrf_candidates.append(normalized_csrf_cookie)
|
||||||
|
if (
|
||||||
|
not csrf_candidates
|
||||||
|
or not normalized_csrf_header
|
||||||
|
or not any(hmac.compare_digest(candidate, normalized_csrf_header) for candidate in csrf_candidates)
|
||||||
|
):
|
||||||
|
_raise_csrf_rejected()
|
||||||
|
|
||||||
|
resolved_session = None
|
||||||
|
if token:
|
||||||
resolved_session = resolve_auth_session(session, token=token)
|
resolved_session = resolve_auth_session(session, token=token)
|
||||||
|
else:
|
||||||
|
for candidate in session_candidates:
|
||||||
|
resolved_session = resolve_auth_session(session, token=candidate)
|
||||||
|
if resolved_session is not None and resolved_session.user is not None:
|
||||||
|
break
|
||||||
|
|
||||||
if resolved_session is None or resolved_session.user is None:
|
if resolved_session is None or resolved_session.user is None:
|
||||||
_raise_unauthorized()
|
_raise_unauthorized()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
"""Authentication endpoints for credential login, session introspection, and logout."""
|
"""Authentication endpoints for credential login, session introspection, and logout."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.auth import AuthContext, require_user_or_admin
|
from app.api.auth import (
|
||||||
|
AuthContext,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
CSRF_COOKIE_NAME,
|
||||||
|
require_user_or_admin,
|
||||||
|
)
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
@@ -12,10 +23,28 @@ from app.schemas.auth import (
|
|||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthUserResponse,
|
AuthUserResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.auth_login_throttle import (
|
||||||
|
check_login_throttle,
|
||||||
|
clear_login_throttle,
|
||||||
|
record_failed_login_attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import Cookie, Response
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LOGIN_THROTTLED_DETAIL = "Too many login attempts. Try again later."
|
||||||
|
LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL = "Login rate limiter backend unavailable"
|
||||||
|
|
||||||
|
|
||||||
def _request_ip_address(request: Request) -> str | None:
|
def _request_ip_address(request: Request) -> str | None:
|
||||||
@@ -31,13 +60,184 @@ def _request_user_agent(request: Request) -> str | None:
|
|||||||
return user_agent[:512] if user_agent else None
|
return user_agent[:512] if user_agent else None
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
|
||||||
|
"""Returns a bounded Retry-After header payload for throttled authentication responses."""
|
||||||
|
|
||||||
|
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https_request(request: Request) -> bool:
|
||||||
|
"""Returns whether the incoming request should be treated as HTTPS for cookie flags."""
|
||||||
|
|
||||||
|
forwarded_protocol = request.headers.get("x-forwarded-proto", "").strip().lower().split(",")[0]
|
||||||
|
if forwarded_protocol:
|
||||||
|
return forwarded_protocol == "https"
|
||||||
|
request_url = getattr(request, "url", None)
|
||||||
|
request_scheme = str(getattr(request_url, "scheme", "")).lower() if request_url is not None else ""
|
||||||
|
if request_scheme == "https":
|
||||||
|
return True
|
||||||
|
|
||||||
|
parsed_public_base_url = urlparse(get_settings().public_base_url.strip())
|
||||||
|
return parsed_public_base_url.scheme.lower() == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_domain() -> str | None:
|
||||||
|
"""Returns optional cookie domain override for multi-subdomain deployments."""
|
||||||
|
|
||||||
|
configured_domain = get_settings().auth_cookie_domain.strip().lower().lstrip(".")
|
||||||
|
if not configured_domain or "." not in configured_domain:
|
||||||
|
return None
|
||||||
|
return configured_domain
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_domains() -> tuple[str | None, ...]:
|
||||||
|
"""Returns cookie domain variants with a host-only cookie first for browser compatibility."""
|
||||||
|
|
||||||
|
configured_domain = _resolve_cookie_domain()
|
||||||
|
if configured_domain is None:
|
||||||
|
return (None,)
|
||||||
|
return (None, configured_domain)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_matches_cookie_domain(request: Request) -> bool:
|
||||||
|
"""Returns whether request and origin hosts both sit under the configured cookie domain."""
|
||||||
|
|
||||||
|
configured_domain = _resolve_cookie_domain()
|
||||||
|
if configured_domain is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
origin_header = request.headers.get("origin", "").strip()
|
||||||
|
origin_host = urlparse(origin_header).hostname.strip().lower() if origin_header else ""
|
||||||
|
if not origin_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
request_url = getattr(request, "url", None)
|
||||||
|
request_host = str(getattr(request_url, "hostname", "")).strip().lower() if request_url is not None else ""
|
||||||
|
if not request_host:
|
||||||
|
parsed_public_base_url = urlparse(get_settings().public_base_url.strip())
|
||||||
|
request_host = parsed_public_base_url.hostname.strip().lower() if parsed_public_base_url.hostname else ""
|
||||||
|
if not request_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _matches(candidate: str) -> bool:
|
||||||
|
return candidate == configured_domain or candidate.endswith(f".{configured_domain}")
|
||||||
|
|
||||||
|
return _matches(origin_host) and _matches(request_host)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_samesite(request: Request, secure_cookie: bool) -> str:
|
||||||
|
"""Returns cookie SameSite mode with same-site subdomain compatibility defaults."""
|
||||||
|
|
||||||
|
configured_mode = get_settings().auth_cookie_samesite.strip().lower()
|
||||||
|
if configured_mode in {"strict", "lax"}:
|
||||||
|
return configured_mode
|
||||||
|
if configured_mode == "none":
|
||||||
|
return "lax" if _request_matches_cookie_domain(request) else "none"
|
||||||
|
return "none" if secure_cookie else "lax"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
||||||
|
"""Converts session expiration datetime into cookie max-age seconds."""
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
ttl = int((expires_at - now).total_seconds())
|
||||||
|
return max(1, ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(
|
||||||
|
response: Response,
|
||||||
|
session_token: str,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
expires_at: datetime,
|
||||||
|
secure: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Stores the issued session token in a browser HttpOnly auth cookie."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
|
return
|
||||||
|
expires_seconds = _session_cookie_ttl_seconds(expires_at)
|
||||||
|
same_site_mode = _resolve_cookie_samesite(request, secure)
|
||||||
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
|
cookie_kwargs = {
|
||||||
|
"value": session_token,
|
||||||
|
"max_age": expires_seconds,
|
||||||
|
"httponly": True,
|
||||||
|
"secure": secure,
|
||||||
|
"samesite": same_site_mode,
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
cookie_kwargs["domain"] = cookie_domain
|
||||||
|
response.set_cookie(SESSION_COOKIE_NAME, **cookie_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_csrf_cookie(
|
||||||
|
response: Response,
|
||||||
|
csrf_token: str,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
expires_at: datetime,
|
||||||
|
secure: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Stores an anti-CSRF token in a browser cookie for JavaScript-safe extraction."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
|
return
|
||||||
|
same_site_mode = _resolve_cookie_samesite(request, secure)
|
||||||
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
|
cookie_kwargs = {
|
||||||
|
"value": csrf_token,
|
||||||
|
"max_age": _session_cookie_ttl_seconds(expires_at),
|
||||||
|
"httponly": False,
|
||||||
|
"secure": secure,
|
||||||
|
"samesite": same_site_mode,
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
cookie_kwargs["domain"] = cookie_domain
|
||||||
|
response.set_cookie(CSRF_COOKIE_NAME, **cookie_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_session_cookies(response: Response) -> None:
|
||||||
|
"""Clears auth cookies returned by login responses."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "delete_cookie"):
|
||||||
|
return
|
||||||
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
|
delete_kwargs = {"path": "/"}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
delete_kwargs["domain"] = cookie_domain
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, **delete_kwargs)
|
||||||
|
response.delete_cookie(CSRF_COOKIE_NAME, **delete_kwargs)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthLoginResponse)
|
@router.post("/login", response_model=AuthLoginResponse)
|
||||||
def login(
|
def login(
|
||||||
payload: AuthLoginRequest,
|
payload: AuthLoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AuthLoginResponse:
|
) -> AuthLoginResponse:
|
||||||
"""Authenticates username and password and returns an issued bearer session token."""
|
"""Authenticates credentials with throttle protection and returns issued session metadata."""
|
||||||
|
|
||||||
|
ip_address = _request_ip_address(request)
|
||||||
|
try:
|
||||||
|
throttle_status = check_login_throttle(
|
||||||
|
username=payload.username,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
|
||||||
|
) from error
|
||||||
|
if throttle_status.is_throttled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=LOGIN_THROTTLED_DETAIL,
|
||||||
|
headers=_retry_after_headers(throttle_status.retry_after_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
user = authenticate_user(
|
user = authenticate_user(
|
||||||
session,
|
session,
|
||||||
@@ -45,29 +245,80 @@ def login(
|
|||||||
password=payload.password,
|
password=payload.password,
|
||||||
)
|
)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
try:
|
||||||
|
lockout_seconds = record_failed_login_attempt(
|
||||||
|
username=payload.username,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
|
||||||
|
) from error
|
||||||
|
if lockout_seconds > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=LOGIN_THROTTLED_DETAIL,
|
||||||
|
headers=_retry_after_headers(lockout_seconds),
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid username or password",
|
detail="Invalid username or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
clear_login_throttle(
|
||||||
|
username=payload.username,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to clear login throttle state after successful authentication: username=%s ip=%s",
|
||||||
|
payload.username.strip().lower(),
|
||||||
|
ip_address or "",
|
||||||
|
)
|
||||||
|
|
||||||
issued_session = issue_user_session(
|
issued_session = issue_user_session(
|
||||||
session,
|
session,
|
||||||
user=user,
|
user=user,
|
||||||
user_agent=_request_user_agent(request),
|
user_agent=_request_user_agent(request),
|
||||||
ip_address=_request_ip_address(request),
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return AuthLoginResponse(
|
|
||||||
access_token=issued_session.token,
|
csrf_token = secrets.token_urlsafe(32)
|
||||||
|
secure_cookie = _is_https_request(request)
|
||||||
|
_set_session_cookie(
|
||||||
|
response,
|
||||||
|
issued_session.token,
|
||||||
|
request=request,
|
||||||
expires_at=issued_session.expires_at,
|
expires_at=issued_session.expires_at,
|
||||||
|
secure=secure_cookie,
|
||||||
|
)
|
||||||
|
_set_csrf_cookie(
|
||||||
|
response,
|
||||||
|
csrf_token,
|
||||||
|
request=request,
|
||||||
|
expires_at=issued_session.expires_at,
|
||||||
|
secure=secure_cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthLoginResponse(
|
||||||
user=AuthUserResponse.model_validate(user),
|
user=AuthUserResponse.model_validate(user),
|
||||||
|
expires_at=issued_session.expires_at,
|
||||||
|
access_token=issued_session.token,
|
||||||
|
csrf_token=csrf_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=AuthSessionResponse)
|
@router.get("/me", response_model=AuthSessionResponse)
|
||||||
def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResponse:
|
def me(
|
||||||
|
context: AuthContext = Depends(require_user_or_admin),
|
||||||
|
csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME),
|
||||||
|
) -> AuthSessionResponse:
|
||||||
"""Returns current authenticated session identity and expiration metadata."""
|
"""Returns current authenticated session identity and expiration metadata."""
|
||||||
|
|
||||||
|
normalized_csrf_cookie = (csrf_cookie or "").strip() or None
|
||||||
return AuthSessionResponse(
|
return AuthSessionResponse(
|
||||||
expires_at=context.expires_at,
|
expires_at=context.expires_at,
|
||||||
user=AuthUserResponse(
|
user=AuthUserResponse(
|
||||||
@@ -75,15 +326,17 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
|
|||||||
username=context.username,
|
username=context.username,
|
||||||
role=context.role,
|
role=context.role,
|
||||||
),
|
),
|
||||||
|
csrf_token=normalized_csrf_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", response_model=AuthLogoutResponse)
|
@router.post("/logout", response_model=AuthLogoutResponse)
|
||||||
def logout(
|
def logout(
|
||||||
|
response: Response,
|
||||||
context: AuthContext = Depends(require_user_or_admin),
|
context: AuthContext = Depends(require_user_or_admin),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AuthLogoutResponse:
|
) -> AuthLogoutResponse:
|
||||||
"""Revokes current bearer session token and confirms logout state."""
|
"""Revokes current session token and clears client auth cookies."""
|
||||||
|
|
||||||
revoked = revoke_auth_session(
|
revoked = revoke_auth_session(
|
||||||
session,
|
session,
|
||||||
@@ -91,4 +344,6 @@ def logout(
|
|||||||
)
|
)
|
||||||
if revoked:
|
if revoked:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
_clear_session_cookies(response)
|
||||||
return AuthLogoutResponse(revoked=revoked)
|
return AuthLogoutResponse(revoked=revoked)
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ def _scope_document_statement_for_auth_context(statement, auth_context: AuthCont
|
|||||||
return statement.where(Document.owner_user_id == auth_context.user_id)
|
return statement.where(Document.owner_user_id == auth_context.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_predefined_entry_visible_to_auth_context(entry: dict[str, object], auth_context: AuthContext) -> bool:
|
||||||
|
"""Returns whether one predefined catalog entry is visible to the active caller role."""
|
||||||
|
|
||||||
|
if auth_context.role == UserRole.ADMIN:
|
||||||
|
return True
|
||||||
|
return bool(entry.get("global_shared", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_visible_predefined_values(
|
||||||
|
entries: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
auth_context: AuthContext,
|
||||||
|
) -> set[str]:
|
||||||
|
"""Collects normalized predefined values visible for the active caller role."""
|
||||||
|
|
||||||
|
visible_values: set[str] = set()
|
||||||
|
for entry in entries:
|
||||||
|
if not _is_predefined_entry_visible_to_auth_context(entry, auth_context):
|
||||||
|
continue
|
||||||
|
normalized = str(entry.get("value", "")).strip()
|
||||||
|
if normalized:
|
||||||
|
visible_values.add(normalized)
|
||||||
|
return visible_values
|
||||||
|
|
||||||
|
|
||||||
def _ensure_document_access(document: Document, auth_context: AuthContext) -> None:
|
def _ensure_document_access(document: Document, auth_context: AuthContext) -> None:
|
||||||
"""Enforces owner-level access for non-admin users and raises not-found on violations."""
|
"""Enforces owner-level access for non-admin users and raises not-found on violations."""
|
||||||
|
|
||||||
@@ -397,9 +422,10 @@ def list_tags(
|
|||||||
rows = session.execute(statement).scalars().all()
|
rows = session.execute(statement).scalars().all()
|
||||||
tags = {tag for row in rows for tag in row if tag}
|
tags = {tag for row in rows for tag in row if tag}
|
||||||
tags.update(
|
tags.update(
|
||||||
str(item.get("value", "")).strip()
|
_collect_visible_predefined_values(
|
||||||
for item in read_predefined_tags_settings()
|
read_predefined_tags_settings(),
|
||||||
if str(item.get("value", "")).strip()
|
auth_context=auth_context,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
tags = sorted(tags)
|
tags = sorted(tags)
|
||||||
return {"tags": tags}
|
return {"tags": tags}
|
||||||
@@ -421,9 +447,10 @@ def list_paths(
|
|||||||
rows = session.execute(statement).scalars().all()
|
rows = session.execute(statement).scalars().all()
|
||||||
paths = {row for row in rows if row}
|
paths = {row for row in rows if row}
|
||||||
paths.update(
|
paths.update(
|
||||||
str(item.get("value", "")).strip()
|
_collect_visible_predefined_values(
|
||||||
for item in read_predefined_paths_settings()
|
read_predefined_paths_settings(),
|
||||||
if str(item.get("value", "")).strip()
|
auth_context=auth_context,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
paths = sorted(paths)
|
paths = sorted(paths)
|
||||||
return {"paths": paths}
|
return {"paths": paths}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class Settings(BaseSettings):
|
|||||||
auth_password_pbkdf2_iterations: int = 390000
|
auth_password_pbkdf2_iterations: int = 390000
|
||||||
auth_session_token_bytes: int = 32
|
auth_session_token_bytes: int = 32
|
||||||
auth_session_pepper: str = ""
|
auth_session_pepper: str = ""
|
||||||
|
auth_login_failure_limit: int = 5
|
||||||
|
auth_login_failure_window_seconds: int = 900
|
||||||
|
auth_login_lockout_base_seconds: int = 30
|
||||||
|
auth_login_lockout_max_seconds: int = 900
|
||||||
|
auth_cookie_domain: str = ""
|
||||||
|
auth_cookie_samesite: str = "auto"
|
||||||
storage_root: Path = Path("/data/storage")
|
storage_root: Path = Path("/data/storage")
|
||||||
upload_chunk_size: int = 4 * 1024 * 1024
|
upload_chunk_size: int = 4 * 1024 * 1024
|
||||||
max_upload_files_per_request: int = 50
|
max_upload_files_per_request: int = 50
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def create_app() -> FastAPI:
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=False,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ class AuthSessionResponse(BaseModel):
|
|||||||
|
|
||||||
user: AuthUserResponse
|
user: AuthUserResponse
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
csrf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginResponse(AuthSessionResponse):
|
class AuthLoginResponse(AuthSessionResponse):
|
||||||
"""Represents one newly issued bearer token and associated user context."""
|
"""Represents one newly issued bearer token and associated user context."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str | None = None
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
csrf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthLogoutResponse(BaseModel):
|
class AuthLogoutResponse(BaseModel):
|
||||||
|
|||||||
187
backend/app/services/auth_login_throttle.py
Normal file
187
backend/app/services/auth_login_throttle.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Redis-backed brute-force protections for authentication login requests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.services.authentication import normalize_username
|
||||||
|
from app.worker.queue import get_redis
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USERNAME_SUBJECT_KIND = "username"
|
||||||
|
IP_SUBJECT_KIND = "ip"
|
||||||
|
UNKNOWN_USERNAME_SUBJECT = "unknown-username"
|
||||||
|
UNKNOWN_IP_SUBJECT = "unknown-ip"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LoginThrottlePolicy:
|
||||||
|
"""Captures login throttle policy values resolved from runtime settings."""
|
||||||
|
|
||||||
|
failure_limit: int
|
||||||
|
failure_window_seconds: int
|
||||||
|
lockout_base_seconds: int
|
||||||
|
lockout_max_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LoginThrottleStatus:
|
||||||
|
"""Represents whether login attempts are currently throttled and retry metadata."""
|
||||||
|
|
||||||
|
is_throttled: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_int(value: int, *, minimum: int, maximum: int) -> int:
|
||||||
|
"""Clamps one integer value to an inclusive minimum and maximum range."""
|
||||||
|
|
||||||
|
return max(minimum, min(maximum, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_policy(settings: Settings) -> LoginThrottlePolicy:
|
||||||
|
"""Resolves login throttle policy from settings with defensive value bounds."""
|
||||||
|
|
||||||
|
failure_limit = _bounded_int(settings.auth_login_failure_limit, minimum=1, maximum=1000)
|
||||||
|
failure_window_seconds = _bounded_int(settings.auth_login_failure_window_seconds, minimum=30, maximum=86400)
|
||||||
|
lockout_base_seconds = _bounded_int(settings.auth_login_lockout_base_seconds, minimum=1, maximum=3600)
|
||||||
|
lockout_max_seconds = _bounded_int(settings.auth_login_lockout_max_seconds, minimum=1, maximum=86400)
|
||||||
|
if lockout_max_seconds < lockout_base_seconds:
|
||||||
|
lockout_max_seconds = lockout_base_seconds
|
||||||
|
return LoginThrottlePolicy(
|
||||||
|
failure_limit=failure_limit,
|
||||||
|
failure_window_seconds=failure_window_seconds,
|
||||||
|
lockout_base_seconds=lockout_base_seconds,
|
||||||
|
lockout_max_seconds=lockout_max_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_login_identity(username: str, ip_address: str | None) -> tuple[str, str]:
|
||||||
|
"""Normalizes username and source IP identity values used by throttle storage keys."""
|
||||||
|
|
||||||
|
normalized_username = normalize_username(username) or UNKNOWN_USERNAME_SUBJECT
|
||||||
|
normalized_ip = (ip_address or "").strip()[:64] or UNKNOWN_IP_SUBJECT
|
||||||
|
return normalized_username, normalized_ip
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_subjects(username: str, ip_address: str | None) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||||
|
"""Builds the username and IP throttle subject tuples for one login attempt."""
|
||||||
|
|
||||||
|
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
|
||||||
|
return (
|
||||||
|
(USERNAME_SUBJECT_KIND, normalized_username),
|
||||||
|
(IP_SUBJECT_KIND, normalized_ip),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_key(*, subject_kind: str, subject_value: str) -> str:
|
||||||
|
"""Builds the Redis key used to track failed login counts for one subject."""
|
||||||
|
|
||||||
|
return f"dcm:auth-login:fail:{subject_kind}:{subject_value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_key(*, subject_kind: str, subject_value: str) -> str:
|
||||||
|
"""Builds the Redis key used to store active lockout state for one subject."""
|
||||||
|
|
||||||
|
return f"dcm:auth-login:lock:{subject_kind}:{subject_value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _next_lockout_seconds(*, failure_count: int, policy: LoginThrottlePolicy) -> int:
|
||||||
|
"""Computes exponential lockout duration when failed attempts exceed configured limit."""
|
||||||
|
|
||||||
|
if failure_count <= policy.failure_limit:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
additional_failures = failure_count - policy.failure_limit - 1
|
||||||
|
lockout_seconds = policy.lockout_base_seconds
|
||||||
|
while additional_failures > 0 and lockout_seconds < policy.lockout_max_seconds:
|
||||||
|
lockout_seconds = min(policy.lockout_max_seconds, lockout_seconds * 2)
|
||||||
|
additional_failures -= 1
|
||||||
|
return lockout_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def check_login_throttle(*, username: str, ip_address: str | None) -> LoginThrottleStatus:
|
||||||
|
"""Returns active login throttle status for the username and source IP identity tuple."""
|
||||||
|
|
||||||
|
redis_client = get_redis()
|
||||||
|
try:
|
||||||
|
retry_after_seconds = 0
|
||||||
|
for subject_kind, subject_value in _identity_subjects(username, ip_address):
|
||||||
|
subject_ttl = int(redis_client.ttl(_lock_key(subject_kind=subject_kind, subject_value=subject_value)))
|
||||||
|
if subject_ttl == -1:
|
||||||
|
retry_after_seconds = max(retry_after_seconds, 1)
|
||||||
|
elif subject_ttl > 0:
|
||||||
|
retry_after_seconds = max(retry_after_seconds, subject_ttl)
|
||||||
|
except RedisError as error:
|
||||||
|
raise RuntimeError("Login throttle backend unavailable") from error
|
||||||
|
|
||||||
|
return LoginThrottleStatus(
|
||||||
|
is_throttled=retry_after_seconds > 0,
|
||||||
|
retry_after_seconds=retry_after_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_failed_login_attempt(*, username: str, ip_address: str | None) -> int:
|
||||||
|
"""Records one failed login attempt and returns active lockout seconds, if any."""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
policy = _resolve_policy(settings)
|
||||||
|
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
|
||||||
|
redis_client = get_redis()
|
||||||
|
|
||||||
|
try:
|
||||||
|
highest_failure_count = 0
|
||||||
|
active_lockout_seconds = 0
|
||||||
|
for subject_kind, subject_value in (
|
||||||
|
(USERNAME_SUBJECT_KIND, normalized_username),
|
||||||
|
(IP_SUBJECT_KIND, normalized_ip),
|
||||||
|
):
|
||||||
|
failure_key = _failure_key(subject_kind=subject_kind, subject_value=subject_value)
|
||||||
|
pipeline = redis_client.pipeline(transaction=True)
|
||||||
|
pipeline.incr(failure_key, 1)
|
||||||
|
pipeline.expire(failure_key, policy.failure_window_seconds + 5)
|
||||||
|
count_value, _ = pipeline.execute()
|
||||||
|
failure_count = int(count_value)
|
||||||
|
highest_failure_count = max(highest_failure_count, failure_count)
|
||||||
|
|
||||||
|
lockout_seconds = _next_lockout_seconds(failure_count=failure_count, policy=policy)
|
||||||
|
if lockout_seconds > 0:
|
||||||
|
redis_client.set(
|
||||||
|
_lock_key(subject_kind=subject_kind, subject_value=subject_value),
|
||||||
|
"1",
|
||||||
|
ex=lockout_seconds,
|
||||||
|
)
|
||||||
|
active_lockout_seconds = max(active_lockout_seconds, lockout_seconds)
|
||||||
|
except RedisError as error:
|
||||||
|
raise RuntimeError("Login throttle backend unavailable") from error
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Authentication login failure: username=%s ip=%s failed_attempts=%s lockout_seconds=%s",
|
||||||
|
normalized_username,
|
||||||
|
normalized_ip,
|
||||||
|
highest_failure_count,
|
||||||
|
active_lockout_seconds,
|
||||||
|
)
|
||||||
|
return active_lockout_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def clear_login_throttle(*, username: str, ip_address: str | None) -> None:
|
||||||
|
"""Clears username and source-IP login throttle state after successful authentication."""
|
||||||
|
|
||||||
|
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
|
||||||
|
redis_client = get_redis()
|
||||||
|
keys = [
|
||||||
|
_failure_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username),
|
||||||
|
_lock_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username),
|
||||||
|
_failure_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip),
|
||||||
|
_lock_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
redis_client.delete(*keys)
|
||||||
|
except RedisError as error:
|
||||||
|
raise RuntimeError("Login throttle backend unavailable") from error
|
||||||
@@ -40,6 +40,48 @@ if "pydantic_settings" not in sys.modules:
|
|||||||
if "fastapi" not in sys.modules:
|
if "fastapi" not in sys.modules:
|
||||||
fastapi_stub = ModuleType("fastapi")
|
fastapi_stub = ModuleType("fastapi")
|
||||||
|
|
||||||
|
class _APIRouter:
|
||||||
|
"""Minimal APIRouter stand-in supporting decorator registration."""
|
||||||
|
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def post(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns no-op decorator for POST route declarations."""
|
||||||
|
|
||||||
|
def decorator(func): # type: ignore[no-untyped-def]
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def get(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns no-op decorator for GET route declarations."""
|
||||||
|
|
||||||
|
def decorator(func): # type: ignore[no-untyped-def]
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def patch(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns no-op decorator for PATCH route declarations."""
|
||||||
|
|
||||||
|
def decorator(func): # type: ignore[no-untyped-def]
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def delete(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns no-op decorator for DELETE route declarations."""
|
||||||
|
|
||||||
|
def decorator(func): # type: ignore[no-untyped-def]
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
"""Minimal request placeholder for route function import compatibility."""
|
||||||
|
|
||||||
class _HTTPException(Exception):
|
class _HTTPException(Exception):
|
||||||
"""Minimal HTTPException compatible with route dependency tests."""
|
"""Minimal HTTPException compatible with route dependency tests."""
|
||||||
|
|
||||||
@@ -54,6 +96,7 @@ if "fastapi" not in sys.modules:
|
|||||||
|
|
||||||
HTTP_401_UNAUTHORIZED = 401
|
HTTP_401_UNAUTHORIZED = 401
|
||||||
HTTP_403_FORBIDDEN = 403
|
HTTP_403_FORBIDDEN = 403
|
||||||
|
HTTP_429_TOO_MANY_REQUESTS = 429
|
||||||
HTTP_503_SERVICE_UNAVAILABLE = 503
|
HTTP_503_SERVICE_UNAVAILABLE = 503
|
||||||
|
|
||||||
def _depends(dependency): # type: ignore[no-untyped-def]
|
def _depends(dependency): # type: ignore[no-untyped-def]
|
||||||
@@ -61,11 +104,52 @@ if "fastapi" not in sys.modules:
|
|||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
def _query(default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns FastAPI-like query defaults for dependency-light route imports."""
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _file(default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns FastAPI-like file defaults for dependency-light route imports."""
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _form(default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Returns FastAPI-like form defaults for dependency-light route imports."""
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
class _UploadFile:
|
||||||
|
"""Minimal UploadFile placeholder for route import compatibility."""
|
||||||
|
|
||||||
|
fastapi_stub.APIRouter = _APIRouter
|
||||||
fastapi_stub.Depends = _depends
|
fastapi_stub.Depends = _depends
|
||||||
|
fastapi_stub.File = _file
|
||||||
|
fastapi_stub.Form = _form
|
||||||
fastapi_stub.HTTPException = _HTTPException
|
fastapi_stub.HTTPException = _HTTPException
|
||||||
|
fastapi_stub.Query = _query
|
||||||
|
fastapi_stub.Request = _Request
|
||||||
|
fastapi_stub.UploadFile = _UploadFile
|
||||||
fastapi_stub.status = _Status()
|
fastapi_stub.status = _Status()
|
||||||
sys.modules["fastapi"] = fastapi_stub
|
sys.modules["fastapi"] = fastapi_stub
|
||||||
|
|
||||||
|
if "fastapi.responses" not in sys.modules:
|
||||||
|
fastapi_responses_stub = ModuleType("fastapi.responses")
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
"""Minimal response placeholder for route import compatibility."""
|
||||||
|
|
||||||
|
class _FileResponse(_Response):
|
||||||
|
"""Minimal file response placeholder for route import compatibility."""
|
||||||
|
|
||||||
|
class _StreamingResponse(_Response):
|
||||||
|
"""Minimal streaming response placeholder for route import compatibility."""
|
||||||
|
|
||||||
|
fastapi_responses_stub.Response = _Response
|
||||||
|
fastapi_responses_stub.FileResponse = _FileResponse
|
||||||
|
fastapi_responses_stub.StreamingResponse = _StreamingResponse
|
||||||
|
sys.modules["fastapi.responses"] = fastapi_responses_stub
|
||||||
|
|
||||||
if "fastapi.security" not in sys.modules:
|
if "fastapi.security" not in sys.modules:
|
||||||
fastapi_security_stub = ModuleType("fastapi.security")
|
fastapi_security_stub = ModuleType("fastapi.security")
|
||||||
|
|
||||||
@@ -238,8 +322,14 @@ if "app.services.handwriting_style" not in sys.modules:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _delete_many_handwriting_style_documents(*_args: object, **_kwargs: object) -> None:
|
||||||
|
"""No-op bulk style document delete stub for route import compatibility."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
handwriting_style_stub.assign_handwriting_style = _assign_handwriting_style
|
handwriting_style_stub.assign_handwriting_style = _assign_handwriting_style
|
||||||
handwriting_style_stub.delete_handwriting_style_document = _delete_handwriting_style_document
|
handwriting_style_stub.delete_handwriting_style_document = _delete_handwriting_style_document
|
||||||
|
handwriting_style_stub.delete_many_handwriting_style_documents = _delete_many_handwriting_style_documents
|
||||||
sys.modules["app.services.handwriting_style"] = handwriting_style_stub
|
sys.modules["app.services.handwriting_style"] = handwriting_style_stub
|
||||||
|
|
||||||
if "app.services.routing_pipeline" not in sys.modules:
|
if "app.services.routing_pipeline" not in sys.modules:
|
||||||
@@ -274,10 +364,14 @@ if "app.services.routing_pipeline" not in sys.modules:
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.api.auth import AuthContext, require_admin
|
from app.api.auth import AuthContext, require_admin
|
||||||
|
from app.api import auth as auth_dependency_module
|
||||||
|
from app.api import routes_auth as auth_routes_module
|
||||||
|
from app.api import routes_documents as documents_routes_module
|
||||||
from app.core import config as config_module
|
from app.core import config as config_module
|
||||||
from app.models.auth import UserRole
|
from app.models.auth import UserRole
|
||||||
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
|
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
|
||||||
from app.schemas.processing_logs import ProcessingLogEntryResponse
|
from app.schemas.processing_logs import ProcessingLogEntryResponse
|
||||||
|
from app.services import auth_login_throttle as auth_login_throttle_module
|
||||||
from app.services import extractor as extractor_module
|
from app.services import extractor as extractor_module
|
||||||
from app.worker import tasks as worker_tasks_module
|
from app.worker import tasks as worker_tasks_module
|
||||||
|
|
||||||
@@ -327,6 +421,661 @@ class AuthDependencyTests(unittest.TestCase):
|
|||||||
resolved = require_admin(context=auth_context)
|
resolved = require_admin(context=auth_context)
|
||||||
self.assertEqual(resolved.role, UserRole.ADMIN)
|
self.assertEqual(resolved.role, UserRole.ADMIN)
|
||||||
|
|
||||||
|
def test_csrf_validation_accepts_matching_token_among_duplicate_cookie_values(self) -> None:
|
||||||
|
"""PATCH CSRF validation accepts header token matching any duplicate csrf cookie value."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="PATCH",
|
||||||
|
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
|
||||||
|
context = auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header="fresh-token",
|
||||||
|
csrf_cookie="stale-token",
|
||||||
|
session_cookie="session-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(context.username, "admin")
|
||||||
|
self.assertEqual(context.role, UserRole.ADMIN)
|
||||||
|
|
||||||
|
def test_csrf_validation_rejects_when_header_does_not_match_any_cookie_value(self) -> None:
|
||||||
|
"""PATCH CSRF validation rejects requests when header token matches no csrf cookie values."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="PATCH",
|
||||||
|
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header="unknown-token",
|
||||||
|
csrf_cookie="stale-token",
|
||||||
|
session_cookie="session-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid CSRF token")
|
||||||
|
|
||||||
|
def test_cookie_auth_accepts_matching_session_among_duplicate_cookie_values(self) -> None:
|
||||||
|
"""Cookie auth accepts the first valid session token among duplicate cookie values."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="GET",
|
||||||
|
headers={"cookie": "dcm_session=stale-token; dcm_session=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
auth_dependency_module,
|
||||||
|
"resolve_auth_session",
|
||||||
|
side_effect=[None, resolved_session],
|
||||||
|
) as resolve_mock:
|
||||||
|
context = auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header=None,
|
||||||
|
csrf_cookie=None,
|
||||||
|
session_cookie="stale-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(context.username, "admin")
|
||||||
|
self.assertEqual(context.role, UserRole.ADMIN)
|
||||||
|
self.assertEqual(resolve_mock.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCatalogVisibilityTests(unittest.TestCase):
|
||||||
|
"""Verifies predefined tag and path discovery visibility by caller role."""
|
||||||
|
|
||||||
|
class _ScalarSequence:
|
||||||
|
"""Provides SQLAlchemy-like scalar result chaining for route unit tests."""
|
||||||
|
|
||||||
|
def __init__(self, values: list[object]) -> None:
|
||||||
|
self._values = values
|
||||||
|
|
||||||
|
def scalars(self) -> "DocumentCatalogVisibilityTests._ScalarSequence":
|
||||||
|
"""Returns self to emulate `.scalars().all()` call chains."""
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self) -> list[object]:
|
||||||
|
"""Returns deterministic sequence values for route helper queries."""
|
||||||
|
|
||||||
|
return list(self._values)
|
||||||
|
|
||||||
|
class _SessionStub:
|
||||||
|
"""Returns a fixed scalar sequence for route metadata queries."""
|
||||||
|
|
||||||
|
def __init__(self, values: list[object]) -> None:
|
||||||
|
self._values = values
|
||||||
|
|
||||||
|
def execute(self, _statement: object) -> "DocumentCatalogVisibilityTests._ScalarSequence":
|
||||||
|
"""Ignores query details and returns deterministic scalar sequence results."""
|
||||||
|
|
||||||
|
return DocumentCatalogVisibilityTests._ScalarSequence(self._values)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auth_context(role: UserRole) -> AuthContext:
|
||||||
|
"""Builds deterministic auth context fixtures for document discovery tests."""
|
||||||
|
|
||||||
|
return AuthContext(
|
||||||
|
user_id=uuid.uuid4(),
|
||||||
|
username=f"{role.value}-user",
|
||||||
|
role=role,
|
||||||
|
session_id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_admin_only_receives_global_shared_predefined_tags_and_paths(self) -> None:
|
||||||
|
"""User role receives only globally shared predefined values in discovery responses."""
|
||||||
|
|
||||||
|
session = self._SessionStub(
|
||||||
|
values=[
|
||||||
|
["owner-tag", ""],
|
||||||
|
["owner-only-tag"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
predefined_tags = [
|
||||||
|
{"value": "SharedTag", "global_shared": True},
|
||||||
|
{"value": "InternalTag", "global_shared": False},
|
||||||
|
{"value": "ImplicitPrivateTag"},
|
||||||
|
]
|
||||||
|
predefined_paths = [
|
||||||
|
{"value": "Shared/Path", "global_shared": True},
|
||||||
|
{"value": "Internal/Path", "global_shared": False},
|
||||||
|
{"value": "Implicit/Private"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(documents_routes_module, "read_predefined_tags_settings", return_value=predefined_tags),
|
||||||
|
patch.object(documents_routes_module, "read_predefined_paths_settings", return_value=predefined_paths),
|
||||||
|
):
|
||||||
|
tags_response = documents_routes_module.list_tags(
|
||||||
|
include_trashed=False,
|
||||||
|
auth_context=self._auth_context(UserRole.USER),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
paths_response = documents_routes_module.list_paths(
|
||||||
|
include_trashed=False,
|
||||||
|
auth_context=self._auth_context(UserRole.USER),
|
||||||
|
session=self._SessionStub(values=["Owner/Path"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(tags_response["tags"], ["SharedTag", "owner-only-tag", "owner-tag"])
|
||||||
|
self.assertEqual(paths_response["paths"], ["Owner/Path", "Shared/Path"])
|
||||||
|
|
||||||
|
def test_admin_receives_full_predefined_tags_and_paths_catalog(self) -> None:
|
||||||
|
"""Admin role receives full predefined values regardless of global-sharing scope."""
|
||||||
|
|
||||||
|
predefined_tags = [
|
||||||
|
{"value": "SharedTag", "global_shared": True},
|
||||||
|
{"value": "InternalTag", "global_shared": False},
|
||||||
|
{"value": "ImplicitPrivateTag"},
|
||||||
|
]
|
||||||
|
predefined_paths = [
|
||||||
|
{"value": "Shared/Path", "global_shared": True},
|
||||||
|
{"value": "Internal/Path", "global_shared": False},
|
||||||
|
{"value": "Implicit/Private"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(documents_routes_module, "read_predefined_tags_settings", return_value=predefined_tags),
|
||||||
|
patch.object(documents_routes_module, "read_predefined_paths_settings", return_value=predefined_paths),
|
||||||
|
):
|
||||||
|
tags_response = documents_routes_module.list_tags(
|
||||||
|
include_trashed=False,
|
||||||
|
auth_context=self._auth_context(UserRole.ADMIN),
|
||||||
|
session=self._SessionStub(values=[["admin-tag"]]),
|
||||||
|
)
|
||||||
|
paths_response = documents_routes_module.list_paths(
|
||||||
|
include_trashed=False,
|
||||||
|
auth_context=self._auth_context(UserRole.ADMIN),
|
||||||
|
session=self._SessionStub(values=["Admin/Path"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tags_response["tags"],
|
||||||
|
["ImplicitPrivateTag", "InternalTag", "SharedTag", "admin-tag"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
paths_response["paths"],
|
||||||
|
["Admin/Path", "Implicit/Private", "Internal/Path", "Shared/Path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedisPipeline:
|
||||||
|
"""Provides deterministic Redis pipeline behavior for login throttle tests."""
|
||||||
|
|
||||||
|
def __init__(self, redis_client: "_FakeRedis") -> None:
|
||||||
|
self._redis_client = redis_client
|
||||||
|
self._operations: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
|
||||||
|
def incr(self, key: str, amount: int) -> "_FakeRedisPipeline":
|
||||||
|
"""Queues one counter increment operation for pipeline execution."""
|
||||||
|
|
||||||
|
self._operations.append(("incr", (key, amount)))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def expire(self, key: str, ttl_seconds: int) -> "_FakeRedisPipeline":
|
||||||
|
"""Queues one key expiration operation for pipeline execution."""
|
||||||
|
|
||||||
|
self._operations.append(("expire", (key, ttl_seconds)))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def execute(self) -> list[object]:
|
||||||
|
"""Executes queued operations in order and returns Redis-like result values."""
|
||||||
|
|
||||||
|
results: list[object] = []
|
||||||
|
for operation, arguments in self._operations:
|
||||||
|
if operation == "incr":
|
||||||
|
key, amount = arguments
|
||||||
|
previous = int(self._redis_client.values.get(str(key), 0))
|
||||||
|
updated = previous + int(amount)
|
||||||
|
self._redis_client.values[str(key)] = updated
|
||||||
|
results.append(updated)
|
||||||
|
elif operation == "expire":
|
||||||
|
key, ttl_seconds = arguments
|
||||||
|
self._redis_client.ttl_seconds[str(key)] = int(ttl_seconds)
|
||||||
|
results.append(True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
"""In-memory Redis replacement with TTL behavior needed by throttle tests."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.values: dict[str, object] = {}
|
||||||
|
self.ttl_seconds: dict[str, int] = {}
|
||||||
|
|
||||||
|
def pipeline(self, transaction: bool = True) -> _FakeRedisPipeline:
|
||||||
|
"""Creates a fake transaction pipeline for grouped increment operations."""
|
||||||
|
|
||||||
|
_ = transaction
|
||||||
|
return _FakeRedisPipeline(self)
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, ex: int | None = None) -> bool:
|
||||||
|
"""Stores key values and optional TTL metadata used by lockout keys."""
|
||||||
|
|
||||||
|
self.values[key] = value
|
||||||
|
if ex is not None:
|
||||||
|
self.ttl_seconds[key] = int(ex)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ttl(self, key: str) -> int:
|
||||||
|
"""Returns TTL for existing keys or Redis-compatible missing-key indicator."""
|
||||||
|
|
||||||
|
return int(self.ttl_seconds.get(key, -2))
|
||||||
|
|
||||||
|
def delete(self, *keys: str) -> int:
|
||||||
|
"""Deletes keys and returns number of removed entries."""
|
||||||
|
|
||||||
|
removed_count = 0
|
||||||
|
for key in keys:
|
||||||
|
if key in self.values:
|
||||||
|
self.values.pop(key, None)
|
||||||
|
removed_count += 1
|
||||||
|
self.ttl_seconds.pop(key, None)
|
||||||
|
return removed_count
|
||||||
|
|
||||||
|
|
||||||
|
def _login_throttle_settings(
|
||||||
|
*,
|
||||||
|
failure_limit: int = 2,
|
||||||
|
failure_window_seconds: int = 60,
|
||||||
|
lockout_base_seconds: int = 10,
|
||||||
|
lockout_max_seconds: int = 40,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
"""Builds deterministic login-throttle settings for service-level unit coverage."""
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
auth_login_failure_limit=failure_limit,
|
||||||
|
auth_login_failure_window_seconds=failure_window_seconds,
|
||||||
|
auth_login_lockout_base_seconds=lockout_base_seconds,
|
||||||
|
auth_login_lockout_max_seconds=lockout_max_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginThrottleServiceTests(unittest.TestCase):
|
||||||
|
"""Verifies login throttle lockout progression, cap behavior, and clear semantics."""
|
||||||
|
|
||||||
|
def test_failed_attempts_trigger_lockout_after_limit(self) -> None:
|
||||||
|
"""Failed attempts beyond configured limit activate login lockouts."""
|
||||||
|
|
||||||
|
fake_redis = _FakeRedis()
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_login_throttle_module,
|
||||||
|
"get_settings",
|
||||||
|
return_value=_login_throttle_settings(failure_limit=2, lockout_base_seconds=12),
|
||||||
|
),
|
||||||
|
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
lockout_seconds = auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
)
|
||||||
|
status = auth_login_throttle_module.check_login_throttle(
|
||||||
|
username="admin",
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(lockout_seconds, 12)
|
||||||
|
self.assertTrue(status.is_throttled)
|
||||||
|
self.assertEqual(status.retry_after_seconds, 12)
|
||||||
|
|
||||||
|
def test_lockout_duration_escalates_and_respects_max_cap(self) -> None:
|
||||||
|
"""Repeated failures after threshold double lockout duration up to configured maximum."""
|
||||||
|
|
||||||
|
fake_redis = _FakeRedis()
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_login_throttle_module,
|
||||||
|
"get_settings",
|
||||||
|
return_value=_login_throttle_settings(
|
||||||
|
failure_limit=1,
|
||||||
|
lockout_base_seconds=10,
|
||||||
|
lockout_max_seconds=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
|
||||||
|
):
|
||||||
|
first_lockout = auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="198.51.100.15",
|
||||||
|
)
|
||||||
|
second_lockout = auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="198.51.100.15",
|
||||||
|
)
|
||||||
|
third_lockout = auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="198.51.100.15",
|
||||||
|
)
|
||||||
|
fourth_lockout = auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="198.51.100.15",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first_lockout, 0)
|
||||||
|
self.assertEqual(second_lockout, 10)
|
||||||
|
self.assertEqual(third_lockout, 20)
|
||||||
|
self.assertEqual(fourth_lockout, 25)
|
||||||
|
|
||||||
|
def test_clear_login_throttle_removes_active_lockout_state(self) -> None:
|
||||||
|
"""Successful login clears active lockout keys for username and IP subjects."""
|
||||||
|
|
||||||
|
fake_redis = _FakeRedis()
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_login_throttle_module,
|
||||||
|
"get_settings",
|
||||||
|
return_value=_login_throttle_settings(
|
||||||
|
failure_limit=1,
|
||||||
|
lockout_base_seconds=15,
|
||||||
|
lockout_max_seconds=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
|
||||||
|
):
|
||||||
|
auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="192.0.2.20",
|
||||||
|
)
|
||||||
|
auth_login_throttle_module.record_failed_login_attempt(
|
||||||
|
username="admin",
|
||||||
|
ip_address="192.0.2.20",
|
||||||
|
)
|
||||||
|
throttled_before_clear = auth_login_throttle_module.check_login_throttle(
|
||||||
|
username="admin",
|
||||||
|
ip_address="192.0.2.20",
|
||||||
|
)
|
||||||
|
auth_login_throttle_module.clear_login_throttle(
|
||||||
|
username="admin",
|
||||||
|
ip_address="192.0.2.20",
|
||||||
|
)
|
||||||
|
throttled_after_clear = auth_login_throttle_module.check_login_throttle(
|
||||||
|
username="admin",
|
||||||
|
ip_address="192.0.2.20",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(throttled_before_clear.is_throttled)
|
||||||
|
self.assertFalse(throttled_after_clear.is_throttled)
|
||||||
|
self.assertEqual(throttled_after_clear.retry_after_seconds, 0)
|
||||||
|
|
||||||
|
def test_backend_errors_raise_runtime_error(self) -> None:
|
||||||
|
"""Redis backend failures are surfaced as RuntimeError for caller fail-closed handling."""
|
||||||
|
|
||||||
|
class _BrokenRedis:
|
||||||
|
"""Raises RedisError for all Redis interactions used by login throttle service."""
|
||||||
|
|
||||||
|
def ttl(self, _key: str) -> int:
|
||||||
|
raise auth_login_throttle_module.RedisError("redis unavailable")
|
||||||
|
|
||||||
|
with patch.object(auth_login_throttle_module, "get_redis", return_value=_BrokenRedis()):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
auth_login_throttle_module.check_login_throttle(
|
||||||
|
username="admin",
|
||||||
|
ip_address="203.0.113.88",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginRouteThrottleTests(unittest.TestCase):
|
||||||
|
"""Verifies `/auth/login` route throttle responses and success-flow state clearing."""
|
||||||
|
|
||||||
|
class _SessionStub:
|
||||||
|
"""Tracks commit calls for route-level login tests without database dependencies."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.commit_count = 0
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
"""Records one commit invocation."""
|
||||||
|
|
||||||
|
self.commit_count += 1
|
||||||
|
|
||||||
|
class _ResponseStub:
|
||||||
|
"""Captures response cookie calls for direct route invocation tests."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.set_cookie_calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
||||||
|
self.delete_cookie_calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
||||||
|
|
||||||
|
def set_cookie(self, *args: object, **kwargs: object) -> None:
|
||||||
|
"""Records one set-cookie call."""
|
||||||
|
|
||||||
|
self.set_cookie_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
def delete_cookie(self, *args: object, **kwargs: object) -> None:
|
||||||
|
"""Records one delete-cookie call."""
|
||||||
|
|
||||||
|
self.delete_cookie_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _response_stub(cls) -> "AuthLoginRouteThrottleTests._ResponseStub":
|
||||||
|
"""Builds a minimal response object for direct route invocation."""
|
||||||
|
|
||||||
|
return cls._ResponseStub()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _request_stub(
|
||||||
|
ip_address: str = "203.0.113.2",
|
||||||
|
user_agent: str = "unit-test",
|
||||||
|
origin: str | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
"""Builds request-like object containing client host and user-agent header fields."""
|
||||||
|
|
||||||
|
headers = {"user-agent": user_agent}
|
||||||
|
if origin:
|
||||||
|
headers["origin"] = origin
|
||||||
|
return SimpleNamespace(
|
||||||
|
client=SimpleNamespace(host=ip_address),
|
||||||
|
headers=headers,
|
||||||
|
url=SimpleNamespace(hostname="api.docs.lan"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_when_precheck_reports_active_throttle(self) -> None:
|
||||||
|
"""Pre-auth throttle checks return a stable 429 response without credential lookup."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
throttled = auth_login_throttle_module.LoginThrottleStatus(
|
||||||
|
is_throttled=True,
|
||||||
|
retry_after_seconds=21,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(auth_routes_module, "check_login_throttle", return_value=throttled),
|
||||||
|
patch.object(auth_routes_module, "authenticate_user") as authenticate_mock,
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(),
|
||||||
|
response=self._response_stub(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL)
|
||||||
|
self.assertEqual(raised.exception.headers.get("Retry-After"), "21")
|
||||||
|
authenticate_mock.assert_not_called()
|
||||||
|
self.assertEqual(session.commit_count, 0)
|
||||||
|
|
||||||
|
def test_login_returns_throttle_response_when_failure_crosses_limit(self) -> None:
|
||||||
|
"""Failed credentials return stable 429 response once lockout threshold is crossed."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_routes_module,
|
||||||
|
"check_login_throttle",
|
||||||
|
return_value=auth_login_throttle_module.LoginThrottleStatus(
|
||||||
|
is_throttled=False,
|
||||||
|
retry_after_seconds=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_routes_module, "authenticate_user", return_value=None),
|
||||||
|
patch.object(auth_routes_module, "record_failed_login_attempt", return_value=30),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(),
|
||||||
|
response=self._response_stub(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL)
|
||||||
|
self.assertEqual(raised.exception.headers.get("Retry-After"), "30")
|
||||||
|
self.assertEqual(session.commit_count, 0)
|
||||||
|
|
||||||
|
def test_login_clears_throttle_state_after_successful_authentication(self) -> None:
|
||||||
|
"""Successful login clears throttle state and commits issued session token."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="correct-password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
fake_user = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
fake_session = SimpleNamespace(
|
||||||
|
token="session-token",
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_routes_module,
|
||||||
|
"check_login_throttle",
|
||||||
|
return_value=auth_login_throttle_module.LoginThrottleStatus(
|
||||||
|
is_throttled=False,
|
||||||
|
retry_after_seconds=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_routes_module, "authenticate_user", return_value=fake_user),
|
||||||
|
patch.object(auth_routes_module, "clear_login_throttle") as clear_mock,
|
||||||
|
patch.object(auth_routes_module, "issue_user_session", return_value=fake_session),
|
||||||
|
):
|
||||||
|
response = auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(),
|
||||||
|
response=self._response_stub(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.access_token, "session-token")
|
||||||
|
self.assertEqual(response.user.username, "admin")
|
||||||
|
clear_mock.assert_called_once()
|
||||||
|
self.assertEqual(session.commit_count, 1)
|
||||||
|
|
||||||
|
def test_login_returns_503_when_throttle_backend_is_unavailable(self) -> None:
|
||||||
|
"""Throttle backend errors fail closed with a deterministic 503 login response."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
with patch.object(auth_routes_module, "check_login_throttle", side_effect=RuntimeError("redis down")):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(),
|
||||||
|
response=self._response_stub(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 503)
|
||||||
|
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL)
|
||||||
|
self.assertEqual(session.commit_count, 0)
|
||||||
|
|
||||||
|
def test_login_sets_host_only_and_parent_domain_cookie_variants(self) -> None:
|
||||||
|
"""Successful login sets a host-only cookie and an optional parent-domain mirror."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="correct-password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
response_stub = self._response_stub()
|
||||||
|
fake_user = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
fake_session = SimpleNamespace(
|
||||||
|
token="session-token",
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
fake_settings = SimpleNamespace(
|
||||||
|
auth_cookie_domain="docs.lan",
|
||||||
|
auth_cookie_samesite="none",
|
||||||
|
public_base_url="https://api.docs.lan",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_routes_module,
|
||||||
|
"check_login_throttle",
|
||||||
|
return_value=auth_login_throttle_module.LoginThrottleStatus(
|
||||||
|
is_throttled=False,
|
||||||
|
retry_after_seconds=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_routes_module, "authenticate_user", return_value=fake_user),
|
||||||
|
patch.object(auth_routes_module, "clear_login_throttle"),
|
||||||
|
patch.object(auth_routes_module, "issue_user_session", return_value=fake_session),
|
||||||
|
patch.object(auth_routes_module, "get_settings", return_value=fake_settings),
|
||||||
|
patch.object(auth_routes_module.secrets, "token_urlsafe", return_value="csrf-token"),
|
||||||
|
):
|
||||||
|
auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(origin="https://docs.lan"),
|
||||||
|
response=response_stub,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_cookie_calls = [call for call in response_stub.set_cookie_calls if call[0][0] == auth_routes_module.SESSION_COOKIE_NAME]
|
||||||
|
csrf_cookie_calls = [call for call in response_stub.set_cookie_calls if call[0][0] == auth_routes_module.CSRF_COOKIE_NAME]
|
||||||
|
self.assertEqual(len(session_cookie_calls), 2)
|
||||||
|
self.assertEqual(len(csrf_cookie_calls), 2)
|
||||||
|
self.assertFalse(any("domain" in kwargs and kwargs["domain"] is None for _args, kwargs in session_cookie_calls))
|
||||||
|
self.assertIn("domain", session_cookie_calls[1][1])
|
||||||
|
self.assertEqual(session_cookie_calls[1][1]["domain"], "docs.lan")
|
||||||
|
self.assertEqual(session_cookie_calls[0][1]["samesite"], "lax")
|
||||||
|
|
||||||
|
|
||||||
class ProviderBaseUrlValidationTests(unittest.TestCase):
|
class ProviderBaseUrlValidationTests(unittest.TestCase):
|
||||||
"""Verifies allowlist, scheme, and private-network SSRF protections."""
|
"""Verifies allowlist, scheme, and private-network SSRF protections."""
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]:
|
|||||||
"app.core.config",
|
"app.core.config",
|
||||||
"app.db.base",
|
"app.db.base",
|
||||||
"app.services.app_settings",
|
"app.services.app_settings",
|
||||||
|
"app.services.authentication",
|
||||||
"app.services.handwriting_style",
|
"app.services.handwriting_style",
|
||||||
"app.services.storage",
|
"app.services.storage",
|
||||||
"app.services.typesense_index",
|
"app.services.typesense_index",
|
||||||
@@ -139,6 +140,14 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]:
|
|||||||
app_settings_stub.ensure_app_settings = ensure_app_settings
|
app_settings_stub.ensure_app_settings = ensure_app_settings
|
||||||
sys.modules["app.services.app_settings"] = app_settings_stub
|
sys.modules["app.services.app_settings"] = app_settings_stub
|
||||||
|
|
||||||
|
authentication_stub = ModuleType("app.services.authentication")
|
||||||
|
|
||||||
|
def ensure_bootstrap_users() -> None:
|
||||||
|
"""No-op bootstrap user initializer for middleware scope tests."""
|
||||||
|
|
||||||
|
authentication_stub.ensure_bootstrap_users = ensure_bootstrap_users
|
||||||
|
sys.modules["app.services.authentication"] = authentication_stub
|
||||||
|
|
||||||
handwriting_style_stub = ModuleType("app.services.handwriting_style")
|
handwriting_style_stub = ModuleType("app.services.handwriting_style")
|
||||||
|
|
||||||
def ensure_handwriting_style_collection() -> None:
|
def ensure_handwriting_style_collection() -> None:
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ This directory contains technical documentation for DMS.
|
|||||||
|
|
||||||
- `../README.md` - project overview, setup, and quick operations
|
- `../README.md` - project overview, setup, and quick operations
|
||||||
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
||||||
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, role and ownership scope, upload limits, and settings or processing-log security constraints
|
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, login throttle responses, role and ownership scope, upload limits, and settings or processing-log security constraints
|
||||||
- `data-model-reference.md` - database entity definitions and lifecycle states
|
- `data-model-reference.md` - database entity definitions and lifecycle states
|
||||||
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, and persisted settings configuration behavior
|
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, persisted settings configuration behavior, and frontend Vite host allowlist controls
|
||||||
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance
|
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance
|
||||||
- `../.env.example` - repository-level environment template with local defaults and production override guidance
|
- `../.env.example` - repository-level environment template with local defaults and production override guidance
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ Primary implementation modules:
|
|||||||
|
|
||||||
## Authentication And Authorization
|
## Authentication And Authorization
|
||||||
|
|
||||||
- Authentication is session-based bearer auth.
|
- Authentication is cookie-based session auth with a server-issued hashed session token.
|
||||||
- Clients authenticate with `POST /auth/login` using username and password.
|
- Clients authenticate with `POST /auth/login` using username and password.
|
||||||
- Backend issues per-user bearer session tokens and stores hashed session state server-side.
|
- Backend issues a server-stored session token and sets `HttpOnly` `dcm_session` and readable `dcm_csrf` cookies.
|
||||||
- Clients send issued tokens as `Authorization: Bearer <token>`.
|
- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
|
||||||
- `GET /auth/me` returns current identity and role.
|
- State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
|
||||||
|
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
|
||||||
|
- `GET /auth/me` returns current identity, role, and current CSRF token.
|
||||||
- `POST /auth/logout` revokes current session token.
|
- `POST /auth/logout` revokes current session token.
|
||||||
|
|
||||||
Role matrix:
|
Role matrix:
|
||||||
@@ -35,6 +37,10 @@ Ownership rules:
|
|||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
- Body model: `AuthLoginRequest`
|
- Body model: `AuthLoginRequest`
|
||||||
- Response model: `AuthLoginResponse`
|
- Response model: `AuthLoginResponse`
|
||||||
|
- Additional responses:
|
||||||
|
- `401` for invalid credentials
|
||||||
|
- `429` for throttled login attempts, with stable message and `Retry-After` header
|
||||||
|
- `503` when the login rate-limiter backend is unavailable
|
||||||
- `GET /auth/me`
|
- `GET /auth/me`
|
||||||
- Response model: `AuthSessionResponse`
|
- Response model: `AuthSessionResponse`
|
||||||
- `POST /auth/logout`
|
- `POST /auth/logout`
|
||||||
@@ -56,9 +62,15 @@ Ownership rules:
|
|||||||
- `GET /documents/tags`
|
- `GET /documents/tags`
|
||||||
- Query: `include_trashed`
|
- Query: `include_trashed`
|
||||||
- Response: `{ "tags": string[] }`
|
- Response: `{ "tags": string[] }`
|
||||||
|
- Behavior:
|
||||||
|
- all document-assigned tags visible to caller scope are included
|
||||||
|
- predefined tags are role-filtered: `admin` receives full catalog, `user` receives only entries with `global_shared=true`
|
||||||
- `GET /documents/paths`
|
- `GET /documents/paths`
|
||||||
- Query: `include_trashed`
|
- Query: `include_trashed`
|
||||||
- Response: `{ "paths": string[] }`
|
- Response: `{ "paths": string[] }`
|
||||||
|
- Behavior:
|
||||||
|
- all document-assigned logical paths visible to caller scope are included
|
||||||
|
- predefined paths are role-filtered: `admin` receives full catalog, `user` receives only entries with `global_shared=true`
|
||||||
- `GET /documents/types`
|
- `GET /documents/types`
|
||||||
- Query: `include_trashed`
|
- Query: `include_trashed`
|
||||||
- Response: `{ "types": string[] }`
|
- Response: `{ "types": string[] }`
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
|||||||
## Authenticated Media Delivery
|
## Authenticated Media Delivery
|
||||||
|
|
||||||
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
|
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
|
||||||
- Runtime auth uses server-issued per-user session tokens persisted with `setRuntimeApiToken` and read by `getRuntimeApiToken`.
|
- Runtime auth is cookie-backed; valid sessions are reused by browser reload and tab reuse while the `dcm_session` cookie remains valid.
|
||||||
- Static build-time token distribution is not supported.
|
- Static build-time token distribution is not supported.
|
||||||
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
|
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
|
||||||
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
|
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
|
||||||
|
|||||||
@@ -10,16 +10,17 @@
|
|||||||
- `worker` (RQ worker via `python -m app.worker.run_worker`)
|
- `worker` (RQ worker via `python -m app.worker.run_worker`)
|
||||||
- `frontend` (Vite React UI)
|
- `frontend` (Vite React UI)
|
||||||
|
|
||||||
Persistent volumes:
|
Persistent host bind mounts (default root `./data`, overridable with `DCM_DATA_DIR`):
|
||||||
- `db-data`
|
- `${DCM_DATA_DIR:-./data}/db-data`
|
||||||
- `redis-data`
|
- `${DCM_DATA_DIR:-./data}/redis-data`
|
||||||
- `dcm-storage`
|
- `${DCM_DATA_DIR:-./data}/storage`
|
||||||
- `typesense-data`
|
- `${DCM_DATA_DIR:-./data}/typesense-data`
|
||||||
|
|
||||||
Reset all persisted runtime data:
|
Reset all persisted runtime data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down
|
||||||
|
rm -rf ${DCM_DATA_DIR:-./data}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core Commands
|
## Core Commands
|
||||||
@@ -42,16 +43,41 @@ Tail logs:
|
|||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Host Bind Mounts
|
||||||
|
|
||||||
|
Compose is configured with host bind mounts for persistent data. Ensure host directories exist and are writable by the backend runtime user.
|
||||||
|
|
||||||
|
Backend and worker run as non-root user `uid=10001` inside containers. Compose bootstraps the storage bind mount through the one-shot `storage-init` service before either process starts. For manual inspection or repair of host-mounted storage paths:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chown -R 10001:10001 ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chmod -R u+rwX,g+rwX ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
If permissions are incorrect, API startup fails with errors similar to:
|
||||||
|
- `PermissionError: [Errno 13] Permission denied: '/data/storage'`
|
||||||
|
- `FileNotFoundError` for `/data/storage/originals`
|
||||||
|
|
||||||
|
## Frontend Build Baseline
|
||||||
|
|
||||||
|
The frontend Dockerfile uses `node:22-slim` with a standard `npm ci --no-audit` install step and no npm-specific build tuning flags.
|
||||||
|
|
||||||
## Authentication Model
|
## Authentication Model
|
||||||
|
|
||||||
- Legacy shared build-time frontend token behavior was removed.
|
- Legacy shared build-time frontend token behavior was removed.
|
||||||
- API now uses server-issued per-user bearer sessions.
|
- API now uses server-issued sessions that are stored in HttpOnly cookies (`dcm_session`) with a separate CSRF cookie (`dcm_csrf`).
|
||||||
- Bootstrap users are provisioned from environment:
|
- Bootstrap users are provisioned from environment:
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
|
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
|
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
|
||||||
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
|
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
|
||||||
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
|
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
|
||||||
- Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage.
|
- Login brute-force protection is enabled by default and keyed by username and source IP:
|
||||||
|
- `AUTH_LOGIN_FAILURE_LIMIT`
|
||||||
|
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
|
||||||
|
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
|
||||||
|
- `AUTH_LOGIN_LOCKOUT_MAX_SECONDS`
|
||||||
|
- Frontend signs in through `/api/v1/auth/login` and relies on browser session persistence for valid cookie-backed sessions.
|
||||||
|
|
||||||
## DEV And LIVE Configuration Matrix
|
## DEV And LIVE Configuration Matrix
|
||||||
|
|
||||||
@@ -61,12 +87,19 @@ Use `.env.example` as baseline. The table below documents user-managed settings
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | `development` | `production` |
|
| `APP_ENV` | `development` | `production` |
|
||||||
| `HOST_BIND_IP` | `127.0.0.1` or local LAN bind if needed | `127.0.0.1` (publish behind proxy only) |
|
| `HOST_BIND_IP` | `127.0.0.1` or local LAN bind if needed | `127.0.0.1` (publish behind proxy only) |
|
||||||
| `PUBLIC_BASE_URL` | `http://localhost:8000` | `https://api.example.com` |
|
| `PUBLIC_BASE_URL` | `http://localhost:8000` or same-origin frontend host when proxying API through frontend | `https://app.example.com` when frontend proxies `/api`, or dedicated API origin if you intentionally keep split-origin routing |
|
||||||
| `VITE_API_BASE` | empty for host-derived `http://<frontend-host>:8000/api/v1`, or explicit local URL | `https://api.example.com/api/v1` |
|
| `VITE_API_BASE` | empty to use same-origin `/api/v1` through frontend proxy, or explicit local URL when bypassing proxy | empty or `/api/v1` for same-origin production routing; only use `https://api.example.com/api/v1` when you intentionally keep split-origin frontend/API traffic |
|
||||||
|
| `VITE_ALLOWED_HOSTS` | optional comma-separated hostnames, for example `localhost,docs.lan` | optional comma-separated public frontend hostnames, for example `app.example.com` |
|
||||||
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | exact frontend origins only, for example `["https://app.example.com"]` |
|
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | exact frontend origins only, for example `["https://app.example.com"]` |
|
||||||
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
|
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
|
||||||
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
|
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
|
||||||
| `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` |
|
| `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` |
|
||||||
|
| `AUTH_LOGIN_FAILURE_LIMIT` | default `5` | tune to identity-protection policy and support requirements |
|
||||||
|
| `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
||||||
|
| `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` | default `30` | tune to identity-protection policy and support requirements |
|
||||||
|
| `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
||||||
|
| `AUTH_COOKIE_DOMAIN` | empty (recommended; API always issues a host-only auth cookie) | optional parent domain only when you explicitly need a mirrored domain cookie, for example `docs.lan` |
|
||||||
|
| `AUTH_COOKIE_SAMESITE` | `auto` | `none` only for truly cross-site frontend/API deployments; keep `auto` for same-site subdomains such as `docs.lan` and `api.docs.lan` |
|
||||||
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
|
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
|
||||||
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
|
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
|
||||||
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
|
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
|
||||||
@@ -89,7 +122,7 @@ Recommended LIVE pattern:
|
|||||||
2. Keep container published ports bound to localhost or internal network.
|
2. Keep container published ports bound to localhost or internal network.
|
||||||
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
|
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
|
||||||
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins.
|
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins.
|
||||||
5. Credentialed CORS is intentionally disabled in application code for bearer-header auth.
|
5. Credentialed CORS is enabled and constrained for cookie-based sessions with strict origin allowlists.
|
||||||
|
|
||||||
## Security Controls
|
## Security Controls
|
||||||
|
|
||||||
@@ -99,6 +132,7 @@ Recommended LIVE pattern:
|
|||||||
- legacy `enc-v1` payloads are read for backward compatibility
|
- legacy `enc-v1` payloads are read for backward compatibility
|
||||||
- new writes use `enc-v2`
|
- new writes use `enc-v2`
|
||||||
- Processing logs default to metadata-only persistence.
|
- Processing logs default to metadata-only persistence.
|
||||||
|
- Login endpoint applies escalating temporary lockout on repeated failed credentials using Redis-backed subject keys for username and source IP.
|
||||||
- Markdown export enforces:
|
- Markdown export enforces:
|
||||||
- max document count
|
- max document count
|
||||||
- max total markdown bytes
|
- max total markdown bytes
|
||||||
@@ -109,7 +143,19 @@ Recommended LIVE pattern:
|
|||||||
## Frontend Runtime
|
## Frontend Runtime
|
||||||
|
|
||||||
- Frontend no longer consumes `VITE_API_TOKEN`.
|
- Frontend no longer consumes `VITE_API_TOKEN`.
|
||||||
- Session token storage key is `dcm.access_token` in browser session storage.
|
- Frontend image target is environment-driven:
|
||||||
|
- `APP_ENV=development` builds the `development` target and runs Vite dev server
|
||||||
|
- `APP_ENV=production` builds the `production` target and serves static assets through unprivileged Nginx
|
||||||
|
- Frontend Docker targets are selected from `APP_ENV`, so use `development` or `production` values.
|
||||||
|
- Production frontend Nginx uses non-root runtime plus `/tmp` temp-path configuration so it can run with container capability dropping enabled.
|
||||||
|
- Vite dev server host allowlist uses the union of:
|
||||||
|
- hostnames extracted from `CORS_ORIGINS`
|
||||||
|
- optional explicit hostnames from `VITE_ALLOWED_HOSTS`
|
||||||
|
- `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running.
|
||||||
|
- API auth cookies support optional domain and SameSite configuration through `AUTH_COOKIE_DOMAIN` and `AUTH_COOKIE_SAMESITE`.
|
||||||
|
- HTTPS cookie security detection falls back to `PUBLIC_BASE_URL` scheme when proxy headers are missing.
|
||||||
|
- CSRF validation accepts header matches against any `dcm_csrf` cookie value in the request, covering stale plus fresh duplicate-cookie transitions.
|
||||||
|
- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
|
||||||
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
|
storage-init:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
user: "0:0"
|
||||||
|
command:
|
||||||
|
- "sh"
|
||||||
|
- "-c"
|
||||||
|
- >
|
||||||
|
mkdir -p /data/storage/originals /data/storage/derived/previews /data/storage/tmp &&
|
||||||
|
chown -R 10001:10001 /data/storage &&
|
||||||
|
chmod -R u+rwX,g+rwX /data/storage
|
||||||
|
volumes:
|
||||||
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
@@ -6,12 +21,15 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set}
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- ${DCM_DATA_DIR:-./data}/db-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER must be set} -d ${POSTGRES_DB:?POSTGRES_DB must be set}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER must be set} -d ${POSTGRES_DB:?POSTGRES_DB must be set}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -22,16 +40,21 @@ services:
|
|||||||
- "--requirepass"
|
- "--requirepass"
|
||||||
- "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"
|
- "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- ${DCM_DATA_DIR:-./data}/redis-data:/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
typesense:
|
typesense:
|
||||||
image: typesense/typesense:29.0
|
image: typesense/typesense:30.2.rc6
|
||||||
command:
|
command:
|
||||||
- "--data-dir=/data"
|
- "--data-dir=/data"
|
||||||
- "--api-key=${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}"
|
- "--api-key=${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}"
|
||||||
- "--enable-cors"
|
- "--enable-cors"
|
||||||
volumes:
|
volumes:
|
||||||
- typesense-data:/data
|
- ${DCM_DATA_DIR:-./data}/typesense-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
@@ -47,6 +70,10 @@ services:
|
|||||||
AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set}
|
AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set}
|
||||||
AUTH_BOOTSTRAP_USER_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-}
|
AUTH_BOOTSTRAP_USER_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-}
|
||||||
AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-}
|
AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-}
|
||||||
|
AUTH_LOGIN_FAILURE_LIMIT: ${AUTH_LOGIN_FAILURE_LIMIT:-5}
|
||||||
|
AUTH_LOGIN_FAILURE_WINDOW_SECONDS: ${AUTH_LOGIN_FAILURE_WINDOW_SECONDS:-900}
|
||||||
|
AUTH_LOGIN_LOCKOUT_BASE_SECONDS: ${AUTH_LOGIN_LOCKOUT_BASE_SECONDS:-30}
|
||||||
|
AUTH_LOGIN_LOCKOUT_MAX_SECONDS: ${AUTH_LOGIN_LOCKOUT_MAX_SECONDS:-900}
|
||||||
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
||||||
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
|
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
|
||||||
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
|
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
|
||||||
@@ -64,22 +91,29 @@ services:
|
|||||||
TYPESENSE_PORT: 8108
|
TYPESENSE_PORT: 8108
|
||||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}
|
||||||
TYPESENSE_COLLECTION_NAME: documents
|
TYPESENSE_COLLECTION_NAME: documents
|
||||||
ports:
|
# ports:
|
||||||
- "${HOST_BIND_IP:-127.0.0.1}:8000:8000"
|
# - "${HOST_BIND_IP:-127.0.0.1}:8000:8000"
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- dcm-storage:/data
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
typesense:
|
typesense:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
npm_proxy:
|
||||||
|
ipv4_address: 192.168.98.41
|
||||||
|
internal:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
@@ -107,7 +141,7 @@ services:
|
|||||||
TYPESENSE_COLLECTION_NAME: documents
|
TYPESENSE_COLLECTION_NAME: documents
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- dcm-storage:/data
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
@@ -117,16 +151,27 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
typesense:
|
typesense:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
target: ${APP_ENV:-development}
|
||||||
|
args:
|
||||||
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE: ${VITE_API_BASE:-}
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
ports:
|
VITE_API_PROXY_TARGET: ${VITE_API_PROXY_TARGET:-http://api:8000}
|
||||||
- "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
|
||||||
|
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-}
|
||||||
|
# ports:
|
||||||
|
# - "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/src:/app/src
|
- ./frontend/src:/app/src
|
||||||
- ./frontend/index.html:/app/index.html
|
- ./frontend/index.html:/app/index.html
|
||||||
@@ -138,9 +183,14 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
networks:
|
||||||
|
npm_proxy:
|
||||||
|
ipv4_address: 192.168.98.40
|
||||||
|
internal:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
networks:
|
||||||
db-data:
|
internal:
|
||||||
redis-data:
|
driver: bridge
|
||||||
dcm-storage:
|
npm_proxy:
|
||||||
typesense-data:
|
external: true
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
FROM node:22-alpine
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY package-lock.json /app/package-lock.json
|
COPY package-lock.json /app/package-lock.json
|
||||||
RUN npm ci
|
RUN npm ci --no-audit \
|
||||||
RUN chown -R node:node /app
|
&& chown -R node:node /app
|
||||||
|
|
||||||
COPY --chown=node:node tsconfig.json /app/tsconfig.json
|
COPY --chown=node:node tsconfig.json /app/tsconfig.json
|
||||||
COPY --chown=node:node tsconfig.node.json /app/tsconfig.node.json
|
COPY --chown=node:node tsconfig.node.json /app/tsconfig.node.json
|
||||||
@@ -13,8 +13,32 @@ COPY --chown=node:node vite.config.ts /app/vite.config.ts
|
|||||||
COPY --chown=node:node index.html /app/index.html
|
COPY --chown=node:node index.html /app/index.html
|
||||||
COPY --chown=node:node src /app/src
|
COPY --chown=node:node src /app/src
|
||||||
|
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
|
ARG VITE_API_BASE=
|
||||||
|
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS production
|
||||||
|
|
||||||
|
COPY nginx-main.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
RUN mkdir -p /tmp/client_temp /tmp/proxy_temp /tmp/fastcgi_temp /tmp/uwsgi_temp /tmp/scgi_temp \
|
||||||
|
&& chown -R 101:101 /tmp /var/log/nginx /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
USER 101:101
|
||||||
|
|
||||||
|
ENTRYPOINT ["nginx"]
|
||||||
|
CMD ["-g", "daemon off;"]
|
||||||
|
|||||||
22
frontend/nginx-main.conf
Normal file
22
frontend/nginx-main.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 5173;
|
||||||
|
listen [::]:5173;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
deleteDocument,
|
deleteDocument,
|
||||||
exportContentsMarkdown,
|
exportContentsMarkdown,
|
||||||
getCurrentAuthSession,
|
getCurrentAuthSession,
|
||||||
getRuntimeApiToken,
|
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
listDocuments,
|
listDocuments,
|
||||||
listPaths,
|
listPaths,
|
||||||
@@ -30,7 +29,6 @@ import {
|
|||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
logoutCurrentSession,
|
logoutCurrentSession,
|
||||||
resetAppSettings,
|
resetAppSettings,
|
||||||
setRuntimeApiToken,
|
|
||||||
searchDocuments,
|
searchDocuments,
|
||||||
trashDocument,
|
trashDocument,
|
||||||
updateAppSettings,
|
updateAppSettings,
|
||||||
@@ -161,21 +159,19 @@ export default function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges submitted credentials for server-issued bearer session and activates app shell.
|
* Exchanges submitted credentials for a server-issued session and activates the app shell.
|
||||||
*/
|
*/
|
||||||
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
|
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
|
||||||
setIsAuthenticating(true);
|
setIsAuthenticating(true);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await loginWithPassword(username, password);
|
const payload = await loginWithPassword(username, password);
|
||||||
setRuntimeApiToken(payload.access_token);
|
|
||||||
setAuthUser(payload.user);
|
setAuthUser(payload.user);
|
||||||
setAuthPhase('authenticated');
|
setAuthPhase('authenticated');
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
|
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
resetApplicationState();
|
resetApplicationState();
|
||||||
@@ -192,7 +188,6 @@ export default function App(): JSX.Element {
|
|||||||
try {
|
try {
|
||||||
await logoutCurrentSession();
|
await logoutCurrentSession();
|
||||||
} catch {}
|
} catch {}
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
@@ -303,13 +298,6 @@ export default function App(): JSX.Element {
|
|||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const existingToken = getRuntimeApiToken();
|
|
||||||
if (!existingToken) {
|
|
||||||
setAuthPhase('unauthenticated');
|
|
||||||
setAuthUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveSession = async (): Promise<void> => {
|
const resolveSession = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const sessionPayload = await getCurrentAuthSession();
|
const sessionPayload = await getCurrentAuthSession();
|
||||||
@@ -317,7 +305,6 @@ export default function App(): JSX.Element {
|
|||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthPhase('authenticated');
|
setAuthPhase('authenticated');
|
||||||
} catch {
|
} catch {
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
resetApplicationState();
|
resetApplicationState();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface LoginScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders credential form used to issue per-user API bearer sessions.
|
* Renders credential form used to issue per-user API sessions.
|
||||||
*/
|
*/
|
||||||
export default function LoginScreen({
|
export default function LoginScreen({
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
getCurrentAuthSession,
|
getCurrentAuthSession,
|
||||||
getDocumentPreviewBlob,
|
getDocumentPreviewBlob,
|
||||||
getDocumentThumbnailBlob,
|
getDocumentThumbnailBlob,
|
||||||
getRuntimeApiToken,
|
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
logoutCurrentSession,
|
logoutCurrentSession,
|
||||||
setRuntimeApiToken,
|
|
||||||
updateDocumentMetadata,
|
updateDocumentMetadata,
|
||||||
} from './api.ts';
|
} from './api.ts';
|
||||||
|
|
||||||
@@ -48,53 +46,23 @@ function toRequestUrl(input: RequestInfo | URL): string {
|
|||||||
return input.url;
|
return input.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a minimal session storage implementation for Node-based tests.
|
|
||||||
*/
|
|
||||||
function createMemorySessionStorage(): Storage {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
get length(): number {
|
|
||||||
return values.size;
|
|
||||||
},
|
|
||||||
clear(): void {
|
|
||||||
values.clear();
|
|
||||||
},
|
|
||||||
getItem(key: string): string | null {
|
|
||||||
return values.has(key) ? values.get(key) ?? null : null;
|
|
||||||
},
|
|
||||||
key(index: number): string | null {
|
|
||||||
return Array.from(values.keys())[index] ?? null;
|
|
||||||
},
|
|
||||||
removeItem(key: string): void {
|
|
||||||
values.delete(key);
|
|
||||||
},
|
|
||||||
setItem(key: string, value: string): void {
|
|
||||||
values.set(key, String(value));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs API helper tests for authenticated media and auth session workflows.
|
* Runs API helper tests for authenticated media and auth session workflows.
|
||||||
*/
|
*/
|
||||||
async function runApiTests(): Promise<void> {
|
async function runApiTests(): Promise<void> {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } };
|
||||||
|
const originalDocument = globalWithDocument.document;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: createMemorySessionStorage(),
|
|
||||||
});
|
|
||||||
setRuntimeApiToken(null);
|
|
||||||
|
|
||||||
const requestUrls: string[] = [];
|
const requestUrls: string[] = [];
|
||||||
const requestAuthHeaders: Array<string | null> = [];
|
const requestAuthHeaders: Array<string | null> = [];
|
||||||
|
const requestCsrfHeaders: Array<string | null> = [];
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
requestUrls.push(toRequestUrl(input));
|
requestUrls.push(toRequestUrl(input));
|
||||||
requestAuthHeaders.push(new Headers(init?.headers).get('Authorization'));
|
const normalizedHeaders = new Headers(init?.headers);
|
||||||
|
requestAuthHeaders.push(normalizedHeaders.get('Authorization'));
|
||||||
|
requestCsrfHeaders.push(normalizedHeaders.get('x-csrf-token'));
|
||||||
return new Response('preview-bytes', { status: 200 });
|
return new Response('preview-bytes', { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
@@ -113,27 +81,26 @@ async function runApiTests(): Promise<void> {
|
|||||||
);
|
);
|
||||||
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
||||||
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
||||||
|
assert(requestCsrfHeaders[0] === null, `Expected no CSRF header for thumbnail request, got "${requestCsrfHeaders[0]}"`);
|
||||||
|
assert(requestCsrfHeaders[1] === null, `Expected no CSRF header for preview request, got "${requestCsrfHeaders[1]}"`);
|
||||||
|
|
||||||
setRuntimeApiToken('session-user-token');
|
globalWithDocument.document = {
|
||||||
assert(getRuntimeApiToken() === 'session-user-token', 'Expected session token readback to match persisted token');
|
cookie: 'dcm_csrf=csrf-session-token',
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
};
|
||||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
let metadataCsrfHeader: string | null = null;
|
||||||
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
|
let metadataContentType: string | null = null;
|
||||||
return new Response('preview-bytes', { status: 200 });
|
let metadataAuthHeader: string | null = null;
|
||||||
}) as typeof fetch;
|
|
||||||
await getDocumentPreviewBlob('doc-session-auth');
|
|
||||||
|
|
||||||
let mergedContentType: string | null = null;
|
|
||||||
let mergedAuthorization: string | null = null;
|
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
mergedContentType = headers.get('Content-Type');
|
metadataCsrfHeader = headers.get('x-csrf-token');
|
||||||
mergedAuthorization = headers.get('Authorization');
|
metadataAuthHeader = headers.get('Authorization');
|
||||||
|
metadataContentType = headers.get('Content-Type');
|
||||||
return new Response('{}', { status: 200 });
|
return new Response('{}', { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
||||||
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
|
assert(metadataContentType === 'application/json', `Expected JSON content type to be preserved, got "${metadataContentType}"`);
|
||||||
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
|
assert(metadataAuthHeader === null, `Expected no auth header, got "${metadataAuthHeader}"`);
|
||||||
|
assert(metadataCsrfHeader === 'csrf-session-token', `Expected CSRF header, got "${metadataCsrfHeader}"`);
|
||||||
|
|
||||||
globalThis.fetch = (async (): Promise<Response> => {
|
globalThis.fetch = (async (): Promise<Response> => {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -202,13 +169,12 @@ async function runApiTests(): Promise<void> {
|
|||||||
|
|
||||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||||
} finally {
|
} finally {
|
||||||
setRuntimeApiToken(null);
|
|
||||||
if (sessionStorageDescriptor) {
|
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
|
|
||||||
} else {
|
|
||||||
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
||||||
}
|
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalDocument !== undefined) {
|
||||||
|
globalWithDocument.document = originalDocument;
|
||||||
|
} else {
|
||||||
|
delete globalWithDocument.document;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves backend base URL from environment with host-derived HTTP fallback.
|
* Resolves backend base URL from environment with same-origin proxy fallback.
|
||||||
*/
|
*/
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const envValue = import.meta.env?.VITE_API_BASE;
|
const envValue = import.meta.env?.VITE_API_BASE;
|
||||||
@@ -27,8 +27,8 @@ function resolveApiBase(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.location?.hostname) {
|
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||||
return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`;
|
return '/api/v1';
|
||||||
}
|
}
|
||||||
return 'http://localhost:8000/api/v1';
|
return 'http://localhost:8000/api/v1';
|
||||||
}
|
}
|
||||||
@@ -36,90 +36,101 @@ function resolveApiBase(): string {
|
|||||||
const API_BASE = resolveApiBase();
|
const API_BASE = resolveApiBase();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session storage key used for per-user runtime token persistence.
|
* CSRF cookie contract used by authenticated requests.
|
||||||
*/
|
*/
|
||||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
|
const CSRF_COOKIE_NAME = "dcm_csrf";
|
||||||
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
const CSRF_SESSION_STORAGE_KEY = "dcm_csrf_token";
|
||||||
|
|
||||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||||
|
|
||||||
type ApiErrorPayload = { detail?: string } | null;
|
type ApiErrorPayload = { detail?: string } | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
|
* Returns a cookie value by name for the active browser runtime.
|
||||||
*/
|
*/
|
||||||
function normalizeBearerToken(candidate: unknown): string | undefined {
|
function getCookieValue(name: string): string | undefined {
|
||||||
if (typeof candidate !== 'string') {
|
if (typeof document === "undefined") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const normalized = candidate.trim();
|
const rawCookie = document.cookie ?? "";
|
||||||
return normalized ? normalized : undefined;
|
return rawCookie
|
||||||
|
.split(";")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find((entry) => entry.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves bearer token persisted for current browser session.
|
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
||||||
*/
|
*/
|
||||||
export function getRuntimeApiToken(): string | undefined {
|
function resolveCsrfToken(): string | undefined {
|
||||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
const cookieToken = getCookieValue(CSRF_COOKIE_NAME);
|
||||||
return undefined;
|
if (cookieToken) {
|
||||||
}
|
return cookieToken;
|
||||||
try {
|
|
||||||
return normalizeBearerToken(globalThis.sessionStorage.getItem(API_TOKEN_RUNTIME_STORAGE_KEY));
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return loadStoredCsrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves bearer token from authenticated browser-session storage.
|
* Loads the runtime CSRF token from browser session storage.
|
||||||
*/
|
*/
|
||||||
function resolveApiToken(): string | undefined {
|
function loadStoredCsrfToken(): string | undefined {
|
||||||
return getRuntimeApiToken();
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rawValue = window.sessionStorage.getItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
|
const normalizedValue = rawValue?.trim();
|
||||||
|
return normalizedValue ? normalizedValue : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores or clears the per-user runtime API token in session storage.
|
* Persists or clears a runtime CSRF token in browser session storage.
|
||||||
*
|
|
||||||
* @param token Token value to persist for this browser session; clears persisted token when empty.
|
|
||||||
*/
|
*/
|
||||||
export function setRuntimeApiToken(token: string | null | undefined): void {
|
function persistCsrfToken(token: string | undefined | null): void {
|
||||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const normalizedValue = typeof token === "string" ? token.trim() : "";
|
||||||
const normalized = normalizeBearerToken(token);
|
if (!normalizedValue) {
|
||||||
if (normalized) {
|
window.sessionStorage.removeItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
globalThis.sessionStorage.setItem(API_TOKEN_RUNTIME_STORAGE_KEY, normalized);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalThis.sessionStorage.removeItem(API_TOKEN_RUNTIME_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
window.sessionStorage.setItem(CSRF_SESSION_STORAGE_KEY, normalizedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges request headers and appends bearer authorization when a token can be resolved.
|
* Returns whether a method should include CSRF metadata.
|
||||||
*/
|
*/
|
||||||
function buildRequestHeaders(headers?: HeadersInit): Headers | undefined {
|
function requiresCsrfHeader(method: string): boolean {
|
||||||
const apiToken = resolveApiToken();
|
const normalizedMethod = method.toUpperCase();
|
||||||
if (!apiToken && !headers) {
|
return !CSRF_SAFE_METHODS.has(normalizedMethod);
|
||||||
return undefined;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges request headers and appends CSRF metadata for state-changing requests.
|
||||||
|
*/
|
||||||
|
function buildRequestHeaders(method: string, headers?: HeadersInit): Headers | undefined {
|
||||||
const requestHeaders = new Headers(headers);
|
const requestHeaders = new Headers(headers);
|
||||||
if (apiToken) {
|
if (method && requiresCsrfHeader(method)) {
|
||||||
requestHeaders.set('Authorization', `Bearer ${apiToken}`);
|
const csrfToken = resolveCsrfToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
requestHeaders.set(CSRF_HEADER_NAME, csrfToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return requestHeaders;
|
return requestHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes an API request with centralized auth-header handling.
|
* Executes an API request with shared fetch options and CSRF handling.
|
||||||
*/
|
*/
|
||||||
function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
|
function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
|
||||||
const headers = buildRequestHeaders(init.headers);
|
const method = init.method ?? "GET";
|
||||||
|
const headers = buildRequestHeaders(method, init.headers);
|
||||||
return fetch(input, {
|
return fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
...(headers ? { headers } : {}),
|
...(headers ? { headers } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,11 +194,12 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
|
* Authenticates one user and returns authenticated session metadata.
|
||||||
*/
|
*/
|
||||||
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
||||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
@@ -201,7 +213,9 @@ export async function loginWithPassword(username: string, password: string): Pro
|
|||||||
}
|
}
|
||||||
throw new Error('Login failed');
|
throw new Error('Login failed');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthLoginResponse>;
|
const payload = await (response.json() as Promise<AuthLoginResponse>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,16 +230,19 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
|||||||
}
|
}
|
||||||
throw new Error('Failed to load authentication session');
|
throw new Error('Failed to load authentication session');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthSessionInfo>;
|
const payload = await (response.json() as Promise<AuthSessionInfo>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revokes the current authenticated bearer session.
|
* Revokes the current authenticated session.
|
||||||
*/
|
*/
|
||||||
export async function logoutCurrentSession(): Promise<void> {
|
export async function logoutCurrentSession(): Promise<void> {
|
||||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
persistCsrfToken(undefined);
|
||||||
if (!response.ok && response.status !== 401) {
|
if (!response.ok && response.status !== 401) {
|
||||||
const detail = await responseErrorDetail(response);
|
const detail = await responseErrorDetail(response);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|||||||
@@ -73,14 +73,16 @@ export interface AuthUser {
|
|||||||
export interface AuthSessionInfo {
|
export interface AuthSessionInfo {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
csrf_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents login response payload with issued bearer token and session metadata.
|
* Represents login response payload with issued session metadata.
|
||||||
*/
|
*/
|
||||||
export interface AuthLoginResponse extends AuthSessionInfo {
|
export interface AuthLoginResponse extends AuthSessionInfo {
|
||||||
access_token: string;
|
access_token?: string;
|
||||||
token_type: 'bearer';
|
token_type: 'bearer';
|
||||||
|
csrf_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,93 @@
|
|||||||
/**
|
/**
|
||||||
* Vite configuration for the DMS frontend application.
|
* Vite configuration for the DMS frontend application.
|
||||||
*/
|
*/
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a comma-separated environment value into normalized entries.
|
||||||
|
*
|
||||||
|
* @param rawValue Raw comma-separated value.
|
||||||
|
* @returns List of non-empty normalized entries.
|
||||||
|
*/
|
||||||
|
function parseCsvList(rawValue: string | undefined): string[] {
|
||||||
|
if (!rawValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts hostnames from CORS origin values.
|
||||||
|
*
|
||||||
|
* @param rawValue JSON array string or comma-separated origin list.
|
||||||
|
* @returns Hostnames parsed from valid origins.
|
||||||
|
*/
|
||||||
|
function parseCorsOriginHosts(rawValue: string | undefined): string[] {
|
||||||
|
if (!rawValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let origins: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedOrigins = JSON.parse(rawValue);
|
||||||
|
if (Array.isArray(parsedOrigins)) {
|
||||||
|
origins = parsedOrigins.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
} else if (typeof parsedOrigins === 'string') {
|
||||||
|
origins = [parsedOrigins];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
origins = parseCsvList(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return origins.flatMap((origin) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(origin);
|
||||||
|
return parsedUrl.hostname ? [parsedUrl.hostname] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Vite allowed host list from environment-driven inputs.
|
||||||
|
*
|
||||||
|
* @param env Environment variable key-value map.
|
||||||
|
* @returns De-duplicated hostnames, or undefined to keep Vite defaults.
|
||||||
|
*/
|
||||||
|
function buildAllowedHosts(env: Record<string, string>): string[] | undefined {
|
||||||
|
const explicitHosts = parseCsvList(env.VITE_ALLOWED_HOSTS);
|
||||||
|
const corsOriginHosts = parseCorsOriginHosts(env.CORS_ORIGINS);
|
||||||
|
const mergedHosts = Array.from(new Set([...explicitHosts, ...corsOriginHosts]));
|
||||||
|
|
||||||
|
return mergedHosts.length > 0 ? mergedHosts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports frontend build and dev-server settings.
|
* Exports frontend build and dev-server settings.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const allowedHosts = buildAllowedHosts(env);
|
||||||
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET?.trim() || 'http://localhost:8000';
|
||||||
|
|
||||||
|
return {
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: false,
|
||||||
|
secure: false,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
...(allowedHosts ? { allowedHosts } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user