#216: hard AUTH_LOCAL_ENABLED switch over every local credential flow #284
@ -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<void> {
|
||||
@ -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(
|
||||
|
||||
@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthedRequest>();
|
||||
|
||||
// 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<boolean>(LOCAL_CREDENTIAL_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isLocalFlow) throw new NotFoundException();
|
||||
}
|
||||
|
||||
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
|
||||
|
||||
if (rawToken && MUTATING_METHODS.has(request.method)) {
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
157
apps/api/src/auth/local-auth-switch.e2e.db.test.ts
Normal file
157
apps/api/src/auth/local-auth-switch.e2e.db.test.ts
Normal file
@ -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<string, unknown> }[] = [
|
||||
{ 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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))
|
||||
|
||||
@ -68,17 +68,27 @@ export function LoginPage(): React.JSX.Element {
|
||||
<a className="button button--block" href="/api/v1/auth/oidc/login">
|
||||
{t('auth:oidc.signIn', { provider: methods.data.oidc.label })}
|
||||
</a>
|
||||
{methods.data.local && (
|
||||
<p className="auth-card__separator" aria-hidden="true">
|
||||
{t('auth:oidc.or')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* AUTH_LOCAL_ENABLED=false (#216): the local form and its
|
||||
credential links disappear — the api answers 404 there anyway. */}
|
||||
{(methods.data?.local ?? true) && (
|
||||
<>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
{unverified && !resent && (
|
||||
<p className="form-banner">
|
||||
{t('auth:login.resendHint')}{' '}
|
||||
<button type="button" className="linklike" onClick={() => void resendVerification()}>
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
onClick={() => void resendVerification()}
|
||||
>
|
||||
{t('auth:login.resendLink')}
|
||||
</button>
|
||||
</p>
|
||||
@ -91,7 +101,11 @@ export function LoginPage(): React.JSX.Element {
|
||||
<input type="text" autoComplete="username" {...form.register('usernameOrEmail')} />
|
||||
</Field>
|
||||
<Field label={t('auth:login.password')} error={form.formState.errors.password?.message}>
|
||||
<input type="password" autoComplete="current-password" {...form.register('password')} />
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||
{t('auth:login.submit')}
|
||||
@ -103,6 +117,8 @@ export function LoginPage(): React.JSX.Element {
|
||||
<p className="auth-card__links">
|
||||
{t('auth:login.noAccount')} <Link to="/signup">{t('auth:login.signupLink')}</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@ 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. |
|
||||
@ -56,12 +56,14 @@ Settings-Cache ist in-process (operations.md).
|
||||
| `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)
|
||||
|
||||
Derzeit leer — `auth.local.enabled` ist mit #216 als `AUTH_LOCAL_ENABLED` (1.2) scharfgestellt.
|
||||
|
||||
| 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. |
|
||||
| -------- | ---------------------- | ------ |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user