diff --git a/backend/03-auth.go b/backend/03-auth.go index 906949d..95ce89f 100644 --- a/backend/03-auth.go +++ b/backend/03-auth.go @@ -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, `
@@ -247,11 +315,32 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) { %s - + Passwort vergessen? -`, loginErrorHTML(errMsg)) + +