Add login backoff countdown

This commit is contained in:
DonVoo 2026-07-13 13:43:46 +02:00
parent 3426cea58c
commit a64a6d187b

View file

@ -9,8 +9,10 @@ import (
"fmt"
"html"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
@ -24,6 +26,18 @@ const (
roleAdmin = "admin"
passwordHashIterations = 120000
defaultSessionValidDays = 7
loginBackoffBase = 15 * time.Second
loginBackoffMax = time.Hour
)
type loginBackoff struct {
failures int
lockedUntil time.Time
}
var (
loginBackoffMu sync.Mutex
loginBackoffs = map[string]loginBackoff{}
)
// InitAuth bereitet die Session-/Login-Verwaltung vor.
@ -122,27 +136,39 @@ func hasRole(user AppUser, needed string) bool {
func loginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
renderLoginPage(w, r.URL.Query().Get("err"))
if locked, rest := loginBackoffCheck(clientIP(r)); locked {
renderLoginPage(w, "Zu viele Fehlversuche. Bitte warten.", rest)
return
}
renderLoginPage(w, r.URL.Query().Get("err"), 0)
case http.MethodPost:
if err := r.ParseForm(); err != nil {
renderLoginPage(w, err.Error())
renderLoginPage(w, err.Error(), 0)
return
}
ip := clientIP(r)
if locked, rest := loginBackoffCheck(ip); locked {
renderLoginPage(w, "Zu viele Fehlversuche. Bitte warten.", rest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
user, err := GetAppUser(username)
if err != nil || !user.Active || !CheckPassword(password, user.PasswordHash) {
renderLoginPage(w, "Login fehlgeschlagen.")
wait := loginBackoffFailure(ip)
log.Printf("login failed username=%q ip=%s failures=%d", username, ip, wait.failures)
renderLoginPage(w, fmt.Sprintf("Login fehlgeschlagen. Naechster Versuch in %d Sekunden.", wait.remainingSeconds()), wait.remainingSeconds())
return
}
loginBackoffReset(ip)
token, err := randomToken(32)
if err != nil {
renderLoginPage(w, err.Error())
renderLoginPage(w, err.Error(), 0)
return
}
expires := time.Now().Add(defaultSessionValidDays * 24 * time.Hour)
if err := CreateSession(token, user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil {
renderLoginPage(w, err.Error())
renderLoginPage(w, err.Error(), 0)
return
}
http.SetCookie(w, &http.Cookie{
@ -160,6 +186,48 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
}
}
func loginBackoffCheck(ip string) (bool, int) {
loginBackoffMu.Lock()
defer loginBackoffMu.Unlock()
b := loginBackoffs[ip]
if time.Now().Before(b.lockedUntil) {
return true, b.remainingSeconds()
}
return false, 0
}
func loginBackoffFailure(ip string) loginBackoff {
loginBackoffMu.Lock()
defer loginBackoffMu.Unlock()
b := loginBackoffs[ip]
b.failures++
exp := b.failures - 1
if exp > 12 {
exp = 12
}
wait := loginBackoffBase << uint(exp)
if wait > loginBackoffMax || wait <= 0 {
wait = loginBackoffMax
}
b.lockedUntil = time.Now().Add(wait)
loginBackoffs[ip] = b
return b
}
func loginBackoffReset(ip string) {
loginBackoffMu.Lock()
delete(loginBackoffs, ip)
loginBackoffMu.Unlock()
}
func (b loginBackoff) remainingSeconds() int {
remaining := int(time.Until(b.lockedUntil).Seconds()) + 1
if remaining < 0 {
return 0
}
return remaining
}
func forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@ -234,7 +302,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func renderLoginPage(w http.ResponseWriter, errMsg string) {
func renderLoginPage(w http.ResponseWriter, errMsg string, waitSeconds int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@ -247,11 +315,32 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
<label class="label">Benutzer<input class="input" name="username" autocomplete="username" autofocus required></label>
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
%s
<button class="btn" type="submit">Anmelden</button>
<button class="btn" type="submit" data-login-button data-wait-seconds="%d">Anmelden</button>
<a class="login-link" href="/password/forgot">Passwort vergessen?</a>
</form>
</main>
</body></html>`, loginErrorHTML(errMsg))
<script>
(function(){
var button = document.querySelector('[data-login-button]');
if (!button) return;
var wait = parseInt(button.dataset.waitSeconds || '0', 10);
if (!wait) return;
var original = button.textContent;
function tick(){
if (wait <= 0) {
button.disabled = false;
button.textContent = original;
return;
}
button.disabled = true;
button.textContent = 'Warten ' + wait + 's';
wait -= 1;
setTimeout(tick, 1000);
}
tick();
})();
</script>
</body></html>`, loginErrorHTML(errMsg), waitSeconds)
}
func renderForgotPasswordPage(w http.ResponseWriter, msg string) {
@ -408,3 +497,19 @@ func baseURL(r *http.Request) string {
}
return proto + "://" + host
}
func clientIP(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
if first := strings.TrimSpace(strings.Split(forwarded, ",")[0]); first != "" {
return first
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return host
}
return r.RemoteAddr
}