diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 25083e6..c42e503 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -7,10 +7,11 @@ Status-Sync), später Tenzu-Adapter. **Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung, API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase. Kommandos in README.md hier. Live-Editing (D76, -`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1–4 der Reihenfolge +`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1–5 der Reihenfolge dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen, -`PATCH /content` und der Änderungsfeed im `LiveEditingService`); offen sind -`PATCH /title`, das Master-Passwort und der Client. +`PATCH /content` und der Änderungsfeed im `LiveEditingService`, +Master-Passwort für die Dokumentenliste); offen sind `PATCH /title` und der +Client im Frontend. ## Konventionen - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. diff --git a/backend/README.md b/backend/README.md index 6b72fed..563fd3c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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: 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 **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, - aber noch auf keine Operation angewendet. -- Später: `spring-boot-starter-security` + `security: [bearerAuth]` in der - Spec; die Behavior-Tests erhalten dann einen Auth-Schritt - („Angenommen ich bin als … angemeldet"). + aber noch auf keine Operation angewendet. Später kommt echte + Authentifizierung als Schicht davor; am Protokoll ändert sich dadurch + nichts. **Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76) - **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 noch nicht ausgewertet. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 38f3f30..612a5ef 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -28,6 +28,9 @@ val kotestVersion = "5.9.1" dependencies { implementation("org.springframework.boot:spring-boot-starter-web") 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") // Boot 4: Liquibase-Autokonfiguration liegt im eigenen Starter (zieht liquibase-core mit) implementation("org.springframework.boot:spring-boot-starter-liquibase") @@ -50,6 +53,7 @@ dependencies { testImplementation("io.cucumber:cucumber-spring:$cucumberVersion") testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion") testImplementation("org.junit.platform:junit-platform-suite") + testImplementation("org.springframework.security:spring-security-test") } // --------------------------------------------------------------------------- diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md index fadc465..aebe777 100644 --- a/backend/docs/live-editing-proposal.md +++ b/backend/docs/live-editing-proposal.md @@ -1,8 +1,8 @@ # Live-Editing über HTTP (Variante „Simpel") -Status: **Konzept entschieden** (D76), **Schritte 1–4 der Umsetzungsreihenfolge -gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed); -das Master-Passwort und der Client stehen aus, ebenso das Umbenennen per +Status: **Konzept entschieden** (D76), **Schritte 1–5 der Umsetzungsreihenfolge +gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed, +Master-Passwort); der Client steht aus, ebenso das Umbenennen per `PATCH /title` und damit das Ereignis `RENAMED`. Die offenen Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in `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 Login, kein Rechtemodell. Echte Authentifizierung kommt später als Schicht davor; das Protokoll bleibt davon unberührt. -- **`GET /documents` verlangt ein Master-Passwort** (Hash serverseitig in - einer Umgebungsvariable, geprüft über Spring Security). Ohne diesen Schutz - wäre jede UUID auflistbar und das Modell hinfällig. Der Endpunkt braucht - eine **Sperre nach Fehlversuchen**, und die Übertragung setzt HTTPS voraus. +- **`GET /documents` verlangt ein Master-Passwort** (HTTP Basic, Benutzer + `werkbaum`; Hash serverseitig in einer Umgebungsvariable, geprüft über + Spring Security). Ohne diesen Schutz wäre jede UUID auflistbar und das + 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` und einen selbstgewählten Anzeigenamen (Etherpad-Modell). Ohne Anmeldung ist der Name eine Behauptung und darf in der Oberfläche nicht wie ein @@ -429,7 +432,7 @@ verworfen. Cucumber)~~ — gebaut, `LiveEditingService` 4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen (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) **Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") — diff --git a/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt b/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt new file mode 100644 index 0000000..1f2633f --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt @@ -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() +} diff --git a/backend/src/main/kotlin/de/werkbaum/service/LoginThrottle.kt b/backend/src/main/kotlin/de/werkbaum/service/LoginThrottle.kt new file mode 100644 index 0000000..0cda49b --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/LoginThrottle.kt @@ -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 + } +} diff --git a/backend/src/main/kotlin/de/werkbaum/service/MasterPasswordProperties.kt b/backend/src/main/kotlin/de/werkbaum/service/MasterPasswordProperties.kt new file mode 100644 index 0000000..cb558b6 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/MasterPasswordProperties.kt @@ -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() +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index ffc9bad..2c2f57d 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -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 diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index b08ba54..cd47dac 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -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 diff --git a/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt b/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt new file mode 100644 index 0000000..d1007e0 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt @@ -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 + } +} diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt b/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt index a714e0a..fe79059 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt @@ -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() } } diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt index ca482e4..073def4 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt @@ -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() diff --git a/backend/src/test/kotlin/de/werkbaum/service/LoginThrottleTest.kt b/backend/src/test/kotlin/de/werkbaum/service/LoginThrottleTest.kt new file mode 100644 index 0000000..48d5865 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/service/LoginThrottleTest.kt @@ -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 + } +} diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml index acee53e..a06821a 100644 --- a/backend/src/test/resources/application.yaml +++ b/backend/src/test/resources/application.yaml @@ -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 diff --git a/backend/src/test/resources/features/dokumente.feature b/backend/src/test/resources/features/dokumente.feature index ff1117e..8d7eb24 100644 --- a/backend/src/test/resources/features/dokumente.feature +++ b/backend/src/test/resources/features/dokumente.feature @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 800d4c9..c20f579 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6160,3 +6160,39 @@ durch Entfernen der Benachrichtigung, dann fällt genau dieses Szenario. `DELETED`, `RESTORED` und `ROLLED_BACK`. Das Umbenennen bekommt seinen eigenen Weg (`PATCH /title` mit `expectedVersion`) und erst damit den Typ — ihn vorher 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.