Add login backoff countdown
This commit is contained in:
parent
3426cea58c
commit
a64a6d187b
1 changed files with 113 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue