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
@@ -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.
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:
port: 8080
@@ -27,6 +27,13 @@ paths:
tags: [Documents]
operationId: listDocuments
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:
"200":
description: Liste aller Dokumente
@@ -36,6 +43,10 @@ paths:
type: array
items:
$ref: "#/components/schemas/Document"
"401":
description: Master-Passwort fehlt oder ist falsch
"429":
description: Zu viele Fehlversuche; `Retry-After` nennt die Restdauer
post:
tags: [Documents]
operationId: createDocument
@@ -570,6 +581,14 @@ components:
type: string
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:
type: http
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
import de.werkbaum.service.LoginThrottle
import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import io.cucumber.java.Before
@@ -20,9 +21,15 @@ class CucumberSpringConfiguration {
@Autowired
private lateinit var historyRepository: DocumentHistoryRepository
@Autowired
private lateinit var loginThrottle: LoginThrottle
@Before
fun resetState() {
repository.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.test.web.servlet.client.EntityExchangeResult
import org.springframework.test.web.servlet.client.RestTestClient
import java.util.Base64
/**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
@@ -65,14 +66,34 @@ class DocumentStepDefinitions {
.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`() {
lastResponse = listDocuments("test-geheim")
}
@Wenn("ich alle Dokumente ohne Master-Passwort abrufe")
fun `ich rufe alle Dokumente ohne Passwort ab`() {
lastResponse = client.get()
.uri("/api/v1/documents")
.exchange()
.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")
fun `ich rufe dieses Dokument ab`() {
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:
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
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
Angenommen es existiert ein Dokument mit dem Titel "Erstes"
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
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
Angenommen es existiert ein Dokument mit dem Titel "Protokoll"
Wenn ich dieses Dokument abrufe