diff --git a/auth.go b/auth.go index 395cddc..30d868e 100644 --- a/auth.go +++ b/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -15,6 +16,30 @@ import ( const sessionCookie = "am_session" +var loginLimiter = struct { + sync.Mutex + attempts map[string][]time.Time +}{attempts: map[string][]time.Time{}} + +func loginAllowed(key string) bool { + now := time.Now() + loginLimiter.Lock() + defer loginLimiter.Unlock() + items := loginLimiter.attempts[key] + cutoff := now.Add(-15 * time.Minute) + kept := items[:0] + for _, t := range items { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= 10 { + return false + } + loginLimiter.attempts[key] = append(kept, now) + return true +} + func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) @@ -104,6 +129,11 @@ func (a *App) login(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required"}) return } + key := c.ClientIP() + ":" + strings.ToLower(strings.TrimSpace(in.Username)) + if !loginAllowed(key) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts; try again later"}) + return + } var user User if a.db.Where("username = ?", strings.TrimSpace(in.Username)).First(&user).Error != nil || user.Status != UserActive || checkPassword(user.PasswordHash, in.Password) != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) @@ -120,6 +150,7 @@ func (a *App) login(c *gin.Context) { return } http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: c.Request.TLS != nil, Expires: session.ExpiresAt}) + user.PasswordHash = "" c.JSON(http.StatusOK, gin.H{"user": user}) }