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