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,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