Add AUTH_DEV_AUTOLOGIN to switch off the login on development instances

With AUTH_DEV_AUTOLOGIN=<username>, 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 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 09:03:29 +01:00
parent 4677b1685c
commit 084002bea9
11 changed files with 211 additions and 8 deletions

View file

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

View file

@ -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=<username>`
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.

View file

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

View file

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

View file

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

View file

@ -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=<benutzername>` 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=<Startpasswort, mindestens 12 Zeichen>
AUTH_DEV_AUTOLOGIN=julian
```
## Bootstrap-Admin
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe

View file

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

View file

@ -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<AuthConfig, "devAutoLogin">,
sessions: SessionStore,
users: Pick<UserStore, "findById" | "findByUsername">,
slideExpiry = true
): Promise<AuthUser | undefined> {
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<UserStore, "findById">,
users: Pick<UserStore, "findById" | "findByUsername">,
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." });

View file

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

View file

@ -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 });
});

View file

@ -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<boolean>((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" };