SecurityConfiguration.kt
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
// CORS-Regeln gelten auch fuer die Preflight-Anfrage - die traegt
// keine Anmeldedaten und darf deshalb nicht an der Kette haengen
// bleiben.
.cors { }
// 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()
}