LoginThrottle.kt
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
}
}