Compare commits

...

29 Commits

Author SHA1 Message Date
tototomate123 5d613ea550 perf(collab): reduce cursor and preview update churn 2026-02-14 16:38:40 +01:00
tototomate123 0ffe410eeb feat(collab): add server-authoritative sync and preview-only updates 2026-02-13 20:47:05 +01:00
tototomate123 fd5470ada5 fix(editor): flush lifecycle saves and recover from version conflicts 2026-02-13 19:17:25 +01:00
tototomate123 75cbe97bc0 feat(collab): restore cross-account sharing and reliable realtime sync 2026-02-13 19:02:03 +01:00
tototomate123 12da89b815 Update README.md 2026-02-13 16:33:15 +00:00
tototomate123 9e248f9751 css fix 2026-02-12 20:38:05 +01:00
tototomate123 fe58cf7e89 update to excalidraw 0.18.0 2026-02-12 20:32:53 +01:00
tototomate123 6061d4ab94 fix(auth): align frontend password validation with production policy 2026-02-12 19:58:13 +01:00
tototomate123 6fe2ab3d28 fix(deploy): align /api routing, socket path, and proxy-aware auth limits 2026-02-12 19:43:49 +01:00
tototomate123 e05edff84d fix socket in editor 2026-02-12 19:29:17 +01:00
tototomate123 da131834ce add production stuff 2026-02-12 19:22:40 +01:00
tototomate123 08d2165a70 fix(dashboard): normalize route id params for express 5 typings 2026-02-12 19:10:41 +01:00
Zimeng Xiong 2cbd11cf0d fix impersonation issues 2026-02-10 22:45:00 -08:00
Zimeng Xiong 1c71a08bbe Plan OIDC integration and audit 2026-02-10 14:45:34 -08:00
Zimeng Xiong bb028ef2db fix csrf token hardset, remove cookie from localstorage 2026-02-10 13:16:04 -08:00
Zimeng Xiong 1117dc584e resolve e2e 2026-02-07 19:24:00 -08:00
Zimeng Xiong 70103e18fb sign CSRF with cookie, Login rate-limit key hardened against identifier-only lockout 2026-02-07 18:52:00 -08:00
Zimeng Xiong fd013de325 add tests on refactor 2026-02-07 18:03:05 -08:00
Zimeng Xiong 6bee0e2ded refactor index.ts 2026-02-07 17:47:41 -08:00
Zimeng Xiong 35bbbb9599 images in preview 2026-02-07 17:21:58 -08:00
Zimeng Xiong 2aa749a2f0 prevent preview updates from overwriting drawings 2026-02-07 15:51:35 -08:00
Zimeng Xiong 02736d663a chore: pre-release v0.4.6-dev 2026-02-07 12:46:00 -08:00
Zimeng Xiong de254d46f2 concurrency 2026-02-07 12:45:33 -08:00
Zimeng Xiong dd0f381ed1 chore: pre-release v0.4.5-dev 2026-02-07 12:09:21 -08:00
Zimeng Xiong c40a5f46a0 fix colliding drawing IDs 2026-02-07 12:09:02 -08:00
Zimeng Xiong 8fcca43b0d chore: pre-release v0.4.4-dev 2026-02-07 11:58:09 -08:00
Zimeng Xiong f20412cdfb separate debounced autosave 2026-02-07 11:57:32 -08:00
Zimeng Xiong a366acfedc chore: pre-release v0.4.3-dev 2026-02-07 11:08:03 -08:00
Zimeng Xiong 154dcbb151 update resopnsiveness hamburger 2026-02-07 11:07:15 -08:00
110 changed files with 21133 additions and 5969 deletions
@@ -0,0 +1 @@
../../../frontend
+53
View File
@@ -6,6 +6,8 @@
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com)
_Original repo can be found [here](https://github.com/ZimengXiong/ExcaliDash)_
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features. A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
## Screenshots ## Screenshots
@@ -101,6 +103,8 @@ docker compose -f docker-compose.prod.yml up -d
For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly. For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly.
By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers.
## Docker Build ## Docker Build
[Install Docker](https://docs.docker.com/desktop/) [Install Docker](https://docs.docker.com/desktop/)
@@ -123,6 +127,7 @@ docker compose up -d
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly: When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses. - `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname. - `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
```yaml ```yaml
@@ -131,6 +136,8 @@ backend:
environment: environment:
# Single URL # Single URL
- FRONTEND_URL=https://excalidash.example.com - FRONTEND_URL=https://excalidash.example.com
# Trust exactly one reverse-proxy hop
- TRUST_PROXY=1
# Or multiple URLs (comma-separated) for local + network access # Or multiple URLs (comma-separated) for local + network access
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767 # - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
frontend: frontend:
@@ -160,6 +167,31 @@ backend:
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting. Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
### Authentication Modes (Local + OIDC)
ExcaliDash supports three auth modes via backend `AUTH_MODE`:
- `local` (default): native email/password login only.
- `hybrid`: native login + OIDC login.
- `oidc_enforced`: OIDC-only login (native login/register disabled).
For OIDC modes (`hybrid` or `oidc_enforced`), set:
```yaml
backend:
environment:
- AUTH_MODE=oidc_enforced
- OIDC_PROVIDER_NAME=Authentik
- OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
- OIDC_CLIENT_ID=your-client-id
- OIDC_CLIENT_SECRET=your-client-secret
- OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
- OIDC_SCOPES=openid profile email
```
In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`.
Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned.
# Development # Development
## Clone the Repository ## Clone the Repository
@@ -200,6 +232,27 @@ npx prisma db push
npm run dev npm run dev
``` ```
### Simulate Auth Onboarding (Development)
To simulate first-run authentication choice flows in local development:
```bash
cd ExcaliDash/backend
# Preview what would change (no data modifications)
npm run dev:simulate-auth-onboarding:dry-run
# Simulate "fresh install" onboarding state
# (wipes drawings/collections/libraries and removes non-bootstrap users)
npm run dev:simulate-auth-onboarding:fresh
# Simulate "migration" onboarding state (ensures legacy data exists)
npm run dev:simulate-auth-onboarding:migration
```
After running a simulation while the backend is already running, wait about 5 seconds
(auth mode cache TTL) or restart the backend before refreshing the UI.
## Project Structure ## Project Structure
``` ```
+1 -1
View File
@@ -1 +1 @@
0.4.2 0.4.6
+18 -1
View File
@@ -2,7 +2,11 @@
PORT=8000 PORT=8000
NODE_ENV=production NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767 FRONTEND_URL=https://draw.louiscreates.com
API_BASE_PATH=/api
# Keep disabled unless traffic always comes through a trusted reverse proxy.
TRUST_PROXY=false
AUTH_MODE=local
JWT_SECRET=change-this-secret-in-production-min-32-chars JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility) # Optional Feature Flags (all default to false for backward compatibility)
@@ -10,3 +14,16 @@ JWT_SECRET=change-this-secret-in-production-min-32-chars
# ENABLE_PASSWORD_RESET=false # ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false # ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false # ENABLE_AUDIT_LOGGING=false
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
# OIDC_PROVIDER_NAME=Authentik
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
# OIDC_CLIENT_ID=your-client-id
# OIDC_CLIENT_SECRET=your-client-secret
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
# OIDC_SCOPES=openid profile email
# OIDC_EMAIL_CLAIM=email
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
# OIDC_REQUIRE_EMAIL_VERIFIED=true
# OIDC_JIT_PROVISIONING=true
# OIDC_FIRST_USER_ADMIN=true
+24 -1
View File
@@ -2,6 +2,7 @@
set -e set -e
JWT_SECRET_FILE="/app/prisma/.jwt_secret" JWT_SECRET_FILE="/app/prisma/.jwt_secret"
CSRF_SECRET_FILE="/app/prisma/.csrf_secret"
# Ensure JWT secret exists for production startup. # Ensure JWT secret exists for production startup.
# Backward compatibility: older installs may not have JWT_SECRET configured. # Backward compatibility: older installs may not have JWT_SECRET configured.
@@ -25,6 +26,27 @@ fi
export JWT_SECRET export JWT_SECRET
# Ensure CSRF secret exists for stable token validation across restarts.
# (Still recommend setting explicitly for multi-instance deployments.)
if [ -z "${CSRF_SECRET:-}" ]; then
echo "CSRF_SECRET not provided, resolving persisted secret..."
if [ -f "${CSRF_SECRET_FILE}" ]; then
CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")"
fi
if [ -z "${CSRF_SECRET}" ]; then
echo "No persisted CSRF secret found. Generating a new secret..."
CSRF_SECRET="$(openssl rand -base64 32)"
umask 077
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
fi
else
umask 077
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
fi
export CSRF_SECRET
# 1. Hydrate volume if empty (Running as root) # 1. Hydrate volume if empty (Running as root)
if [ ! -f "/app/prisma/schema.prisma" ]; then if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..." echo "Mount is empty. Hydrating /app/prisma..."
@@ -43,11 +65,12 @@ chown -R nodejs:nodejs /app/uploads
chown -R nodejs:nodejs /app/prisma chown -R nodejs:nodejs /app/prisma
chmod 755 /app/uploads chmod 755 /app/uploads
chmod 600 "${JWT_SECRET_FILE}" chmod 600 "${JWT_SECRET_FILE}"
chmod 600 "${CSRF_SECRET_FILE}"
# Ensure database file has proper permissions # Ensure database file has proper permissions
if [ -f "/app/prisma/dev.db" ]; then if [ -f "/app/prisma/dev.db" ]; then
echo "Database file found, ensuring write permissions..." echo "Database file found, ensuring write permissions..."
chmod 666 /app/prisma/dev.db chmod 600 /app/prisma/dev.db
fi fi
# 3. Run Migrations (Drop privileges to nodejs) # 3. Run Migrations (Drop privileges to nodejs)
+63 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "backend", "name": "backend",
"version": "0.4.1", "version": "0.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "backend", "name": "backend",
"version": "0.4.1", "version": "0.4.6",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
@@ -24,6 +24,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -3314,6 +3315,15 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "22.1.0", "version": "22.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
@@ -3909,6 +3919,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3932,6 +3951,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3953,6 +3981,33 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5789,6 +5844,12 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yn": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+5 -1
View File
@@ -1,12 +1,15 @@
{ {
"name": "backend", "name": "backend",
"version": "0.4.2", "version": "0.4.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"predev": "node scripts/predev-migrate.cjs", "predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts", "dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs", "admin:recover": "node scripts/admin-recover.cjs",
"dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh",
"dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration",
"dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -31,6 +34,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
+3783
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
-- Track whether initial auth mode choice has been explicitly completed.
ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "AuthIdentity" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"emailAtLink" TEXT NOT NULL,
"lastLoginAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject");
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId");
-- CreateIndex
CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId");
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "DrawingShareLink" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DrawingShareGrant" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"shareLinkId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
-- CreateIndex
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
+54
View File
@@ -21,11 +21,13 @@ model User {
role String @default("USER") role String @default("USER")
mustResetPassword Boolean @default(false) mustResetPassword Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
authIdentities AuthIdentity[]
drawings Drawing[] drawings Drawing[]
collections Collection[] collections Collection[]
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
auditLogs AuditLog[] auditLogs AuditLog[]
drawingShareGrants DrawingShareGrant[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -33,6 +35,7 @@ model User {
model SystemConfig { model SystemConfig {
id String @id @default("default") id String @id @default("default")
authEnabled Boolean @default(false) authEnabled Boolean @default(false)
authOnboardingCompleted Boolean @default(false)
registrationEnabled Boolean @default(false) registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true) authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
@@ -65,6 +68,8 @@ model Drawing {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String? collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id]) collection Collection? @relation(fields: [collectionId], references: [id])
shareLinks DrawingShareLink[]
shareGrants DrawingShareGrant[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -72,6 +77,38 @@ model Drawing {
@@index([userId, collectionId, updatedAt]) @@index([userId, collectionId, updatedAt])
} }
model DrawingShareLink {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
role String
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
grants DrawingShareGrant[]
@@unique([drawingId, role])
@@index([drawingId])
}
model DrawingShareGrant {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shareLinkId String
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
role String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([drawingId, userId, shareLinkId])
@@index([drawingId, userId])
@@index([userId, createdAt])
}
model Library { model Library {
id String @id // User-specific library ID (e.g., "user_<userId>") id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items items String @default("[]") // Stored as JSON string array of library items
@@ -110,3 +147,20 @@ model AuditLog {
details String? // JSON string for additional details details String? // JSON string for additional details
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model AuthIdentity {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
issuer String
subject String
emailAtLink String
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([issuer, subject])
@@unique([provider, userId])
@@index([userId])
}
+22 -2
View File
@@ -24,7 +24,10 @@ const resolveDatabaseUrl = (rawUrl) => {
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); : path.resolve(
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -91,7 +94,15 @@ const backupDbIfPresent = () => {
const isNonProd = nodeEnv !== "production"; const isNonProd = nodeEnv !== "production";
const isFileDb = databaseUrl.startsWith("file:"); const isFileDb = databaseUrl.startsWith("file:");
const deploy = runCapture("npx prisma migrate deploy"); let deploy = runCapture("npx prisma migrate deploy");
if (!deploy.ok) {
console.warn(
`[predev] Prisma migrate deploy failed. Attempting pnpm exec...`,
);
deploy = runCapture("pnpm exec prisma migrate deploy");
}
if (deploy.ok) { if (deploy.ok) {
if (deploy.stdout) process.stdout.write(deploy.stdout); if (deploy.stdout) process.stdout.write(deploy.stdout);
} else { } else {
@@ -111,7 +122,16 @@ if (deploy.ok) {
` If you need to preserve local data, restore the backup and baseline manually.`, ` If you need to preserve local data, restore the backup and baseline manually.`,
); );
// check for npx, if not present try pnpm exec
try {
console.log(
`[predev] Running: npx prisma migrate reset --force --skip-seed`,
);
run("npx prisma migrate reset --force --skip-seed"); run("npx prisma migrate reset --force --skip-seed");
} catch {
console.warn(`[predev] npx not found, trying pnpm exec...`);
run("pnpm exec prisma migrate reset --force --skip-seed");
}
} else { } else {
throw deploy.error; throw deploy.error;
} }
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env node
require("dotenv").config();
const path = require("path");
const { execSync } = require("child_process");
const { PrismaClient } = require("../src/generated/client");
const BOOTSTRAP_USER_ID = "bootstrap-admin";
const DEFAULT_SYSTEM_CONFIG_ID = "default";
const backendRoot = path.resolve(__dirname, "..");
const resolveDatabaseUrl = (rawUrl) => {
const backendRoot = path.resolve(__dirname, "..");
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || String(rawUrl).trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!String(rawUrl).startsWith("file:")) {
return String(rawUrl);
}
const filePath = String(rawUrl).replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
const parseArgs = (argv) => {
const parsed = {
scenario: "",
dryRun: false,
allowProd: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--scenario") {
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--dry-run") {
parsed.dryRun = true;
continue;
}
if (token === "--allow-production") {
parsed.allowProd = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
}
return parsed;
};
const usage = () => {
console.log(`Usage:
node scripts/simulate-auth-onboarding.cjs --scenario fresh
node scripts/simulate-auth-onboarding.cjs --scenario migration
Options:
--dry-run Show what would change without modifying data
--allow-production Override production safety check (not recommended)
--help, -h Show this help
`);
};
const assertScenario = (scenario) => {
if (scenario !== "fresh" && scenario !== "migration") {
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
}
};
const nowIso = () => new Date().toISOString();
const run = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
assertScenario(args.scenario);
const nodeEnv = process.env.NODE_ENV || "development";
if (nodeEnv === "production" && !args.allowProd) {
throw new Error(
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
);
}
// Keep migration history authoritative to avoid drift between db push and deploy.
// Includes a self-heal path for the known duplicate-column failure on
// 20260210153000_add_auth_onboarding_completed in local dev databases.
if (nodeEnv !== "production") {
const runDeploy = () =>
execSync("npx prisma migrate deploy", {
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
});
try {
runDeploy();
} catch (error) {
const stdout =
error && error.stdout
? Buffer.isBuffer(error.stdout)
? error.stdout.toString("utf8")
: String(error.stdout)
: "";
const stderr =
error && error.stderr
? Buffer.isBuffer(error.stderr)
? error.stderr.toString("utf8")
: String(error.stderr)
: "";
const combined = `${stdout}\n${stderr}`;
const canAutoResolve =
combined.includes("Error: P3009") &&
combined.includes("20260210153000_add_auth_onboarding_completed") &&
combined.includes("duplicate column name: authOnboardingCompleted");
if (!canAutoResolve) {
throw error;
}
execSync(
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
{
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
}
);
runDeploy();
}
}
const prisma = new PrismaClient();
try {
const before = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
console.log("[simulate-auth-onboarding] before:", before);
if (args.dryRun) {
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
return;
}
await prisma.$transaction(async (tx) => {
await tx.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
await tx.user.updateMany({
data: {
isActive: false,
mustResetPassword: true,
},
});
await tx.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
if (args.scenario === "fresh") {
await tx.drawing.deleteMany({});
await tx.collection.deleteMany({});
await tx.library.deleteMany({});
await tx.user.deleteMany({
where: {
id: {
not: BOOTSTRAP_USER_ID,
},
},
});
return;
}
// Migration simulation:
// 1) Reassign existing data ownership to bootstrap user
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
await tx.collection.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
await tx.drawing.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
const collectionCount = await tx.collection.count();
let targetCollectionId = null;
if (collectionCount === 0) {
targetCollectionId = `sim-migration-col-${Date.now()}`;
await tx.collection.create({
data: {
id: targetCollectionId,
name: "Migrated Collection",
userId: BOOTSTRAP_USER_ID,
},
});
} else {
const existing = await tx.collection.findFirst({
where: { userId: BOOTSTRAP_USER_ID },
select: { id: true },
orderBy: { createdAt: "asc" },
});
targetCollectionId = existing ? existing.id : null;
}
const drawingCount = await tx.drawing.count();
if (drawingCount === 0) {
await tx.drawing.create({
data: {
id: `sim-migration-draw-${Date.now()}`,
name: "Migrated Drawing",
elements: "[]",
appState: "{}",
files: "{}",
preview: null,
version: 1,
userId: BOOTSTRAP_USER_ID,
collectionId: targetCollectionId,
},
});
}
});
const after = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log("[simulate-auth-onboarding] after:", after);
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
console.log(
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
);
} finally {
await prisma.$disconnect().catch(() => {});
}
};
run().catch((error) => {
console.error("simulate-auth-onboarding failed:", error.message || error);
process.exitCode = 1;
});
@@ -0,0 +1,139 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import bcrypt from "bcrypt";
import jwt, { SignOptions } from "jsonwebtoken";
import { StringValue } from "ms";
import { PrismaClient } from "../generated/client";
import { config } from "../config";
import { getTestPrisma, setupTestDb } from "./testUtils";
describe("Auth Enabled Toggle Authorization", () => {
const userAgent = "vitest-auth-enabled";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
let regularUserToken: string;
let adminUserToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
({ app } = await import("../index"));
await prisma.systemConfig.upsert({
where: { id: "default" },
update: {
authEnabled: true,
registrationEnabled: false,
},
create: {
id: "default",
authEnabled: true,
registrationEnabled: false,
},
});
const passwordHash = await bcrypt.hash("password123", 10);
const user = await prisma.user.create({
data: {
email: "regular-user@test.local",
passwordHash,
name: "Regular User",
role: "USER",
isActive: true,
},
select: {
id: true,
email: true,
},
});
const signOptions: SignOptions = {
expiresIn: config.jwtAccessExpiresIn as StringValue,
};
regularUserToken = jwt.sign(
{ userId: user.id, email: user.email, type: "access" },
config.jwtSecret,
signOptions
);
const admin = await prisma.user.create({
data: {
email: "admin-user@test.local",
passwordHash,
name: "Admin User",
role: "ADMIN",
isActive: true,
},
select: {
id: true,
email: true,
},
});
adminUserToken = jwt.sign(
{ userId: admin.id, email: admin.email, type: "access" },
config.jwtSecret,
signOptions
);
agent = request.agent(app);
const csrfRes = await agent
.get("/csrf-token")
.set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
});
afterAll(async () => {
await prisma.$disconnect();
});
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(response.status).toBe(401);
});
it("rejects non-admin auth-enabled toggle", async () => {
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${regularUserToken}`)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(response.status).toBe(403);
expect(response.body?.message).toContain("Admin access required");
});
it("applies auth mode change immediately for subsequent requests", async () => {
const warmStatusResponse = await request(app)
.get("/auth/status")
.set("User-Agent", userAgent);
expect(warmStatusResponse.status).toBe(200);
expect(warmStatusResponse.body?.authEnabled).toBe(true);
const toggleResponse = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${adminUserToken}`)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(toggleResponse.status).toBe(200);
expect(toggleResponse.body?.authEnabled).toBe(false);
const drawingsResponse = await request(app)
.get("/drawings")
.set("User-Agent", userAgent);
expect(drawingsResponse.status).toBe(200);
expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true);
});
});
@@ -0,0 +1,164 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { PrismaClient } from "../generated/client";
import { getTestPrisma, setupTestDb } from "./testUtils";
import { BOOTSTRAP_USER_ID } from "../auth/authMode";
describe("Auth onboarding decision", () => {
const userAgent = "vitest-auth-onboarding";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
({ app } = await import("../index"));
agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
});
afterAll(async () => {
await prisma.$disconnect();
});
it("reports migration onboarding mode when no active users and legacy data exists", async () => {
await prisma.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authEnabled: false, authOnboardingCompleted: false },
create: {
id: "default",
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
});
await prisma.collection.upsert({
where: { id: "legacy-collection" },
update: {},
create: {
id: "legacy-collection",
name: "Legacy",
userId: BOOTSTRAP_USER_ID,
},
});
await prisma.drawing.upsert({
where: { id: "legacy-drawing" },
update: {},
create: {
id: "legacy-drawing",
name: "Legacy Drawing",
elements: "[]",
appState: "{}",
files: "{}",
userId: BOOTSTRAP_USER_ID,
collectionId: "legacy-collection",
},
});
const response = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(response.status).toBe(200);
expect(response.body?.authEnabled).toBe(false);
expect(response.body?.authOnboardingRequired).toBe(true);
expect(response.body?.authOnboardingMode).toBe("migration");
});
it("persists a single-user onboarding choice", async () => {
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: false });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(false);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("enables auth and bootstrap flow from onboarding choice", async () => {
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } });
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: true });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(true);
expect(choiceResponse.body?.bootstrapRequired).toBe(true);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authEnabled).toBe(true);
expect(statusResponse.body?.bootstrapRequired).toBe(true);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("requires CSRF token for bootstrap registration", async () => {
const noCsrfResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(noCsrfResponse.status).toBe(403);
expect(noCsrfResponse.body?.error).toBe("CSRF token missing");
const bootstrapResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(bootstrapResponse.status).toBe(201);
expect(bootstrapResponse.body?.bootstrapped).toBe(true);
expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createCsrfToken, validateCsrfToken } from "../security";
describe("CSRF client identity stability", () => {
it("keeps token validation stable when using cookie-based client IDs", () => {
const cookieClientId = "cookie:fixed-client-id";
const token = createCsrfToken(cookieClientId);
expect(validateCsrfToken(cookieClientId, token)).toBe(true);
});
it("shows why legacy IP-based IDs are unstable across proxy hops", () => {
const userAgent = "Mozilla/5.0 test";
const clientIdViaProxyA = `10.0.0.5:${userAgent}`;
const clientIdViaProxyB = `10.0.0.6:${userAgent}`;
const token = createCsrfToken(clientIdViaProxyA);
expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false);
});
});
@@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => {
}); });
}); });
describe("sanitizeDrawingData - preview svg handling", () => {
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
const preview = [
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
expect(result.preview).toContain('stroke-linecap="round"');
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
});
it("should preserve safe embedded image previews", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<image");
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
});
it("should remove embedded images with unsafe href values", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).not.toContain("<image");
expect(result.preview).toContain("<rect");
});
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
"</pattern></defs>",
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<defs>");
expect(result.preview).toContain("<pattern");
expect(result.preview).toContain('id="p1"');
expect(result.preview).toContain("<image");
expect(result.preview).toContain('fill="url(#p1)"');
});
});
describe("validateImportedDrawing - with files", () => { describe("validateImportedDrawing - with files", () => {
it("should validate drawing with embedded images", () => { it("should validate drawing with embedded images", () => {
const files = createSampleFilesObject(2, "large"); const files = createSampleFilesObject(2, "large");
@@ -3,6 +3,7 @@ import request from "supertest";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import JSZip from "jszip";
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils"; import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
type LegacyDbOptions = { type LegacyDbOptions = {
@@ -156,11 +157,117 @@ const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
return filePath; return filePath;
}; };
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
const dir = createTempDir();
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
const zip = new JSZip();
const manifest = {
format: "excalidash",
formatVersion: 1,
exportedAt: new Date().toISOString(),
unorganizedFolder: "Unorganized",
collections: [] as any[],
drawings: [
{
id: "duplicate-drawing-id",
name: "Drawing One",
filePath: "Unorganized/drawing-1.excalidraw",
collectionId: null,
},
{
id: "duplicate-drawing-id",
name: "Drawing Two",
filePath: "Unorganized/drawing-2.excalidraw",
collectionId: null,
},
],
};
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
zip.file(
"Unorganized/drawing-1.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
zip.file(
"Unorganized/drawing-2.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(filePath, buffer);
return filePath;
};
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
const dir = createTempDir();
const filePath = path.join(dir, "legacy-duplicate-ids.db");
const db = openWritableDb(filePath);
try {
db.exec(`
CREATE TABLE "Drawing" (
id TEXT,
name TEXT NOT NULL,
elements TEXT NOT NULL,
appState TEXT NOT NULL,
files TEXT,
preview TEXT,
version INTEGER,
collectionId TEXT,
collectionName TEXT,
createdAt TEXT,
updatedAt TEXT
);
`);
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
const insertDrawing = db.prepare(
`INSERT INTO "Drawing"
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing A",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing B",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
} finally {
db.close();
}
return filePath;
};
describe("Import compatibility (legacy exports)", () => { describe("Import compatibility (legacy exports)", () => {
const uploadsDir = path.resolve(__dirname, "../../uploads"); const uploadsDir = path.resolve(__dirname, "../../uploads");
const userAgent = "vitest-import-compat"; const userAgent = "vitest-import-compat";
let prisma: ReturnType<typeof getTestPrisma>; let prisma: ReturnType<typeof getTestPrisma>;
let app: any; let app: any;
let agent: any;
let csrfHeaderName: string; let csrfHeaderName: string;
let csrfToken: string; let csrfToken: string;
@@ -172,7 +279,8 @@ describe("Import compatibility (legacy exports)", () => {
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma. // Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
({ app } = await import("../index")); ({ app } = await import("../index"));
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent); agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header; csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token; csrfToken = csrfRes.body.token;
expect(typeof csrfHeaderName).toBe("string"); expect(typeof csrfHeaderName).toBe("string");
@@ -195,7 +303,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false, includeTrashDrawing: false,
}); });
const res = await request(app) const res = await agent
.post("/import/sqlite/legacy/verify") .post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent) .set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken) .set(csrfHeaderName, csrfToken)
@@ -217,7 +325,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: true, includeTrashDrawing: true,
}); });
const res = await request(app) const res = await agent
.post("/import/sqlite/legacy") .post("/import/sqlite/legacy")
.set("User-Agent", userAgent) .set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken) .set(csrfHeaderName, csrfToken)
@@ -239,7 +347,9 @@ describe("Import compatibility (legacy exports)", () => {
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"]) expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
); );
const trash = await prisma.collection.findUnique({ where: { id: "trash" } }); const trash = await prisma.collection.findUnique({
where: { id: "trash:bootstrap-admin" },
});
expect(trash).toBeTruthy(); expect(trash).toBeTruthy();
}); });
@@ -251,7 +361,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false, includeTrashDrawing: false,
}); });
const verify = await request(app) const verify = await agent
.post("/import/sqlite/legacy/verify") .post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent) .set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken) .set(csrfHeaderName, csrfToken)
@@ -261,7 +371,7 @@ describe("Import compatibility (legacy exports)", () => {
expect(verify.body.drawings).toBe(2); expect(verify.body.drawings).toBe(2);
expect(verify.body.collections).toBe(1); expect(verify.body.collections).toBe(1);
const res = await request(app) const res = await agent
.post("/import/sqlite/legacy") .post("/import/sqlite/legacy")
.set("User-Agent", userAgent) .set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken) .set(csrfHeaderName, csrfToken)
@@ -278,7 +388,7 @@ describe("Import compatibility (legacy exports)", () => {
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`); db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
db.close(); db.close();
const res = await request(app) const res = await agent
.post("/import/sqlite/legacy/verify") .post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent) .set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken) .set(csrfHeaderName, csrfToken)
@@ -287,4 +397,52 @@ describe("Import compatibility (legacy exports)", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid legacy DB"); expect(res.body.error).toBe("Invalid legacy DB");
}); });
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await agent
.post("/import/excalidash/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await agent
.post("/import/excalidash")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
}); });
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { sanitizeDrawingUpdateData } from "../index";
describe("sanitizeDrawingUpdateData regression", () => {
it("does not inject empty scene fields for preview-only updates", () => {
const payload: {
preview?: string | null;
elements?: unknown[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
} = {
preview: "<svg><rect width=\"10\" height=\"10\"/></svg>",
};
const ok = sanitizeDrawingUpdateData(payload);
expect(ok).toBe(true);
expect(typeof payload.preview).toBe("string");
expect(String(payload.preview)).toContain("<svg");
expect(payload.elements).toBeUndefined();
expect(payload.appState).toBeUndefined();
expect(payload.files).toBeUndefined();
});
it("still sanitizes scene fields when scene data is provided", () => {
const payload: {
preview?: string | null;
elements?: any[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
} = {
elements: [
{
id: "el-1",
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
version: 1,
versionNonce: 1,
isDeleted: false,
},
],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview: "<svg/>",
};
const ok = sanitizeDrawingUpdateData(payload);
expect(ok).toBe(true);
expect(Array.isArray(payload.elements)).toBe(true);
expect(typeof payload.appState).toBe("object");
});
});
+6 -7
View File
@@ -98,11 +98,9 @@ export const setupTestDb = () => {
* Clean up the test database between tests * Clean up the test database between tests
*/ */
export const cleanupTestDb = async (prisma: PrismaClient) => { export const cleanupTestDb = async (prisma: PrismaClient) => {
// Delete all drawings and collections (except Trash) // Delete all drawings and collections.
await prisma.drawing.deleteMany({}); await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({ await prisma.collection.deleteMany({});
where: { id: { not: "trash" } },
});
}; };
/** /**
@@ -129,14 +127,15 @@ export const createTestUser = async (prisma: PrismaClient, email: string = "test
export const initTestDb = async (prisma: PrismaClient) => { export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first // Create a test user first
const testUser = await createTestUser(prisma); const testUser = await createTestUser(prisma);
const trashCollectionId = `trash:${testUser.id}`;
// Ensure Trash collection exists // Ensure Trash collection exists
const trash = await prisma.collection.findUnique({ const trash = await prisma.collection.findFirst({
where: { id: "trash" }, where: { id: trashCollectionId, userId: testUser.id },
}); });
if (!trash) { if (!trash) {
await prisma.collection.create({ await prisma.collection.create({
data: { id: "trash", name: "Trash", userId: testUser.id }, data: { id: trashCollectionId, name: "Trash", userId: testUser.id },
}); });
} }
+184 -90
View File
@@ -2,14 +2,34 @@ import express, { Request, Response } from "express";
import crypto from "crypto"; import crypto from "crypto";
import jwt, { SignOptions } from "jsonwebtoken"; import jwt, { SignOptions } from "jsonwebtoken";
import ms, { type StringValue } from "ms"; import ms, { type StringValue } from "ms";
import { PrismaClient, Prisma } from "./generated/client"; import { Prisma, PrismaClient } from "./generated/client";
import { config } from "./config"; import { config } from "./config";
import { requireAuth, optionalAuth } from "./middleware/auth"; import {
requireAuth as defaultRequireAuth,
optionalAuth as defaultOptionalAuth,
authModeService as defaultAuthModeService,
} from "./middleware/auth";
import { getCsrfTokenHeader, sanitizeText, validateCsrfToken } from "./security"; import { getCsrfTokenHeader, sanitizeText, validateCsrfToken } from "./security";
import rateLimit, { MemoryStore } from "express-rate-limit"; import rateLimit, { MemoryStore } from "express-rate-limit";
import { registerAccountRoutes } from "./auth/accountRoutes"; import { registerAccountRoutes } from "./auth/accountRoutes";
import { registerAdminRoutes } from "./auth/adminRoutes"; import { registerAdminRoutes } from "./auth/adminRoutes";
import { registerCoreRoutes } from "./auth/coreRoutes"; import { registerCoreRoutes } from "./auth/coreRoutes";
import { registerOidcRoutes } from "./auth/oidcRoutes";
import { prisma as defaultPrisma } from "./db/prisma";
import {
BOOTSTRAP_USER_ID,
DEFAULT_SYSTEM_CONFIG_ID,
type AuthModeService,
} from "./auth/authMode";
import { getCsrfValidationClientIds } from "./security/csrfClient";
import {
clearAuthCookies,
readCookie,
REFRESH_TOKEN_COOKIE_NAME,
setAccessTokenCookie,
setAuthCookies,
} from "./auth/cookies";
import { getClientIp } from "./utils/clientIp";
interface JwtPayload { interface JwtPayload {
userId: string; userId: string;
@@ -30,30 +50,24 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
); );
}; };
const router = express.Router(); type CreateAuthRouterDeps = {
const prisma = new PrismaClient(); prisma: PrismaClient;
requireAuth: express.RequestHandler;
const BOOTSTRAP_USER_ID = "bootstrap-admin"; optionalAuth: express.RequestHandler;
const DEFAULT_SYSTEM_CONFIG_ID = "default"; authModeService: AuthModeService;
const ensureSystemConfig = async () => {
return prisma.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
}; };
const ensureAuthEnabled = async (res: Response): Promise<boolean> => { export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => {
const { prisma, requireAuth, optionalAuth, authModeService } = deps;
const router = express.Router();
const ensureSystemConfig = authModeService.ensureSystemConfig;
const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
const systemConfig = await ensureSystemConfig(); const systemConfig = await ensureSystemConfig();
if (!systemConfig.authEnabled) { const authEnabled =
config.authMode !== "local" ? true : systemConfig.authEnabled;
if (!authEnabled) {
res.status(404).json({ res.status(404).json({
error: "Not found", error: "Not found",
message: "Authentication is disabled", message: "Authentication is disabled",
@@ -61,38 +75,46 @@ const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
return false; return false;
} }
return true; return true;
}; };
type LoginRateLimitConfig = { type LoginRateLimitConfig = {
enabled: boolean; enabled: boolean;
windowMs: number; windowMs: number;
max: number; max: number;
}; };
const DEFAULT_LOGIN_RATE_LIMIT: LoginRateLimitConfig = { const DEFAULT_LOGIN_RATE_LIMIT: LoginRateLimitConfig = {
enabled: true, enabled: true,
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 20, max: 20,
}; };
let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT }; let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT };
let loginAttemptLimiter: ReturnType<typeof rateLimit> | null = null; let loginAttemptLimiter: ReturnType<typeof rateLimit> | null = null;
let loginLimiterInitPromise: Promise<void> | null = null; let loginLimiterInitPromise: Promise<void> | null = null;
let loginIdentifierKeyIndex = new Map<string, Set<string>>();
const parseLoginRateLimitConfig = (systemConfig: Awaited<ReturnType<typeof ensureSystemConfig>>): LoginRateLimitConfig => { const parseLoginRateLimitConfig = (
const enabled = typeof systemConfig.authLoginRateLimitEnabled === "boolean" ? systemConfig.authLoginRateLimitEnabled : DEFAULT_LOGIN_RATE_LIMIT.enabled; systemConfig: Awaited<ReturnType<typeof ensureSystemConfig>>
): LoginRateLimitConfig => {
const enabled =
typeof systemConfig.authLoginRateLimitEnabled === "boolean"
? systemConfig.authLoginRateLimitEnabled
: DEFAULT_LOGIN_RATE_LIMIT.enabled;
const windowMs = const windowMs =
Number.isFinite(Number(systemConfig.authLoginRateLimitWindowMs)) && Number(systemConfig.authLoginRateLimitWindowMs) > 0 Number.isFinite(Number(systemConfig.authLoginRateLimitWindowMs)) &&
Number(systemConfig.authLoginRateLimitWindowMs) > 0
? Number(systemConfig.authLoginRateLimitWindowMs) ? Number(systemConfig.authLoginRateLimitWindowMs)
: DEFAULT_LOGIN_RATE_LIMIT.windowMs; : DEFAULT_LOGIN_RATE_LIMIT.windowMs;
const max = const max =
Number.isFinite(Number(systemConfig.authLoginRateLimitMax)) && Number(systemConfig.authLoginRateLimitMax) > 0 Number.isFinite(Number(systemConfig.authLoginRateLimitMax)) &&
Number(systemConfig.authLoginRateLimitMax) > 0
? Number(systemConfig.authLoginRateLimitMax) ? Number(systemConfig.authLoginRateLimitMax)
: DEFAULT_LOGIN_RATE_LIMIT.max; : DEFAULT_LOGIN_RATE_LIMIT.max;
return { enabled, windowMs, max }; return { enabled, windowMs, max };
}; };
const resolveAuthIdentifier = (req: Request): string | null => { const resolveAuthIdentifier = (req: Request): string | null => {
const body = (req.body || {}) as Record<string, unknown>; const body = (req.body || {}) as Record<string, unknown>;
const raw = const raw =
(typeof body.email === "string" && body.email) || (typeof body.email === "string" && body.email) ||
@@ -102,10 +124,32 @@ const resolveAuthIdentifier = (req: Request): string | null => {
if (!raw) return null; if (!raw) return null;
const trimmed = raw.trim().toLowerCase(); const trimmed = raw.trim().toLowerCase();
return trimmed.length > 0 ? trimmed.slice(0, 255) : null; return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
}; };
const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => { const resolveRateLimitIp = (req: Request): string => getClientIp(req);
const trackIdentifierRateLimitKey = (identifier: string, key: string): void => {
if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) {
const oldestIdentifier = loginIdentifierKeyIndex.keys().next().value;
if (typeof oldestIdentifier === "string") {
loginIdentifierKeyIndex.delete(oldestIdentifier);
}
}
const existing = loginIdentifierKeyIndex.get(identifier) ?? new Set<string>();
if (existing.size >= 50) {
const oldestKey = existing.values().next().value;
if (typeof oldestKey === "string") {
existing.delete(oldestKey);
}
}
existing.add(key);
loginIdentifierKeyIndex.set(identifier, existing);
};
const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => {
const store = new MemoryStore(); const store = new MemoryStore();
loginIdentifierKeyIndex = new Map<string, Set<string>>();
const limiter = rateLimit({ const limiter = rateLimit({
windowMs: cfg.windowMs, windowMs: cfg.windowMs,
max: cfg.max, max: cfg.max,
@@ -121,22 +165,26 @@ const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => {
store, store,
keyGenerator: (req) => { keyGenerator: (req) => {
const identifier = resolveAuthIdentifier(req as Request); const identifier = resolveAuthIdentifier(req as Request);
if (identifier) return `login:${identifier}`; const ip = resolveRateLimitIp(req as Request);
const ip = (req as Request).ip || "unknown"; if (identifier) {
const key = `login:${identifier}:ip:${ip}`;
trackIdentifierRateLimitKey(identifier, key);
return key;
}
return `login-ip:${ip}`; return `login-ip:${ip}`;
}, },
}); });
loginAttemptLimiter = limiter; loginAttemptLimiter = limiter;
}; };
const initLoginAttemptLimiter = async () => { const initLoginAttemptLimiter = async () => {
const systemConfig = await ensureSystemConfig(); const systemConfig = await ensureSystemConfig();
loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig); loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig);
buildLoginAttemptLimiter(loginRateLimitConfig); buildLoginAttemptLimiter(loginRateLimitConfig);
}; };
const ensureLoginAttemptLimiter = async () => { const ensureLoginAttemptLimiter = async () => {
if (loginAttemptLimiter) return; if (loginAttemptLimiter) return;
if (!loginLimiterInitPromise) { if (!loginLimiterInitPromise) {
loginLimiterInitPromise = initLoginAttemptLimiter().finally(() => { loginLimiterInitPromise = initLoginAttemptLimiter().finally(() => {
@@ -144,35 +192,53 @@ const ensureLoginAttemptLimiter = async () => {
}); });
} }
await loginLimiterInitPromise; await loginLimiterInitPromise;
}; };
const applyLoginRateLimitConfig = ( const applyLoginRateLimitConfig = (
systemConfig: Pick<Awaited<ReturnType<typeof ensureSystemConfig>>, "authLoginRateLimitEnabled" | "authLoginRateLimitWindowMs" | "authLoginRateLimitMax"> systemConfig: Pick<
): LoginRateLimitConfig => { Awaited<ReturnType<typeof ensureSystemConfig>>,
loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig as Awaited<ReturnType<typeof ensureSystemConfig>>); "authLoginRateLimitEnabled" | "authLoginRateLimitWindowMs" | "authLoginRateLimitMax"
>
): LoginRateLimitConfig => {
loginRateLimitConfig = parseLoginRateLimitConfig(
systemConfig as Awaited<ReturnType<typeof ensureSystemConfig>>
);
buildLoginAttemptLimiter(loginRateLimitConfig); buildLoginAttemptLimiter(loginRateLimitConfig);
return loginRateLimitConfig; return loginRateLimitConfig;
}; };
const resetLoginAttemptKey = async (identifier: string): Promise<void> => { const resetLoginAttemptKey = async (identifier: string): Promise<void> => {
await ensureLoginAttemptLimiter(); await ensureLoginAttemptLimiter();
const key = `login:${identifier}`; const normalizedIdentifier = identifier.trim().toLowerCase();
const keys = loginIdentifierKeyIndex.get(normalizedIdentifier);
try { try {
if (!keys || keys.size === 0) {
// Backward-compatible fallback for pre-change key format.
await loginAttemptLimiter?.resetKey(`login:${normalizedIdentifier}`);
return;
}
for (const key of keys) {
await loginAttemptLimiter?.resetKey(key); await loginAttemptLimiter?.resetKey(key);
}
loginIdentifierKeyIndex.delete(normalizedIdentifier);
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.debug("Rate limit reset skipped:", error); console.debug("Rate limit reset skipped:", error);
} }
} }
}; };
const loginAttemptRateLimiter = async (req: Request, res: Response, next: express.NextFunction) => { const loginAttemptRateLimiter = async (
req: Request,
res: Response,
next: express.NextFunction
) => {
await ensureLoginAttemptLimiter(); await ensureLoginAttemptLimiter();
if (!loginRateLimitConfig.enabled) return next(); if (!loginRateLimitConfig.enabled) return next();
return (loginAttemptLimiter as ReturnType<typeof rateLimit>)(req, res, next); return (loginAttemptLimiter as ReturnType<typeof rateLimit>)(req, res, next);
}; };
const accountActionRateLimiter = rateLimit({ const accountActionRateLimiter = rateLimit({
windowMs: 5 * 60 * 1000, windowMs: 5 * 60 * 1000,
max: 60, max: 60,
message: { message: {
@@ -184,14 +250,15 @@ const accountActionRateLimiter = rateLimit({
validate: { validate: {
trustProxy: false, trustProxy: false,
}, },
}); keyGenerator: (req) => getClientIp(req as Request),
});
const generateTempPassword = (): string => { const generateTempPassword = (): string => {
const buf = crypto.randomBytes(18); const buf = crypto.randomBytes(18);
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
}; };
const findUserByIdentifier = async (identifier: string) => { const findUserByIdentifier = async (identifier: string) => {
const trimmed = identifier.trim(); const trimmed = identifier.trim();
if (trimmed.length === 0) return null; if (trimmed.length === 0) return null;
@@ -207,12 +274,12 @@ const findUserByIdentifier = async (identifier: string) => {
OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }], OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }],
}, },
}); });
}; };
const requireAdmin = ( const requireAdmin = (
req: Request, req: Request,
res: Response res: Response
): req is Request & { user: NonNullable<Request["user"]> } => { ): req is Request & { user: NonNullable<Request["user"]> } => {
if (!req.user) { if (!req.user) {
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
return false; return false;
@@ -222,15 +289,9 @@ const requireAdmin = (
return false; return false;
} }
return true; return true;
}; };
const getClientId = (req: Request): string => { const requireCsrf = (req: Request, res: Response): boolean => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const userAgent = req.headers["user-agent"] || "unknown";
return `${ip}:${userAgent}`.slice(0, 256);
};
const requireCsrf = (req: Request, res: Response): boolean => {
const headerName = getCsrfTokenHeader(); const headerName = getCsrfTokenHeader();
const tokenHeader = req.headers[headerName]; const tokenHeader = req.headers[headerName];
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
@@ -243,7 +304,9 @@ const requireCsrf = (req: Request, res: Response): boolean => {
return false; return false;
} }
if (!validateCsrfToken(getClientId(req), token)) { const clientIds = getCsrfValidationClientIds(req);
const isValidToken = clientIds.some((clientId) => validateCsrfToken(clientId, token));
if (!isValidToken) {
res.status(403).json({ res.status(403).json({
error: "CSRF token invalid", error: "CSRF token invalid",
message: "Invalid or expired CSRF token. Please refresh and try again.", message: "Invalid or expired CSRF token. Please refresh and try again.",
@@ -252,19 +315,19 @@ const requireCsrf = (req: Request, res: Response): boolean => {
} }
return true; return true;
}; };
const countActiveAdmins = async () => { const countActiveAdmins = async () => {
return prisma.user.count({ return prisma.user.count({
where: { role: "ADMIN", isActive: true }, where: { role: "ADMIN", isActive: true },
}); });
}; };
const generateTokens = ( const generateTokens = (
userId: string, userId: string,
email: string, email: string,
options?: { impersonatorId?: string } options?: { impersonatorId?: string }
) => { ) => {
const signOptions: SignOptions = { const signOptions: SignOptions = {
expiresIn: config.jwtAccessExpiresIn as StringValue, expiresIn: config.jwtAccessExpiresIn as StringValue,
}; };
@@ -284,15 +347,15 @@ const generateTokens = (
); );
return { accessToken, refreshToken }; return { accessToken, refreshToken };
}; };
const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => { const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => {
const parsed = ms(expiresIn as StringValue); const parsed = ms(expiresIn as StringValue);
const ttlMs = typeof parsed === "number" && parsed > 0 ? parsed : fallbackMs; const ttlMs = typeof parsed === "number" && parsed > 0 ? parsed : fallbackMs;
return new Date(Date.now() + ttlMs); return new Date(Date.now() + ttlMs);
}; };
const isMissingRefreshTokenTableError = (error: unknown): boolean => { const isMissingRefreshTokenTableError = (error: unknown): boolean => {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2021") { if (error.code === "P2021") {
return true; return true;
@@ -304,12 +367,24 @@ const isMissingRefreshTokenTableError = (error: unknown): boolean => {
? String((error as any).message) ? String((error as any).message)
: ""; : "";
return /no such table:\s*RefreshToken/i.test(message); return /no such table:\s*RefreshToken/i.test(message);
}; };
const getRefreshTokenExpiresAt = (): Date => const getRefreshTokenExpiresAt = (): Date =>
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000); resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
registerCoreRoutes({ registerOidcRoutes({
router,
prisma,
ensureAuthEnabled,
sanitizeText,
generateTokens,
setAuthCookies,
getRefreshTokenExpiresAt,
isMissingRefreshTokenTableError,
config,
});
registerCoreRoutes({
router, router,
prisma, prisma,
requireAuth, requireAuth,
@@ -327,9 +402,14 @@ registerCoreRoutes({
isMissingRefreshTokenTableError, isMissingRefreshTokenTableError,
bootstrapUserId: BOOTSTRAP_USER_ID, bootstrapUserId: BOOTSTRAP_USER_ID,
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
}); clearAuthEnabledCache: authModeService.clearAuthEnabledCache,
setAuthCookies,
setAccessTokenCookie,
clearAuthCookies,
readRefreshTokenFromRequest: (req) => readCookie(req, REFRESH_TOKEN_COOKIE_NAME),
});
registerAdminRoutes({ registerAdminRoutes({
router, router,
prisma, prisma,
requireAuth, requireAuth,
@@ -348,9 +428,11 @@ registerAdminRoutes({
getRefreshTokenExpiresAt, getRefreshTokenExpiresAt,
config, config,
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
}); setAuthCookies,
requireCsrf,
});
registerAccountRoutes({ registerAccountRoutes({
router, router,
prisma, prisma,
requireAuth, requireAuth,
@@ -361,6 +443,18 @@ registerAccountRoutes({
config, config,
generateTokens, generateTokens,
getRefreshTokenExpiresAt, getRefreshTokenExpiresAt,
setAuthCookies,
requireCsrf,
});
return router;
};
const authRouter = createAuthRouter({
prisma: defaultPrisma,
requireAuth: defaultRequireAuth,
optionalAuth: defaultOptionalAuth,
authModeService: defaultAuthModeService,
}); });
export default router; export default authRouter;
+30 -5
View File
@@ -11,6 +11,7 @@ import {
updateEmailSchema, updateEmailSchema,
updateProfileSchema, updateProfileSchema,
} from "./schemas"; } from "./schemas";
import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity";
type RegisterAccountRoutesDeps = { type RegisterAccountRoutesDeps = {
router: express.Router; router: express.Router;
@@ -33,6 +34,12 @@ type RegisterAccountRoutesDeps = {
options?: { impersonatorId?: string } options?: { impersonatorId?: string }
) => { accessToken: string; refreshToken: string }; ) => { accessToken: string; refreshToken: string };
getRefreshTokenExpiresAt: () => Date; getRefreshTokenExpiresAt: () => Date;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
}; };
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
@@ -47,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
config, config,
generateTokens, generateTokens,
getRefreshTokenExpiresAt, getRefreshTokenExpiresAt,
setAuthCookies,
requireCsrf,
} = deps; } = deps;
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => { router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
@@ -81,7 +90,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}); });
await prisma.passwordResetToken.create({ await prisma.passwordResetToken.create({
data: { userId: user.id, token: resetToken, expiresAt }, data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt },
}); });
if (config.enableAuditLogging) { if (config.enableAuditLogging) {
@@ -137,8 +146,10 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
} }
const { token, password } = parsed.data; const { token, password } = parsed.data;
const resetToken = await prisma.passwordResetToken.findUnique({ const resetToken = await prisma.passwordResetToken.findFirst({
where: { token }, where: {
OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })),
},
include: { user: true }, include: { user: true },
}); });
@@ -207,6 +218,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/profile", requireAuth, async (req: Request, res: Response) => { router.put("/profile", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) { if (!req.user) {
return res.status(401).json({ return res.status(401).json({
error: "Unauthorized", error: "Unauthorized",
@@ -258,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
} }
@@ -344,11 +357,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
} }
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) { if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt(); const expiresAt = getRefreshTokenExpiresAt();
try { try {
await prisma.refreshToken.create({ await prisma.refreshToken.create({
data: { userId: updatedUser.id, token: refreshToken, expiresAt }, data: {
userId: updatedUser.id,
token: hashTokenForStorage(refreshToken),
expiresAt,
},
}); });
} catch { } catch {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
@@ -380,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
} }
@@ -456,6 +475,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
} }
@@ -521,11 +541,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
} }
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) { if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt(); const expiresAt = getRefreshTokenExpiresAt();
try { try {
await prisma.refreshToken.create({ await prisma.refreshToken.create({
data: { userId: updatedUser.id, token: refreshToken, expiresAt }, data: {
userId: updatedUser.id,
token: hashTokenForStorage(refreshToken),
expiresAt,
},
}); });
} catch { } catch {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
+119 -5
View File
@@ -11,6 +11,7 @@ import {
loginRateLimitUpdateSchema, loginRateLimitUpdateSchema,
registrationToggleSchema, registrationToggleSchema,
} from "./schemas"; } from "./schemas";
import { hashTokenForStorage } from "./tokenSecurity";
type RegisterAdminRoutesDeps = { type RegisterAdminRoutesDeps = {
router: express.Router; router: express.Router;
@@ -63,6 +64,12 @@ type RegisterAdminRoutesDeps = {
enableRefreshTokenRotation: boolean; enableRefreshTokenRotation: boolean;
}; };
defaultSystemConfigId: string; defaultSystemConfigId: string;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
}; };
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
@@ -85,11 +92,56 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
getRefreshTokenExpiresAt, getRefreshTokenExpiresAt,
config, config,
defaultSystemConfigId, defaultSystemConfigId,
setAuthCookies,
requireCsrf,
} = deps; } = deps;
const resolveImpersonationAdmin = async (req: Request, res: Response) => {
if (!req.user) {
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
return null;
}
if (req.user.role === "ADMIN") {
return {
id: req.user.id,
email: req.user.email,
name: req.user.name,
};
}
if (!req.user.impersonatorId) {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
const impersonator = await prisma.user.findUnique({
where: { id: req.user.impersonatorId },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
return {
id: impersonator.id,
email: impersonator.email,
name: impersonator.name,
};
};
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const parsed = registrationToggleSchema.safeParse(req.body); const parsed = registrationToggleSchema.safeParse(req.body);
@@ -116,6 +168,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/admins", requireAuth, async (req: Request, res: Response) => { router.post("/admins", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const parsed = adminRoleUpdateSchema.safeParse(req.body); const parsed = adminRoleUpdateSchema.safeParse(req.body);
@@ -199,6 +252,42 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
} }
}); });
router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const users = await prisma.user.findMany({
where: { isActive: true, id: { not: actingAdmin.id } },
orderBy: [{ name: "asc" }, { email: "asc" }],
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
res.json({
users,
impersonator: {
id: actingAdmin.id,
email: actingAdmin.email,
name: actingAdmin.name,
},
});
} catch (error) {
console.error("List impersonation targets error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to list impersonation targets",
});
}
});
router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
@@ -219,6 +308,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitUpdateSchema.safeParse(req.body); const parsed = loginRateLimitUpdateSchema.safeParse(req.body);
@@ -264,6 +354,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => { router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitResetSchema.safeParse(req.body); const parsed = loginRateLimitResetSchema.safeParse(req.body);
@@ -301,10 +392,21 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const parsed = adminCreateUserSchema.safeParse(req.body); const parsed = adminCreateUserSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
const summarizedIssues = parsed.error.issues.map((issue) => ({
code: issue.code,
path: issue.path.join("."),
message: issue.message,
}));
console.warn("[auth/users] validation failed", {
issues: summarizedIssues,
requestId: req.headers["x-request-id"],
ip: req.ip || req.connection.remoteAddress || "unknown",
});
return res.status(400).json({ return res.status(400).json({
error: "Validation error", error: "Validation error",
message: "Invalid user payload", message: "Invalid user payload",
@@ -385,6 +487,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => { router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
const userId = String(req.params.id || "").trim(); const userId = String(req.params.id || "").trim();
@@ -493,6 +596,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (req.user.impersonatorId) { if (req.user.impersonatorId) {
@@ -582,7 +686,9 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; if (!(await ensureAuthEnabled(res))) return;
if (!requireAdmin(req, res)) return; if (!requireCsrf(req, res)) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const parsed = impersonateSchema.safeParse(req.body); const parsed = impersonateSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -598,19 +704,27 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
return res.status(404).json({ error: "Not found", message: "User not found" }); return res.status(404).json({ error: "Not found", message: "User not found" });
} }
if (target.id === actingAdmin.id) {
return res.status(409).json({
error: "Conflict",
message: "Already using the admin account. Use stop impersonation to return.",
});
}
if (!target.isActive) { if (!target.isActive) {
return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" }); return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" });
} }
const { accessToken, refreshToken } = generateTokens(target.id, target.email, { const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
impersonatorId: req.user.id, impersonatorId: actingAdmin.id,
}); });
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) { if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt(); const expiresAt = getRefreshTokenExpiresAt();
try { try {
await prisma.refreshToken.create({ await prisma.refreshToken.create({
data: { userId: target.id, token: refreshToken, expiresAt }, data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt },
}); });
} catch { } catch {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
@@ -621,12 +735,12 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
if (config.enableAuditLogging) { if (config.enableAuditLogging) {
await logAuditEvent({ await logAuditEvent({
userId: req.user.id, userId: actingAdmin.id,
action: "impersonation_started", action: "impersonation_started",
resource: `user:${target.id}`, resource: `user:${target.id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined, ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined, userAgent: req.headers["user-agent"] || undefined,
details: { targetUserId: target.id }, details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) },
}); });
} }
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { PrismaClient } from "../generated/client";
import {
BOOTSTRAP_USER_ID,
DEFAULT_SYSTEM_CONFIG_ID,
createAuthModeService,
} from "./authMode";
const createPrismaMock = () =>
({
systemConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
user: {
upsert: vi.fn(),
},
}) as unknown as PrismaClient;
describe("authMode service", () => {
let now = 1_000_000;
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches authEnabled reads within TTL", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique
.mockResolvedValueOnce({ authEnabled: true })
.mockResolvedValueOnce({ authEnabled: false });
const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 });
await expect(service.getAuthEnabled()).resolves.toBe(true);
now += 1000;
await expect(service.getAuthEnabled()).resolves.toBe(true);
expect(findUnique).toHaveBeenCalledTimes(1);
now += 6000;
await expect(service.getAuthEnabled()).resolves.toBe(false);
expect(findUnique).toHaveBeenCalledTimes(2);
expect(upsert).not.toHaveBeenCalled();
});
it("clears auth cache when requested", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique.mockResolvedValue({ authEnabled: true });
const service = createAuthModeService(prisma);
await service.getAuthEnabled();
service.clearAuthEnabledCache();
await service.getAuthEnabled();
expect(findUnique).toHaveBeenCalledTimes(2);
expect(upsert).not.toHaveBeenCalled();
});
it("falls back to upsert when system config row is missing", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique.mockResolvedValue(null);
upsert.mockResolvedValue({ authEnabled: false });
const service = createAuthModeService(prisma);
await expect(service.getAuthEnabled()).resolves.toBe(false);
expect(findUnique).toHaveBeenCalledTimes(1);
expect(upsert).toHaveBeenCalledTimes(1);
});
it("creates/bootstrap user via upsert", async () => {
const prisma = createPrismaMock();
const userUpsert = prisma.user.upsert as unknown as ReturnType<typeof vi.fn>;
userUpsert.mockResolvedValue({
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
name: "Bootstrap Admin",
role: "ADMIN",
isActive: false,
mustResetPassword: true,
username: null,
});
const service = createAuthModeService(prisma);
const bootstrapUser = await service.getBootstrapActingUser();
expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID);
expect(userUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: BOOTSTRAP_USER_ID },
create: expect.objectContaining({
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
role: "ADMIN",
}),
})
);
});
it("ensures system config defaults", async () => {
const prisma = createPrismaMock();
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
upsert.mockResolvedValue({ authEnabled: false });
const service = createAuthModeService(prisma);
await service.ensureSystemConfig();
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
create: expect.objectContaining({
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
registrationEnabled: false,
}),
})
);
});
});

Some files were not shown because too many files have changed in this diff Show More