From 084002bea9c1b96537527218b603e045ba55102f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 30 Oct 2026 09:03:29 +0100 Subject: [PATCH] Add AUTH_DEV_AUTOLOGIN to switch off the login on development instances With AUTH_DEV_AUTOLOGIN=, every /api request without a valid session runs as that existing, active user and skips the forced password change. Unset keeps the normal login; unknown or deactivated users still get 401, real sessions take precedence, the CSRF application header stays mandatory and the API logs a warning at startup. The switch is not limited to localhost because the development instance is reached from other machines and through a reverse proxy (THREATMODEL D2/R15). It must never be set on an instance with real project data. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 + AGENTS.md | 6 +- compose.dev.yaml | 3 + compose.yaml | 3 + docs/THREATMODEL.md | 11 ++ docs/authentication.md | 20 ++++ src/server/auth/auth-config.ts | 8 ++ src/server/auth/auth.middleware.ts | 29 ++++- src/server/controllers/auth.controller.ts | 4 +- src/server/index.ts | 7 +- tests/authentication.test.ts | 124 +++++++++++++++++++++- 11 files changed, 211 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 46fa42a..28cfa42 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ AUTH_SESSION_HOURS=12 AUTH_SESSION_MAX_DAYS=7 # Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false. COOKIE_SECURE=false +# NUR Entwicklungsinstanzen ohne echte Projektdaten: vorhandener, aktiver +# Benutzer, als der jede Anfrage ohne Session angemeldet ist (Login aus). +# Leer lassen auf jeder produktiven Instanz. +AUTH_DEV_AUTOLOGIN= diff --git a/AGENTS.md b/AGENTS.md index 8d097c7..bc5a8f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,11 @@ It must support circuits, device rows, project devices, drag-and-drop restructur (`src/app/layout.tsx`). Every unsafe API request also requires the explicit `X-Leistungsbilanz-Request: 1` application header, including over plain HTTP. Account settings are available to every signed-in user; user administration - remains admin-only. See `docs/authentication.md` for the full picture. + remains admin-only. Development instances only: `AUTH_DEV_AUTOLOGIN=` + signs every request without a session in as that existing active user + (login off, no forced password change; THREATMODEL D2/R15, never on + instances with real project data). See `docs/authentication.md` for the full + picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. diff --git a/compose.dev.yaml b/compose.dev.yaml index f49e9e6..55b5563 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -39,6 +39,9 @@ services: AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" + # Nur Entwicklungsinstanzen: Benutzername, mit dem jede Anfrage ohne + # Session automatisch angemeldet ist (Login aus). Leer = Login aktiv. + AUTH_DEV_AUTOLOGIN: "${AUTH_DEV_AUTOLOGIN:-}" COOKIE_SECURE: "${COOKIE_SECURE:-false}" # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. ports: diff --git a/compose.yaml b/compose.yaml index 1de9104..9ffc59d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -52,6 +52,9 @@ services: AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" + # Nur Entwicklungsinstanzen: Benutzername, mit dem jede Anfrage ohne + # Session automatisch angemeldet ist (Login aus). Leer = Login aktiv. + AUTH_DEV_AUTOLOGIN: "${AUTH_DEV_AUTOLOGIN:-}" COOKIE_SECURE: "${COOKIE_SECURE:-false}" # Nicht auf dem Host veroeffentlicht: die API wird ueber den # Next.js-Rewrite im Web-Dienst erreicht. diff --git a/docs/THREATMODEL.md b/docs/THREATMODEL.md index bd005d5..24a5950 100644 --- a/docs/THREATMODEL.md +++ b/docs/THREATMODEL.md @@ -75,6 +75,7 @@ Gesundheitsdaten: keine. Personenbezug: nur Benutzerkonten. | R12 | Keine Nachvollziehbarkeit bei geteilten Projekten: der Projektverlauf hat `actorId`, die API setzt ihn aber nie | A4 | Änderungen keinem Nutzer zuordenbar | M | angemeldeten Nutzer (`req.authUser`) bis in `projectCommandService` durchreichen | `grep actorId src/server` leer; Spalte `project_revisions.actor_id` | **offen** | | R13 | Secrets oder echte Kundendaten im Repo | A6 | Zugangsdaten bzw. Kundendaten öffentlich | H | `.env`/`.env.*` und `docs/spec/kbp-Stromkreis.xlsx` gitignored, Testdaten synthetisch, gitleaks per pre-commit | `.gitignore`; `make secrets` | belegt | | R14 | Abhängigkeiten (u. a. `exceljs` parst Fremdinhalt) | A6 | Codeausführung, DoS | M | `npm audit` in `make check`, Lockfile, neue Pakete per `dependency-vetting` | `make audit`: 6 moderate ohne nicht-brechenden Fix (`drizzle-kit`/`esbuild`, `exceljs`/`uuid`), kein High/Critical | offen bis Eintrag in `RELEASE-AUSNAHMEN.md` oder Update | +| R15 | Login per `AUTH_DEV_AUTOLOGIN` abgeschaltet, versehentlich auf einer Instanz mit echten Projektdaten gesetzt | A1, A2 | jeder, der die Instanz erreicht, arbeitet mit den Rechten des Auto-Login-Benutzers (bei Admin: inkl. Benutzerverwaltung) | H | Standard leer, nur ein vorhandener aktiver Benutzer wird verwendet (sonst 401), echte Sessions haben Vorrang, Warnung im API-Log beim Start, CSRF-Header bleibt Pflicht. Bewusst **nicht** auf localhost begrenzt (**D2**) | `tests/authentication.test.ts` „development auto-login“; Baseline „jede Route verlangt Login“ läuft ohne den Schalter | belegt für den Schalter selbst; Schutz der Live-Instanz hängt an deren `.env` (dort nie setzen) | ## Design-Entscheidungen @@ -95,6 +96,16 @@ nichts als entschieden ein. - Folge: ASVS-light B1/B2 sind für diese Instanz nicht anwendbar (R3). `idorGap` in `tests/security/security.config.ts` bleibt gesetzt und macht den Ist-Zustand sichtbar. +**D2: Entwicklungsinstanz ohne Login (`AUTH_DEV_AUTOLOGIN`).** +- Aussage von: Julian im Chat mit einem Agenten am 30.10.2026 („Deaktiviere mal den login auf + dieser dev instanz. Der ist hier nicht nötig“; Schalter ausdrücklich „nicht auf localhost + begrenzen, da ich von anderen PCs und Netzen drauf zugreife. Eventuell auch mit + reverseproxy“). Eingetragen von diesem Agenten. +- Gilt für: lokale Entwicklungsinstanzen ohne echte Projektdaten. Nie auf cj-ki oder einer + anderen Instanz mit Kundendaten. +- Folge: R15. Auf solchen Instanzen ist der Login aus; wer die Instanz erreicht, arbeitet mit + den Rechten des konfigurierten Benutzers. + ## Offene Entscheidungen E1 (geteilte Projekte) ist mit D1 beantwortet. diff --git a/docs/authentication.md b/docs/authentication.md index 32039cf..fdd10e6 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -69,6 +69,26 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen. +## Entwicklungsinstanz ohne Login + +`AUTH_DEV_AUTOLOGIN=` schaltet den Login für eine +Entwicklungsinstanz ab (Entscheidung Julian, 2026-10-30): Jede `/api`-Anfrage +ohne gültige Session läuft als dieser vorhandene, aktive Benutzer, auch der +Passwortwechsel-Zwang entfällt. Der Schalter ist bewusst nicht auf localhost +begrenzt, weil die Dev-Instanz von anderen Rechnern und über einen +Reverse-Proxy erreicht wird. Jeder, der die Instanz erreicht, hat damit die +Rechte dieses Benutzers. Leer (Standard) gilt der normale Login; ein +unbekannter oder deaktivierter Benutzer führt weiter zu 401. Echte Sessions +haben Vorrang, und die API protokolliert beim Start eine Warnung. Der +CSRF-Anwendungsheader bleibt Pflicht. Auf einer Instanz mit echten +Projektdaten darf der Schalter nie gesetzt sein. + +```dotenv +AUTH_BOOTSTRAP_USERNAME=julian +AUTH_BOOTSTRAP_PASSWORT= +AUTH_DEV_AUTOLOGIN=julian +``` + ## Bootstrap-Admin `AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe diff --git a/src/server/auth/auth-config.ts b/src/server/auth/auth-config.ts index c4c3b44..db7f91c 100644 --- a/src/server/auth/auth-config.ts +++ b/src/server/auth/auth-config.ts @@ -15,6 +15,13 @@ export interface AuthConfig { * Unset means: no bootstrap account, the table must already have an admin. */ bootstrap: { username: string; password: string } | null; + /** + * Development instances only (AUTH_DEV_AUTOLOGIN): every /api request + * without a valid session runs as this existing, active user, so no login + * is needed. Unset means the normal login requirement. Never set it on an + * instance holding real project data. + */ + devAutoLogin: string | null; } export class AuthConfigError extends Error {} @@ -55,5 +62,6 @@ export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig bootstrap: bootstrapUsername ? { username: bootstrapUsername, password: bootstrapPassword } : null, + devAutoLogin: (env.AUTH_DEV_AUTOLOGIN ?? "").trim().toLowerCase() || null, }; } diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts index bafae03..d7156a0 100644 --- a/src/server/auth/auth.middleware.ts +++ b/src/server/auth/auth.middleware.ts @@ -50,6 +50,31 @@ export async function identityFromRequest( }; } +/** + * Session identity, or on a development instance with AUTH_DEV_AUTOLOGIN the + * configured active user. The auto-login user skips the forced password + * change because nobody signs in interactively. + */ +export async function resolveRequestIdentity( + req: { headers: { cookie?: string | undefined } }, + authConfig: Pick, + sessions: SessionStore, + users: Pick, + slideExpiry = true +): Promise { + const identity = await identityFromRequest(req, sessions, users, slideExpiry); + if (identity || !authConfig.devAutoLogin) return identity; + const user = await users.findByUsername(authConfig.devAutoLogin); + if (!user || !user.active) return undefined; + return { + id: user.id, + username: user.username, + name: user.name, + role: user.role as UserRole, + mustChangePassword: false, + }; +} + export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) { return { httpOnly: true, @@ -107,7 +132,7 @@ export function requireSameOriginForWrites(): RequestHandler { export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, - users: Pick, + users: Pick, openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] ): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { @@ -115,7 +140,7 @@ export function createAuthGuard( next(); return; } - identityFromRequest(req, sessions, users) + resolveRequestIdentity(req, authConfig, sessions, users) .then((identity) => { if (!identity) { res.status(401).json({ error: "Nicht angemeldet." }); diff --git a/src/server/controllers/auth.controller.ts b/src/server/controllers/auth.controller.ts index 5eaf699..02d6408 100644 --- a/src/server/controllers/auth.controller.ts +++ b/src/server/controllers/auth.controller.ts @@ -8,7 +8,7 @@ import { import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js"; import { cookieOptions, - identityFromRequest, + resolveRequestIdentity, sessionIdFromRequest, setSessionCookie, } from "../auth/auth.middleware.js"; @@ -16,7 +16,7 @@ import { SESSION_COOKIE } from "../auth/auth-config.js"; import { userRepository } from "../composition/application-repositories.js"; export async function getAuthStatus(req: Request, res: Response) { - const identity = await identityFromRequest(req, sessionStore, userRepository); + const identity = await resolveRequestIdentity(req, authConfig, sessionStore, userRepository); const sessionId = sessionIdFromRequest(req); if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId); res.json({ diff --git a/src/server/index.ts b/src/server/index.ts index f8ec5db..b1facf0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,5 @@ import { createApp } from "./app.js"; -import { userService } from "./composition/auth.js"; +import { authConfig, userService } from "./composition/auth.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { closeDefaultDatabase } from "../db/client.js"; @@ -35,6 +35,11 @@ userService process.exit(1); }) .then(() => { + if (authConfig.devAutoLogin) { + logger.warn("login disabled: AUTH_DEV_AUTOLOGIN signs every request in", { + username: authConfig.devAutoLogin, + }); + } const server = app.listen(port, () => { logger.info("server started", { port }); }); diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts index b64333d..fea70e9 100644 --- a/tests/authentication.test.ts +++ b/tests/authentication.test.ts @@ -17,8 +17,11 @@ import { hashPassword, verifyPassword } from "../src/server/auth/password.js"; import { SessionStore } from "../src/server/auth/sessions.js"; import { LoginThrottle } from "../src/server/auth/login-throttle.js"; import { readCookie } from "../src/server/auth/cookie.js"; -import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js"; -import type { AuthConfig } from "../src/server/auth/auth-config.js"; +import { + createAuthGuard, + requireSameOriginForWrites, +} from "../src/server/auth/auth.middleware.js"; +import { readAuthConfig, type AuthConfig } from "../src/server/auth/auth-config.js"; import { createUserSchema } from "../src/shared/validation/user.schemas.js"; import { loginSchema } from "../src/shared/validation/auth.schemas.js"; import { @@ -37,6 +40,7 @@ function createUserService() { sessionMaxAgeMs: 7 * 24 * HOUR, cookieSecure: false, bootstrap: { username: "admin", password: "bootstrap-password-123" }, + devAutoLogin: null, }; return { repository, @@ -202,6 +206,122 @@ describe("same-origin guard for writes", () => { }); }); +describe("development auto-login", () => { + const baseConfig = { + sessionTtlMs: 12 * HOUR, + sessionMaxAgeMs: 7 * 24 * HOUR, + cookieSecure: false, + bootstrap: null, + }; + + // Runs the /api auth guard for one request without a session cookie. + async function guard( + users: UserRepository, + devAutoLogin: string | null, + sessions = new SessionStore(HOUR, 24 * HOUR), + cookie?: string + ) { + const req = { + path: "/projects", + headers: cookie ? { cookie } : {}, + } as unknown as Request; + let status: number | undefined; + const res = { + status(code: number) { + status = code; + return this; + }, + json() { + return this; + }, + cookie() { + return this; + }, + } as unknown as Response; + const passed = await new Promise((resolve, reject) => { + const middleware = createAuthGuard( + { ...baseConfig, devAutoLogin }, + sessions, + users + ); + middleware(req, res, (error?: unknown) => + error ? reject(error) : resolve(true) + ); + setTimeout(() => resolve(false), 200); + }); + return { passed, status, authUser: req.authUser }; + } + + it("is off unless AUTH_DEV_AUTOLOGIN names a user", () => { + assert.equal(readAuthConfig({}).devAutoLogin, null); + assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " " }).devAutoLogin, null); + assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " Julian " }).devAutoLogin, "julian"); + }); + + it("signs every request without a session in as the configured active user", async () => { + const { repository, service } = createUserService(); + const user = await service.createUser({ + username: "julian", + name: "Julian", + role: "admin", + initialPassword: "start-passwort", + }); + assert.ok(user); + + const result = await guard(repository, "julian"); + assert.equal(result.passed, true); + assert.equal(result.authUser?.id, user.id); + assert.equal(result.authUser?.role, "admin"); + // A development instance must not stop at the forced password change. + assert.equal(result.authUser?.mustChangePassword, false); + }); + + it("still requires a login without the switch, for unknown and for deactivated users", async () => { + const { repository, service } = createUserService(); + const user = await service.createUser({ + username: "julian", + name: "Julian", + role: "admin", + initialPassword: "start-passwort", + }); + assert.ok(user); + assert.deepEqual( + { passed: (await guard(repository, null)).passed, status: (await guard(repository, null)).status }, + { passed: false, status: 401 } + ); + assert.equal((await guard(repository, "niemand")).status, 401); + await service.createUser({ + username: "zweiter", + name: "Zweiter", + role: "admin", + initialPassword: "start-passwort", + }); + await service.updateRoleAndActive(user.id, { active: false }); + assert.equal((await guard(repository, "julian")).status, 401); + }); + + it("keeps a real session in front of the auto-login user", async () => { + const { repository, service } = createUserService(); + await service.createUser({ + username: "julian", + name: "Julian", + role: "admin", + initialPassword: "start-passwort", + }); + const other = await service.createUser({ + username: "max", + name: "Max", + role: "user", + initialPassword: "start-passwort", + }); + assert.ok(other); + const sessions = new SessionStore(HOUR, 24 * HOUR); + const sessionId = sessions.create(other.id).id; + const result = await guard(repository, "julian", sessions, `lb_session=${sessionId}`); + assert.equal(result.authUser?.username, "max"); + }); +}); + describe("input validation", () => { it("rejects look-alike and whitespace usernames and normalizes case", () => { const base = { name: "Test", role: "user", initialPassword: "start-passwort" };