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
+4
-3
@@ -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 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,
|
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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Live-Editing über HTTP (Variante „Simpel")
|
# Live-Editing über HTTP (Variante „Simpel")
|
||||||
|
|
||||||
Status: **Konzept entschieden** (D76), **Schritte 1–4 der Umsetzungsreihenfolge
|
Status: **Konzept entschieden** (D76), **Schritte 1–5 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user