From 13f0311d8e71e9a89284f46bc2023470f156e891 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 12:58:07 +0200 Subject: [PATCH] #216: hard AUTH_LOCAL_ENABLED switch over every local credential flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy-level realization of auth.local.enabled (ADR 0021): FALSE answers 404 on every local credential flow — login, signup, e-mail verification, resend, password forgot/reset/change — enforced centrally in the auth guard via the @LocalCredentialFlow() marker before any session or CSRF logic runs. Deploy-level on purpose: a compromised Site Admin cannot reopen the local path, so the runtime-flip residual risk from ADR 0021 does not materialize (R-02 closed in the risk list). An enumeration fence fails when an auth route is neither marked nor on the reviewed allowlist, so a new credential flow cannot ship unswitched. Stated decisions, each tested: sessions/logout keep working for externally authenticated users; PAT and feed-token issuance stays available (API authorization under its own switches, not interactive sign-in). Bootstrap: complete setup (or SETUP_ADMIN_* pre-seed) before flipping; the api warns at boot when local auth is off with neither OIDC nor proxy auth configured. GET /auth/methods reports local:false and the login page hides the local form and credential links. Hardening guide: the planned auth.local.enabled row moves from 1.3 into the live deploy table with the bootstrap ordering, and the verification checklist gains the login-404 probe. Refs #216. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- apps/api/src/auth/auth.controller.ts | 9 +- apps/api/src/auth/auth.guard.ts | 24 +++ apps/api/src/auth/auth.module.ts | 23 ++- .../src/auth/local-auth-switch.e2e.db.test.ts | 157 ++++++++++++++++++ apps/api/src/users/users.controller.ts | 3 +- apps/web/src/pages/auth/LoginPage.tsx | 80 +++++---- .../adr/0021-external-authentication.md | 19 +++ docs/architecture/security.md | 8 + docs/vs-nfd/20-massnahmenplan.md | 2 +- docs/vs-nfd/50-haertungsleitfaden.md | 29 ++-- docs/vs-nfd/90-restrisiken.md | 28 ++-- packages/shared/src/env.ts | 14 ++ 12 files changed, 332 insertions(+), 64 deletions(-) create mode 100644 apps/api/src/auth/local-auth-switch.e2e.db.test.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 2ad68cf..d9dd759 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -21,6 +21,7 @@ import { InstanceSettingsService } from '../settings/instance-settings.service'; import { SetupExempt } from '../setup/setup.guard'; import { AuthedRequest, + LocalCredentialFlow, Public, SESSION_COOKIE, setSessionCookie, @@ -55,13 +56,14 @@ export class AuthController { @Get('methods') methods(): AuthMethodsView { return { - local: true, + local: this.config.env.AUTH_LOCAL_ENABLED, oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null, }; } @Public() @Post('signup') + @LocalCredentialFlow() @HttpCode(201) @RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 }) async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise { @@ -70,6 +72,7 @@ export class AuthController { @Public() @Post('verify-email') + @LocalCredentialFlow() @HttpCode(204) @RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 }) async verifyEmail( @@ -80,6 +83,7 @@ export class AuthController { @Public() @Post('resend-verification') + @LocalCredentialFlow() @HttpCode(204) @RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 }) async resendVerification( @@ -93,6 +97,7 @@ export class AuthController { @SetupExempt() @Public() @Post('login') + @LocalCredentialFlow() @HttpCode(200) @RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 }) async login( @@ -136,6 +141,7 @@ export class AuthController { @Public() @Post('forgot-password') + @LocalCredentialFlow() @HttpCode(204) @RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 }) async forgotPassword( @@ -146,6 +152,7 @@ export class AuthController { @Public() @Post('reset-password') + @LocalCredentialFlow() @HttpCode(204) @RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 }) async resetPassword( diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index e154ea9..7bf01bd 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -3,6 +3,7 @@ import { ExecutionContext, ForbiddenException, Injectable, + NotFoundException, SetMetadata, UnauthorizedException, createParamDecorator, @@ -22,6 +23,18 @@ const IS_PUBLIC_KEY = 'isPublic'; /** Marks a route as reachable without a session (login, signup, healthz…). */ export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); +export const LOCAL_CREDENTIAL_KEY = 'isLocalCredentialFlow'; +/** + * Marks a route as part of the LOCAL credential machinery (issue #216, + * ADR 0021): password login, signup, e-mail verification, password + * forgot/reset/change. With `AUTH_LOCAL_ENABLED=false` every marked route + * answers 404 (existence hidden, the switch precedent) — and the + * enumeration fence in `local-auth-switch.e2e.db.test.ts` fails when an + * auth route is neither marked nor on its reviewed allowlist, so a new + * credential flow cannot ship unswitched by accident. + */ +export const LocalCredentialFlow = (): MethodDecorator => SetMetadata(LOCAL_CREDENTIAL_KEY, true); + export interface AuthedRequest extends Request { user?: User; sessionId?: string; @@ -90,6 +103,17 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); + + // The hard local-auth switch (issue #216): marked credential routes + // disappear entirely — before any session or CSRF logic runs. + if (!this.config.env.AUTH_LOCAL_ENABLED) { + const isLocalFlow = this.reflector.getAllAndOverride(LOCAL_CREDENTIAL_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isLocalFlow) throw new NotFoundException(); + } + const rawToken = (request.cookies as Record | undefined)?.[SESSION_COOKIE]; if (rawToken && MUTATING_METHODS.has(request.method)) { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 397d285..f87b534 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,6 +1,8 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; +import { AppConfig } from '../config/app-config.service'; + import { MailModule } from '../mail/mail.module'; import { PondsModule } from '../ponds/ponds.module'; import { UsersModule } from '../users/users.module'; @@ -27,4 +29,21 @@ import { SessionsModule } from './sessions.module'; ], exports: [AuthTokensService, AuthService, OidcService], }) -export class AuthModule {} +export class AuthModule implements OnModuleInit { + constructor( + private readonly config: AppConfig, + private readonly oidc: OidcService, + private readonly proxyIdentity: ProxyIdentityService, + ) {} + + onModuleInit(): void { + // #216: local auth off without ANY external path means nobody can ever + // sign in — loudly stated at boot, because the operator will otherwise + // discover it at the login screen. + if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) { + new Logger(AuthModule.name).warn( + 'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists', + ); + } + } +} diff --git a/apps/api/src/auth/local-auth-switch.e2e.db.test.ts b/apps/api/src/auth/local-auth-switch.e2e.db.test.ts new file mode 100644 index 0000000..58aac65 --- /dev/null +++ b/apps/api/src/auth/local-auth-switch.e2e.db.test.ts @@ -0,0 +1,157 @@ +import 'reflect-metadata'; + +import { INestApplication } from '@nestjs/common'; +import { PATH_METADATA } from '@nestjs/common/constants'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { LOCAL_CREDENTIAL_KEY } from './auth.guard'; +import { AuthController } from './auth.controller'; +import { OidcController } from './oidc.controller'; +import { SessionsService } from './sessions.service'; + +/** + * The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false + * closes EVERY local credential flow with 404 — enumerated, not assumed — + * while sessions themselves, logout, and token issuance for + * externally-authenticated users keep working (the stated decision: PATs + * and feed tokens authorize API access under their own switches, they are + * not interactive sign-in). A fence asserts every auth route is either + * marked as a local flow or on the reviewed allowlist. + */ +describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + + /** Every local credential surface — the enumeration the issue demands. */ + const LOCAL_ROUTES: { method: 'post'; path: string; body: Record }[] = [ + { method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } }, + { + method: 'post', + path: '/api/v1/auth/signup', + body: { + username: `switch-${suffix}`, + email: `switch-${suffix}@example.test`, + displayName: 'x', + password: 'ein langes passwort 123', + locale: 'en', + }, + }, + { method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } }, + { + method: 'post', + path: '/api/v1/auth/resend-verification', + body: { email: 'x@example.test' }, + }, + { method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } }, + { + method: 'post', + path: '/api/v1/auth/reset-password', + body: { token: 'x', password: 'ein langes passwort 123' }, + }, + { + method: 'post', + path: '/api/v1/users/me/change-password', + body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' }, + }, + ]; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + process.env.AUTH_LOCAL_ENABLED = 'false'; + app = await createTestApp(); + }); + + afterAll(async () => { + delete process.env.AUTH_LOCAL_ENABLED; + await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } }); + await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('answers 404 on every enumerated local credential route', async () => { + for (const route of LOCAL_ROUTES) { + const res = await api()[route.method](route.path).send(route.body); + expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`); + } + }); + + it('reports local:false so the login screen hides the form', async () => { + const res = await api().get('/api/v1/auth/methods').expect(200); + expect(res.body.local).toBe(false); + }); + + it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => { + // An externally-authenticated user is simulated by creating the session + // through the session service — exactly what the OIDC/proxy paths do. + const users = app.get(UsersService); + const user = await users.createUser({ + username: `ext-${suffix}`, + email: `ext-${suffix}@example.test`, + displayName: 'External', + password: 'nie benutzt weil lokal aus', + locale: 'en', + }); + await users.markEmailVerified(user.id); + const token = await app.get(SessionsService).create(user.id, undefined); + const cookie = `dt_session=${token}`; + + const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); + expect(me.body.id).toBe(user.id); + + // Stated decision (#216): token issuance is API authorization, not + // interactive sign-in — it stays available under its own switches. + await api() + .post('/api/v1/users/me/api-tokens') + .set('Cookie', cookie) + .send({ name: `switch-${suffix}`, scope: 'read' }) + .expect(201); + await api() + .post('/api/v1/users/me/feed-tokens') + .set('Cookie', cookie) + .send({ name: `switch-${suffix}` }) + .expect(201); + + await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204); + await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401); + }); + + it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => { + // Routes that must stay reachable with local auth off — reviewed here. + const allowlist = new Set([ + 'registration', // signup-mode discovery; harmless metadata + 'methods', // the login screen's discovery endpoint + 'logout', // ending a session is not a credential flow + 'me', // session introspection + 'login', // OidcController: IdP redirect + 'link', // OidcController: explicit identity linking + 'callback', // OidcController: IdP return leg + ]); + for (const controller of [AuthController, OidcController]) { + for (const name of Object.getOwnPropertyNames(controller.prototype)) { + if (name === 'constructor') continue; + const handler = controller.prototype[name as keyof typeof controller.prototype] as ( + ...args: unknown[] + ) => unknown; + const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined; + if (path === undefined) continue; // not a route + const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true; + expect( + marked || allowlist.has(path), + `${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`, + ).toBe(true); + } + } + }); +}); diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 2626d36..1196af0 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -17,7 +17,7 @@ import { updateProfileInputSchema, } from '@dorfteich/shared'; -import { AuthedRequest, toCurrentUser } from '../auth/auth.guard'; +import { AuthedRequest, toCurrentUser, LocalCredentialFlow } from '../auth/auth.guard'; import { SessionsService } from '../auth/sessions.service'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; @@ -58,6 +58,7 @@ export class UsersController { } @Post('change-password') + @LocalCredentialFlow() @HttpCode(204) async changePassword( @Body(new ZodValidationPipe(changePasswordInputSchema)) diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 406f56a..7538c63 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -68,41 +68,57 @@ export function LoginPage(): React.JSX.Element { {t('auth:oidc.signIn', { provider: methods.data.oidc.label })} - + )} + + )} + {/* AUTH_LOCAL_ENABLED=false (#216): the local form and its + credential links disappear — the api answers 404 there anyway. */} + {(methods.data?.local ?? true) && ( + <> +
+ + {unverified && !resent && ( +

+ {t('auth:login.resendHint')}{' '} + +

+ )} + {resent &&

{t('auth:verify.resent')}

} + + + + + + + + +

