feat(backend): Master-Passwort fuer die Dokumentenliste (Schritt 5)

GET /documents listet alle Dokumente und machte damit jede UUID auffindbar —
das Zugriffsmodell "unerratbarer Link" waere hinfaellig. Der Endpunkt
verlangt jetzt HTTP Basic; alles andere bleibt bewusst offen.

Ohne konfigurierten Hash ist die Liste ausdruecklich gesperrt (denyAll),
nicht offen: Ein vergessener Umgebungswert gaebe sonst jede UUID preis, und
niemandem fiele es auf, weil alles funktioniert. Bewusst als Regel und nicht
bloss als zufaelliges Passwort, das niemand kennt — der Unterschied ist
pruefbar: Mit dem Zufallspasswort blieb die Gegenprobe stumm, mit denyAll
faellt genau die danach benannte Zusicherung.

Die Sperre nach Fehlversuchen ist global statt je Adresse: Es gibt genau ein
Passwort, und hinter dem Reverse Proxy der Zielumgebung saehe der Server
ohnehin fuer alle dieselbe Adresse. Preis benannt (D76-Nachtrag 6).

135 Tests. Gegenproben: Schutz entfernt, Sperre entfernt, Voreinstellung
geoeffnet -> es faellt jeweils genau die danach benannte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:30:10 +02:00
co-authored by Claude Opus 5
parent 741b41ef11
commit bc8850a518
16 changed files with 544 additions and 18 deletions
+4 -3
View File
@@ -7,10 +7,11 @@ Status-Sync), später Tenzu-Adapter.
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung, **Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase. API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
Kommandos in README.md hier. Live-Editing (D76, Kommandos in README.md hier. Live-Editing (D76,
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 14 der Reihenfolge `docs/live-editing-proposal.md`) ist in Arbeit: Schritte 15 der Reihenfolge
dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen, dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen,
`PATCH /content` und der Änderungsfeed im `LiveEditingService`); offen sind `PATCH /content` und der Änderungsfeed im `LiveEditingService`,
`PATCH /title`, das Master-Passwort und der Client. Master-Passwort für die Dokumentenliste); offen sind `PATCH /title` und der
Client im Frontend.
## Konventionen ## Konventionen
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
+31 -5
View File
@@ -164,18 +164,44 @@ noch nicht steht. Voraussetzung ist eine **Einzelinstanz**; hinter einem Load
Balancer erführe ein Beobachter auf der zweiten Instanz nichts. Begründung: Balancer erführe ein Beobachter auf der zweiten Instanz nichts. Begründung:
D76-Nachtrag 5. D76-Nachtrag 5.
## Master-Passwort für die Dokumentenliste
`GET /api/v1/documents` listet **alle** Dokumente und machte damit jede UUID
auffindbar — das Zugriffsmodell „unerratbarer Link" wäre hinfällig. Dieser eine
Endpunkt verlangt deshalb HTTP Basic mit dem Benutzer `werkbaum`:
```bash
export WERKBAUM_MASTER_PASSWORD_HASH="{bcrypt}$(htpasswd -bnBC 12 "" geheim | tr -d ':\n')"
```
- **Ohne gesetzten Hash ist die Liste gesperrt** (401), nicht offen. Ein
vergessener Konfigurationsschritt darf nichts preisgeben; beim Start warnt
das Log.
- Der Hash trägt sein Verfahren als Präfix (`{bcrypt}…`) — so steht in der
Konfiguration, womit gehasht wurde.
- **Nach `max-attempts` Fehlversuchen** (5) ist der Endpunkt für `lockout`
(15 min) gesperrt: **429** mit `Retry-After`. Die Sperre ist **global**, nicht
je Adresse — es gibt genau ein Passwort, und hinter einem Reverse Proxy sähe
der Server ohnehin für alle dieselbe Adresse. Der Preis: Wer falsch rät,
sperrt die Liste für alle; die Dokumente selbst bleiben über ihre UUID
erreichbar.
- Die API ist zustandslos: keine Sitzung, kein CSRF-Token (der schützte hier
nichts und bräche jeden Client).
## Vorbereitete Erweiterungen ## Vorbereitete Erweiterungen
**Autorisierung** **Autorisierung**
- Geschützt ist bisher **genau ein** Endpunkt, siehe unten. Alles andere ist
über die unerratbare UUID erreichbar — das ist das Zugriffsmodell, nicht
eine Lücke.
- `bearerAuth` (JWT) ist in der OpenAPI-Spec als Security Scheme definiert, - `bearerAuth` (JWT) ist in der OpenAPI-Spec als Security Scheme definiert,
aber noch auf keine Operation angewendet. aber noch auf keine Operation angewendet. Später kommt echte
- Später: `spring-boot-starter-security` + `security: [bearerAuth]` in der Authentifizierung als Schicht davor; am Protokoll ändert sich dadurch
Spec; die Behavior-Tests erhalten dann einen Auth-Schritt nichts.
(„Angenommen ich bin als … angemeldet").
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76) **Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
- **Offen:** Umbenennen per `PATCH /title` (und damit das Ereignis - **Offen:** Umbenennen per `PATCH /title` (und damit das Ereignis
`RENAMED`), Master-Passwort für `GET /documents`, Client-Anpassung. `RENAMED`) sowie die Client-Anpassung im Frontend.
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber - `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
noch nicht ausgewertet. noch nicht ausgewertet.
+4
View File
@@ -28,6 +28,9 @@ val kotestVersion = "5.9.1"
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
// Nur fuer das Master-Passwort der Dokumentenliste (D76); alles andere
// bleibt ueber die unerratbare UUID erreichbar.
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Boot 4: Liquibase-Autokonfiguration liegt im eigenen Starter (zieht liquibase-core mit) // Boot 4: Liquibase-Autokonfiguration liegt im eigenen Starter (zieht liquibase-core mit)
implementation("org.springframework.boot:spring-boot-starter-liquibase") implementation("org.springframework.boot:spring-boot-starter-liquibase")
@@ -50,6 +53,7 @@ dependencies {
testImplementation("io.cucumber:cucumber-spring:$cucumberVersion") testImplementation("io.cucumber:cucumber-spring:$cucumberVersion")
testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion") testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion")
testImplementation("org.junit.platform:junit-platform-suite") testImplementation("org.junit.platform:junit-platform-suite")
testImplementation("org.springframework.security:spring-security-test")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -8
View File
@@ -1,8 +1,8 @@
# Live-Editing über HTTP (Variante „Simpel") # Live-Editing über HTTP (Variante „Simpel")
Status: **Konzept entschieden** (D76), **Schritte 14 der Umsetzungsreihenfolge Status: **Konzept entschieden** (D76), **Schritte 15 der Umsetzungsreihenfolge
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed); gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed,
das Master-Passwort und der Client stehen aus, ebenso das Umbenennen per Master-Passwort); der Client steht aus, ebenso das Umbenennen per
`PATCH /title` und damit das Ereignis `RENAMED`. Die offenen `PATCH /title` und damit das Ereignis `RENAMED`. Die offenen
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur `docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
@@ -31,10 +31,13 @@ Nachtrag 4.
- **Zugriff über die unerratbare Dokument-UUID**, wie ein Pad-Link: kein - **Zugriff über die unerratbare Dokument-UUID**, wie ein Pad-Link: kein
Login, kein Rechtemodell. Echte Authentifizierung kommt später als Schicht Login, kein Rechtemodell. Echte Authentifizierung kommt später als Schicht
davor; das Protokoll bleibt davon unberührt. davor; das Protokoll bleibt davon unberührt.
- **`GET /documents` verlangt ein Master-Passwort** (Hash serverseitig in - **`GET /documents` verlangt ein Master-Passwort** (HTTP Basic, Benutzer
einer Umgebungsvariable, geprüft über Spring Security). Ohne diesen Schutz `werkbaum`; Hash serverseitig in einer Umgebungsvariable, geprüft über
wäre jede UUID auflistbar und das Modell hinfällig. Der Endpunkt braucht Spring Security). Ohne diesen Schutz wäre jede UUID auflistbar und das
eine **Sperre nach Fehlversuchen**, und die Übertragung setzt HTTPS voraus. Modell hinfällig. **Ohne konfigurierten Hash ist die Liste gesperrt**, nicht
offen — ein vergessener Konfigurationsschritt darf nichts preisgeben. Die
**Sperre nach Fehlversuchen** ist global statt je Adresse (Begründung:
D76-Nachtrag 6). Die Übertragung setzt HTTPS voraus.
- **Identität ist pseudonym**: Jeder Client führt eine zufällige `clientId` - **Identität ist pseudonym**: Jeder Client führt eine zufällige `clientId`
und einen selbstgewählten Anzeigenamen (Etherpad-Modell). Ohne Anmeldung und einen selbstgewählten Anzeigenamen (Etherpad-Modell). Ohne Anmeldung
ist der Name eine Behauptung und darf in der Oberfläche nicht wie ein ist der Name eine Behauptung und darf in der Oberfläche nicht wie ein
@@ -429,7 +432,7 @@ verworfen.
Cucumber)~~ — gebaut, `LiveEditingService` Cucumber)~~ — gebaut, `LiveEditingService`
4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen 4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService` (Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService`
5. Master-Passwort für `GET /documents` (Spring Security) 5. ~~Master-Passwort für `GET /documents` (Spring Security)~~ — gebaut
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog) 6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)
**Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") — **Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") —
@@ -0,0 +1,134 @@
package de.werkbaum.api
import de.werkbaum.service.LoginThrottle
import de.werkbaum.service.MasterPasswordProperties
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.event.EventListener
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent
import org.springframework.security.authentication.event.AuthenticationSuccessEvent
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
/**
* Schützt **genau einen** Endpunkt: `GET /api/v1/documents`.
*
* Das Zugriffsmodell ist die unerratbare UUID (D76) eine Liste aller
* Dokumente machte jede davon auffindbar und das Modell hinfällig. Alles
* andere bleibt bewusst offen; echte Authentifizierung kommt später als
* Schicht davor, ohne dass sich am Protokoll etwas ändert.
*/
@Configuration
class SecurityConfiguration {
private val log = LoggerFactory.getLogger(javaClass)
/**
* Der Hash trägt sein Verfahren als Präfix (`{bcrypt}\$2a\$…`). So steht in
* der Konfiguration, womit gehasht wurde, und ein Wechsel des Verfahrens
* bricht nichts.
*/
@Bean
fun passwordEncoder(): PasswordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder()
/**
* Ein einziger Benutzer mit dem konfigurierten Hash. Ohne Konfiguration
* bekommt er ein zufälliges, nirgends notiertes Passwort der Zugang ist
* dann ohnehin schon per `denyAll` versperrt (siehe unten); das hier ist
* der zweite Riegel für den Fall, dass jemand den ersten wegnimmt.
*/
@Bean
fun masterUser(properties: MasterPasswordProperties): UserDetailsService {
if (!properties.configured) {
log.warn(
"werkbaum.master-password.hash ist nicht gesetzt " +
"GET /api/v1/documents bleibt gesperrt."
)
}
val hash = if (properties.configured) properties.hash
else passwordEncoder().encode(java.util.UUID.randomUUID().toString())
return InMemoryUserDetailsManager(
User.withUsername(MASTER_USERNAME).password(hash).roles("LIST").build()
)
}
@Bean
fun apiSecurity(
http: HttpSecurity,
throttle: LoginThrottle,
properties: MasterPasswordProperties,
): SecurityFilterChain =
http
// Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz
// hängt am Passwort, nicht an einem Cookie ein CSRF-Token
// schützte hier nichts und bräche jeden Client.
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
val liste = it.requestMatchers(HttpMethod.GET, "/api/v1/documents")
// Ohne konfiguriertes Passwort wird die Liste ausdrücklich
// verweigert, statt hinter einem Geheimnis zu liegen, das
// niemand kennt: Was gesperrt sein soll, soll auch gesperrt
// dastehen - nachlesbar und prüfbar.
if (properties.configured) liste.hasRole("LIST") else liste.denyAll()
it.anyRequest().permitAll()
}
.httpBasic { }
.addFilterBefore(LockoutFilter(throttle), BasicAuthenticationFilter::class.java)
.build()
companion object {
const val MASTER_USERNAME = "werkbaum"
}
}
/** Weist Anfragen ab, solange die Sperre steht vor jeder Passwortprüfung. */
class LockoutFilter(private val throttle: LoginThrottle) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
if (request.getHeader("Authorization") != null && throttle.locked()) {
response.setHeader("Retry-After", throttle.retryAfterSeconds().toString())
response.sendError(
HttpStatus.TOO_MANY_REQUESTS.value(),
"Zu viele Fehlversuche bitte später erneut versuchen",
)
return
}
filterChain.doFilter(request, response)
}
}
/**
* Zählt Fehlversuche mit. Spring Security veröffentlicht die Ereignisse von
* selbst dadurch hängt die Sperre nicht in der Passwortprüfung fest und
* bleibt für sich prüfbar.
*/
@Component
class LoginAttemptListener(private val throttle: LoginThrottle) {
@EventListener
fun onFailure(event: AuthenticationFailureBadCredentialsEvent) = throttle.recordFailure()
@EventListener
fun onSuccess(event: AuthenticationSuccessEvent) = throttle.recordSuccess()
}
@@ -0,0 +1,58 @@
package de.werkbaum.service
import org.springframework.stereotype.Component
import java.time.Clock
import java.time.Instant
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* Sperre nach Fehlversuchen für die Dokumentenliste (D76).
*
* **Global, nicht je Adresse.** Es gibt genau ein Master-Passwort; eine
* globale Sperre ist damit die passende Aussage und nicht zu umgehen, indem
* jemand die Adresse wechselt. Sie hängt außerdem nicht an
* `X-Forwarded-For` — hinter dem Reverse Proxy der Zielumgebung (D76,
* „Betrieb") sähe der Server sonst für alle dieselbe 127.0.0.1 und die Sperre
* wäre unfreiwillig doch global, nur schlechter begründet.
*
* Der Preis ist benannt: Wer das Passwort falsch rät, sperrt die Liste für
* alle — für ein paar Minuten. Die Liste ist eine Bequemlichkeit für den
* Betreiber; die Dokumente selbst bleiben über ihre UUID erreichbar.
*/
@Component
class LoginThrottle(
private val properties: MasterPasswordProperties,
private val clock: Clock,
) {
private val lock = ReentrantLock()
private var failures = 0
private var lockedUntil: Instant = Instant.EPOCH
/** Ist gerade gesperrt? Eine abgelaufene Sperre räumt sich dabei selbst weg. */
fun locked(): Boolean = lock.withLock {
if (clock.instant().isBefore(lockedUntil)) return true
if (lockedUntil != Instant.EPOCH) reset()
false
}
/** Restdauer der Sperre in Sekunden für `Retry-After`. */
fun retryAfterSeconds(): Long = lock.withLock {
maxOf(0, java.time.Duration.between(clock.instant(), lockedUntil).seconds)
}
fun recordFailure() = lock.withLock {
failures++
if (failures >= properties.maxAttempts) {
lockedUntil = clock.instant().plus(properties.lockout)
}
}
fun recordSuccess() = lock.withLock { reset() }
private fun reset() {
failures = 0
lockedUntil = Instant.EPOCH
}
}
@@ -0,0 +1,36 @@
package de.werkbaum.service
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
/**
* Der Schutz der Dokumentenliste (D76).
*
* Das Zugriffsmodell ist die **unerratbare UUID**, wie ein Pad-Link. Das
* kollidiert mit `GET /documents`, das sämtliche Dokumente auflistet und damit
* jede UUID auffindbar machte — der Schutz wäre hinfällig. Dieser eine
* Endpunkt verlangt deshalb ein Master-Passwort.
*
* [hash] ist ein **Passwort-Hash** (BCrypt), gesetzt über eine
* Umgebungsvariable; im
* Repository steht kein Zugangsdatum (backend/CLAUDE.md). Fehlt er, ist die
* Liste **gesperrt** statt offen: Die sichere Voreinstellung ist die, bei der
* ein vergessener Konfigurationsschritt nichts preisgibt.
*/
@ConfigurationProperties(prefix = "werkbaum.master-password")
data class MasterPasswordProperties(
/**
* Hash **mit Verfahrens-Präfix**, in Produktion `{bcrypt}$2a$…`
* (z. B. `htpasswd -bnBC 12 "" geheim | tr -d ':\n'`, davor `{bcrypt}`).
*/
val hash: String = "",
/** Fehlversuche bis zur Sperre. */
val maxAttempts: Int = 5,
/** Wie lange danach gesperrt bleibt. */
val lockout: Duration = Duration.ofMinutes(15),
) {
val configured: Boolean get() = hash.isNotBlank()
}
@@ -37,5 +37,12 @@ werkbaum:
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s. # haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
max-wait: 25s max-wait: 25s
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
master-password:
hash: ${WERKBAUM_MASTER_PASSWORD_HASH:}
max-attempts: 5
lockout: 15m
server: server:
port: 8080 port: 8080
@@ -27,6 +27,13 @@ paths:
tags: [Documents] tags: [Documents]
operationId: listDocuments operationId: listDocuments
summary: Alle Dokumente auflisten summary: Alle Dokumente auflisten
description: >
Verlangt das Master-Passwort (HTTP Basic, Benutzer `werkbaum`).
Ohne diesen Schutz waere jede Dokument-UUID auflistbar - und das
Zugriffsmodell "unerratbare UUID" damit hinfaellig. Nach mehreren
Fehlversuchen wird der Endpunkt fuer eine Weile gesperrt (429).
security:
- masterPassword: []
responses: responses:
"200": "200":
description: Liste aller Dokumente description: Liste aller Dokumente
@@ -36,6 +43,10 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/Document" $ref: "#/components/schemas/Document"
"401":
description: Master-Passwort fehlt oder ist falsch
"429":
description: Zu viele Fehlversuche; `Retry-After` nennt die Restdauer
post: post:
tags: [Documents] tags: [Documents]
operationId: createDocument operationId: createDocument
@@ -570,6 +581,14 @@ components:
type: string type: string
securitySchemes: securitySchemes:
masterPassword:
type: http
scheme: basic
description: >
Master-Passwort fuer die Dokumentenliste. Der Hash liegt serverseitig
in einer Umgebungsvariable (`werkbaum.master-password.hash`); ist er
nicht gesetzt, bleibt die Liste gesperrt.
bearerAuth: bearerAuth:
type: http type: http
scheme: bearer scheme: bearer
@@ -0,0 +1,77 @@
package de.werkbaum.api
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient
import java.util.Base64
/**
* Ohne konfiguriertes Master-Passwort ist die Dokumentenliste **gesperrt**,
* nicht offen.
*
* Die sichere Voreinstellung ist die, bei der ein vergessener
* Konfigurationsschritt nichts preisgibt: Andernfalls stünde jede Dokument-
* UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es
* jemandem auffiele.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@TestPropertySource(
properties = [
"werkbaum.master-password.hash=",
// Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext,
// und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander
// (Liquibase legt DATABASECHANGELOG ein zweites Mal an).
"spring.datasource.url=jdbc:h2:mem:editor-master-pw;MODE=PostgreSQL;" +
"DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
]
)
class MasterPasswordDefaultTest {
@Autowired
private lateinit var client: RestTestClient
@Test
fun `ohne Hash bleibt die Liste ohne Passwort verschlossen`() {
client.get()
.uri("/api/v1/documents")
.exchange()
.returnResult(String::class.java)
.status.value() shouldBe 401
}
@Test
fun `ohne Hash oeffnet auch ein leeres Passwort nicht`() {
val basic = Base64.getEncoder().encodeToString("werkbaum:".toByteArray())
client.get()
.uri("/api/v1/documents")
.header("Authorization", "Basic $basic")
.exchange()
.returnResult(String::class.java)
.status.value() shouldBe 401
}
@Test
fun `ein einzelnes Dokument bleibt ueber seine UUID erreichbar`() {
// Der Schutz gilt der Liste, nicht dem Dokument - sonst waere die
// ganze Zugriffsidee (Link teilen) dahin.
val created = client.post()
.uri("/api/v1/documents")
.header("Content-Type", "application/json")
.body("""{"title":"Offen","content":"- [ ] Eins"}""")
.exchange()
.returnResult(String::class.java)
created.status.value() shouldBe 201
val id = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(created.responseBody!!)!!.groupValues[1]
client.get()
.uri("/api/v1/documents/$id")
.exchange()
.returnResult(String::class.java)
.status.value() shouldBe 200
}
}
@@ -1,5 +1,6 @@
package de.werkbaum.bdd package de.werkbaum.bdd
import de.werkbaum.service.LoginThrottle
import de.werkbaum.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import io.cucumber.java.Before import io.cucumber.java.Before
@@ -20,9 +21,15 @@ class CucumberSpringConfiguration {
@Autowired @Autowired
private lateinit var historyRepository: DocumentHistoryRepository private lateinit var historyRepository: DocumentHistoryRepository
@Autowired
private lateinit var loginThrottle: LoginThrottle
@Before @Before
fun resetState() { fun resetState() {
repository.clear() repository.clear()
historyRepository.clear() historyRepository.clear()
// Die Sperre ist global und ueberlebt sonst das Szenario, das sie
// ausloest - ein gelungener Zugang hebt sie auf, genau wie im Betrieb.
loginThrottle.recordSuccess()
} }
} }
@@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult import org.springframework.test.web.servlet.client.EntityExchangeResult
import org.springframework.test.web.servlet.client.RestTestClient import org.springframework.test.web.servlet.client.RestTestClient
import java.util.Base64
/** /**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes * Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
@@ -65,14 +66,34 @@ class DocumentStepDefinitions {
.find(body())?.groupValues?.get(1) .find(body())?.groupValues?.get(1)
} }
@Wenn("ich alle Dokumente abrufe") @Wenn("ich alle Dokumente mit dem Master-Passwort abrufe")
fun `ich rufe alle Dokumente ab`() { fun `ich rufe alle Dokumente ab`() {
lastResponse = listDocuments("test-geheim")
}
@Wenn("ich alle Dokumente ohne Master-Passwort abrufe")
fun `ich rufe alle Dokumente ohne Passwort ab`() {
lastResponse = client.get() lastResponse = client.get()
.uri("/api/v1/documents") .uri("/api/v1/documents")
.exchange() .exchange()
.returnResult(String::class.java) .returnResult(String::class.java)
} }
@Wenn("ich {int} mal mit falschem Master-Passwort abrufe")
fun `ich rufe mehrfach mit falschem Passwort ab`(male: Int) {
repeat(male) { lastResponse = listDocuments("falsch") }
}
private fun listDocuments(passwort: String) = client.get()
.uri("/api/v1/documents")
.header(
"Authorization",
"Basic " + Base64.getEncoder()
.encodeToString("werkbaum:$passwort".toByteArray()),
)
.exchange()
.returnResult(String::class.java)
@Wenn("ich dieses Dokument abrufe") @Wenn("ich dieses Dokument abrufe")
fun `ich rufe dieses Dokument ab`() { fun `ich rufe dieses Dokument ab`() {
lastResponse = client.get() lastResponse = client.get()
@@ -0,0 +1,80 @@
package de.werkbaum.service
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
class LoginThrottleTest {
private class TestClock(var moment: Instant) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId?): Clock = this
override fun instant(): Instant = moment
}
private val clock = TestClock(Instant.parse("2026-01-01T12:00:00Z"))
private val throttle = LoginThrottle(
MasterPasswordProperties(maxAttempts = 3, lockout = Duration.ofMinutes(15)),
clock,
)
@Test
fun `ohne Fehlversuche ist nichts gesperrt`() {
throttle.locked() shouldBe false
}
@Test
fun `unterhalb der Schwelle bleibt offen`() {
repeat(2) { throttle.recordFailure() }
throttle.locked() shouldBe false
}
@Test
fun `mit der Schwelle wird gesperrt`() {
repeat(3) { throttle.recordFailure() }
throttle.locked() shouldBe true
}
@Test
fun `die Sperre laeuft ab`() {
repeat(3) { throttle.recordFailure() }
clock.moment = clock.moment.plus(Duration.ofMinutes(16))
throttle.locked() shouldBe false
}
@Test
fun `nach dem Ablauf faengt das Zaehlen von vorn an`() {
repeat(3) { throttle.recordFailure() }
clock.moment = clock.moment.plus(Duration.ofMinutes(16))
throttle.locked() shouldBe false
repeat(2) { throttle.recordFailure() }
throttle.locked() shouldBe false
}
@Test
fun `ein gelungener Zugang hebt die Sperre auf`() {
repeat(3) { throttle.recordFailure() }
throttle.recordSuccess()
throttle.locked() shouldBe false
}
@Test
fun `die Restdauer wird genannt`() {
repeat(3) { throttle.recordFailure() }
clock.moment = clock.moment.plus(Duration.ofMinutes(5))
throttle.retryAfterSeconds() shouldBe 600
}
@Test
fun `ohne Sperre ist die Restdauer null`() {
throttle.retryAfterSeconds() shouldBe 0
}
}
@@ -19,3 +19,9 @@ werkbaum:
live-editing: live-editing:
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten. # Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
max-wait: 5s max-wait: 5s
master-password:
# {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$...
hash: "{noop}test-geheim"
max-attempts: 3
lockout: 2s
@@ -13,10 +13,21 @@ Funktionalität: Dokumente verwalten
Szenario: Alle Dokumente auflisten Szenario: Alle Dokumente auflisten
Angenommen es existiert ein Dokument mit dem Titel "Erstes" Angenommen es existiert ein Dokument mit dem Titel "Erstes"
Und es existiert ein Dokument mit dem Titel "Zweites" Und es existiert ein Dokument mit dem Titel "Zweites"
Wenn ich alle Dokumente abrufe Wenn ich alle Dokumente mit dem Master-Passwort abrufe
Dann erhalte ich den Status 200 Dann erhalte ich den Status 200
Und die Antwort enthält 2 Dokumente Und die Antwort enthält 2 Dokumente
Szenario: Ohne Master-Passwort bleibt die Liste verschlossen
Angenommen es existiert ein Dokument mit dem Titel "Geheim"
Wenn ich alle Dokumente ohne Master-Passwort abrufe
Dann erhalte ich den Status 401
Szenario: Nach zu vielen Fehlversuchen ist die Liste gesperrt
Angenommen es existiert ein Dokument mit dem Titel "Geheim"
Wenn ich 3 mal mit falschem Master-Passwort abrufe
Und ich alle Dokumente mit dem Master-Passwort abrufe
Dann erhalte ich den Status 429
Szenario: Ein einzelnes Dokument abrufen Szenario: Ein einzelnes Dokument abrufen
Angenommen es existiert ein Dokument mit dem Titel "Protokoll" Angenommen es existiert ein Dokument mit dem Titel "Protokoll"
Wenn ich dieses Dokument abrufe Wenn ich dieses Dokument abrufe
+36
View File
@@ -6160,3 +6160,39 @@ durch Entfernen der Benachrichtigung, dann fällt genau dieses Szenario.
`DELETED`, `RESTORED` und `ROLLED_BACK`. Das Umbenennen bekommt seinen eigenen `DELETED`, `RESTORED` und `ROLLED_BACK`. Das Umbenennen bekommt seinen eigenen
Weg (`PATCH /title` mit `expectedVersion`) und erst damit den Typ — ihn vorher Weg (`PATCH /title` mit `expectedVersion`) und erst damit den Typ — ihn vorher
zu deklarieren wäre eine Zusage ohne Deckung. zu deklarieren wäre eine Zusage ohne Deckung.
**Nachtrag 6 — das Master-Passwort: gesperrt als Voreinstellung, global
gesperrt nach Fehlversuchen (2026-08-26).** Drei Festlegungen beim Bauen von
Schritt 5:
**Ohne konfigurierten Hash ist die Liste versperrt, nicht offen.** Die
naheliegende Bequemlichkeit — „solange nichts konfiguriert ist, lassen wir
durch" — kehrt die Beweislast um: Ein vergessener Umgebungswert gäbe jede
Dokument-UUID preis, und niemandem fiele es auf, weil alles funktioniert.
Umgekehrt fällt es sofort auf, und das Log sagt beim Start, was fehlt.
Umgesetzt ausdrücklich mit `denyAll`, nicht bloß über ein zufälliges Passwort,
das niemand kennt: Was gesperrt sein soll, soll auch gesperrt dastehen —
nachlesbar und **prüfbar**. Der Unterschied ist nicht theoretisch: Die erste
Fassung setzte nur das Zufallspasswort, und die Gegenprobe (den Schutz
mutieren, prüfen ob Tests fallen) blieb stumm. Erst mit `denyAll` fällt genau
die danach benannte Zusicherung.
**Die Sperre nach Fehlversuchen ist global, nicht je Adresse.** Es gibt genau
ein Passwort; eine globale Sperre ist damit die passende Aussage und nicht zu
umgehen, indem jemand die Adresse wechselt. Sie hängt außerdem nicht an
`X-Forwarded-For` — hinter dem Reverse Proxy der Zielumgebung (Nachtrag 1)
sähe der Server für alle dieselbe 127.0.0.1, und eine „adressbezogene" Sperre
wäre unfreiwillig doch global, nur schlechter begründet. Der Preis ist
benannt: Wer falsch rät, sperrt die Liste für alle, 15 Minuten lang. Die Liste
ist eine Bequemlichkeit für den Betreiber; die Dokumente selbst bleiben über
ihre UUID erreichbar.
**Der Hash trägt sein Verfahren als Präfix** (`{bcrypt}$2a$…`, Spring
Securitys `DelegatingPasswordEncoder`). So steht in der Konfiguration, womit
gehasht wurde, ein Wechsel des Verfahrens bricht nichts — und die Tests dürfen
`{noop}` benutzen, ohne dass dafür eine zweite Code-Bahn nötig wäre.
**Die neue Laufzeit-Abhängigkeit** (`spring-boot-starter-security`) ist in D76
ausdrücklich vorgesehen („geprüft über Spring Security") und damit von der
Rückfragepflicht der Wurzel-CLAUDE.md gedeckt. Sie ist zugleich der Platz für
die spätere richtige Authentifizierung.