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:
co-authored by
Claude Opus 5
parent
741b41ef11
commit
bc8850a518
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user