+ {t('auth:login.forgot')} +

+

+ {t('auth:login.noAccount')} {t('auth:login.signupLink')}

)} -
- - {unverified && !resent && ( -

- {t('auth:login.resendHint')}{' '} - -

- )} - {resent &&

{t('auth:verify.resent')}

} - - - - - - - - -

- {t('auth:login.forgot')} -

-

- {t('auth:login.noAccount')} {t('auth:login.signupLink')} -

); } diff --git a/docs/architecture/adr/0021-external-authentication.md b/docs/architecture/adr/0021-external-authentication.md index 0c65e94..673d361 100644 --- a/docs/architecture/adr/0021-external-authentication.md +++ b/docs/architecture/adr/0021-external-authentication.md @@ -81,6 +81,25 @@ expect the application to trust a header or a client certificate. terminator forwards the certificate subject DN and the configured attribute (default CN) is the identity, under the same peer rules. +## Decisions taken in #216 + +- **Deploy-level, not runtime**: the switch is the environment variable + `AUTH_LOCAL_ENABLED` (default true). A compromised Site Admin cannot + reopen the local path — the runtime-flip residual risk from the + consequences below therefore does NOT materialize (R-02 closed). +- **404 semantics** on every marked flow (login, signup, verification, + resend, password forgot/reset/change), enforced centrally in the auth + guard via the `@LocalCredentialFlow()` marker; an enumeration fence + fails when an auth route is neither marked nor on the reviewed + allowlist, so a new credential flow cannot ship unswitched. +- **Bootstrap**: complete the first-run setup (or the `SETUP_ADMIN_*` + pre-seed, which does not run through HTTP routes) BEFORE flipping to + false — the wizard needs no permanent exemption. The api warns at boot + when local auth is off and neither OIDC nor proxy auth is configured. +- **PAT and feed-token issuance stay available** to (IdP-)authenticated + sessions: they authorize API access under their own switches + (`api.enabled`, `feeds.enabled`), they are not interactive sign-in. + ## Consequences - Bootstrapping needs a documented answer: the first-run wizard creates a diff --git a/docs/architecture/security.md b/docs/architecture/security.md index cad29f4..ec3e128 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -73,6 +73,14 @@ or sloppy plugin authors, compromised dependencies. is deliberately NOT implemented: sessions are short-bounded, and the claim-mapping revocation path (#217) plus the account-disable flag cover the leaver case — recorded in ADR 0021. +- **The hard local-auth switch (issue #216)**: `AUTH_LOCAL_ENABLED=false` + closes every local credential flow with 404 — login, signup, e-mail + verification, resend, password forgot/reset/change — enforced centrally + in the auth guard via a route marker with an enumeration fence. + Deploy-level on purpose (a compromised Site Admin cannot flip it back). + Sessions, logout and PAT/feed-token issuance for externally + authenticated users keep working; stored password hashes remain + (documented, ADR 0021). Bootstrap: complete setup before flipping. - **Trusted-proxy / mTLS path (issue #215)** — for perimeters that authenticate before the application. **The trust boundary, precisely:** the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 97c77af..940dceb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -40,7 +40,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_ ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214 - [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- Client-Zertifikat · 2 AT · #215 -- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und +- [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216 - [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 2–3 AT · #217 diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index 74bf718..f00e837 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -46,22 +46,24 @@ Settings-Cache ist in-process (operations.md). ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) -| Variable | Referenzwert | Warum | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | -| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | -| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | -| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | -| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | -| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | -| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | -| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | +| Variable | Referenzwert | Warum | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | +| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | +| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | +| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | +| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | +| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | +| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | +| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). | ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR) -| Schalter | Referenzwert (geplant) | Status | -| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth.local.enabled` | `false` — lokale Passwort-Auth aus, Anmeldung nur über die Fremdauthentisierung der Behörde | ⏳ kommt mit #216 (M27); bis dahin bleibt lokale Auth der einzige Anmeldeweg und `auth.registrationMode=closed` + Session-Verkürzung sind die Kompensation. Zeile wird im #216-PR scharfgestellt. | +Derzeit leer — `auth.local.enabled` ist mit #216 als `AUTH_LOCAL_ENABLED` (1.2) scharfgestellt. + +| Schalter | Referenzwert (geplant) | Status | +| -------- | ---------------------- | ------ | ## 2 Verifikations-Checkliste @@ -73,6 +75,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/public/v1/ponds # 40 curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false) curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false) curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session +curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/v1/auth/login # 404 (AUTH_LOCAL_ENABLED=false) curl -s https://HOST/api/v1/readyz # status ok ``` diff --git a/docs/vs-nfd/90-restrisiken.md b/docs/vs-nfd/90-restrisiken.md index f6e76c7..249d54b 100644 --- a/docs/vs-nfd/90-restrisiken.md +++ b/docs/vs-nfd/90-restrisiken.md @@ -33,21 +33,21 @@ nachvollziehbar. Empfänger. - **Entscheidung:** Projektleitung, PR #271 / Issue #212, 31.07.2026. -## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar +## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar — ERLEDIGT (#216, 31.07.2026) -- **Risiko:** Bis #216 (M27) existiert kein Schalter - `auth.local.enabled=false`; die Anwendung führt eigene - Passwort-Konten, obwohl die Behördenumgebung Fremdauthentisierung - vorsieht. Zusätzlich ist noch offen, ob der Schalter zur Laufzeit - umschaltbar sein wird oder einen Neustart verlangt — das entscheidet - #216 und trägt es hier nach. -- **Warum akzeptiert:** Reihenfolge der Umsetzung (M26 vor M27); - produktiver VS-NfD-Betrieb beginnt erst nach M27. -- **Kompensation:** Referenzkonfiguration (`50-haertungsleitfaden.md`): - Registrierung geschlossen, kurze Sessions (12 h absolut / 2 h idle), - Argon2id-Hashes, Rate-Limits, Audit der Anmeldungen. -- **Entscheidung:** Projektleitung, Maßnahmenplan Rev. 2 (M27-Planung), - 30.07.2026. +- **Status: geschlossen.** Der harte Schalter existiert als Deploy-Variable + `AUTH_LOCAL_ENABLED=false` (#214–#216, ADR 0021): jeder lokale + Credential-Flow (Login, Signup, Verifikation, Passwort + vergessen/zurücksetzen/ändern) antwortet 404; Anmeldung läuft über + OIDC (#214) bzw. Perimeter-Auth (#215). Die im Risiko offene Frage + „Laufzeit oder Deploy-Ebene" ist zugunsten der Deploy-Ebene + entschieden — ein kompromittierter Site-Admin kann den lokalen Pfad + nicht wieder öffnen; damit entsteht KEIN Laufzeit-Restrisiko. + Verbleibender Hinweis (dokumentiert, ADR 0021): bestehende + Argon2id-Passwort-Hashes bleiben nach dem Umschalten in der Datenbank + stehen; ihre Löschung ist bewusst nicht Teil von #216. +- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche + Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026). ## R-03 Plugin-Hash-Pinning verschoben diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 81d58db..7daaf3f 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -177,6 +177,20 @@ export const apiEnvSchema = z.object({ * header); a request carrying the header from any other peer is * rejected and audited. */ + /** + * The hard local-authentication switch (ADR 0021, issue #216) — the + * deploy-level realization of the planned `auth.local.enabled`. FALSE + * closes EVERY local credential flow with 404 (login, signup, e-mail + * verification, resend, password forgot/reset/change); authentication + * then comes exclusively from OIDC (#214) or the trusted proxy (#215). + * Deploy-level on purpose: a compromised Site Admin must not be able to + * reopen the local path at runtime. Bootstrap order: complete the + * first-run setup (or SETUP_ADMIN_* pre-seed) BEFORE flipping to false. + */ + AUTH_LOCAL_ENABLED: z + .enum(['true', 'false']) + .default('true') + .transform((value) => value === 'true'), AUTH_PROXY_HEADER: z.string().min(1).optional(), AUTH_PROXY_TRUSTED_PEERS: z .string() -- 2.45.2