mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
13c5e55962
* Set minimum chars and ratelimit on pw * Implement onboarding flow for USB device tests by adding helper functions to handle welcome state and login. Enhance test setup to ensure device is ready before running tests. * Refactor password handling by removing rate limiting checks from update and delete password functions. Update UI error handling to remove rate limit messages. Clean up related test cases for rate limiting. * Refactor welcome flow and password handling in tests. Consolidate password setup functions and enhance local authentication mode checks. Update test cases to streamline onboarding and password management processes. * Update log file paths in DEVELOPMENT.md * Add password validation messages for multiple languages - Added error message for passwords that are too short (minimum 8 characters). - Included rate limiting error message for too many failed attempts. - Updated localization files for Danish, German, Spanish, French, Italian, Japanese, Norwegian, Portuguese, Swedish, and both Simplified and Traditional Chinese. * Update noder version in DEVELOPMENT.md * Fix typo in log file path in DEVELOPMENT.md * Refactor device onboarding and authentication flow in tests - Renamed `ensureWelcomeState` to `resetDeviceToWelcome` for clarity. - Consolidated password handling in welcome flow tests, replacing deprecated functions. - Updated test setup to ensure device is in noPassword mode before running tests. - Removed unused functions and cleaned up related test cases for better maintainability. * Refactor mouse round-trip tests to streamline cursor movement verification
196 lines
4.9 KiB
Go
196 lines
4.9 KiB
Go
package kvm
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Rate limiting configuration
|
|
const (
|
|
maxAttempts = 5 // Maximum attempts before lockout
|
|
baseWindow = 15 * time.Minute // Initial lockout window
|
|
maxBackoffLevel = 3 // Maximum backoff multiplier (15min, 30min, 1hr)
|
|
cleanupInterval = 5 * time.Minute // How often to clean stale entries
|
|
)
|
|
|
|
// rateLimitEntry tracks failed attempts for a single IP
|
|
type rateLimitEntry struct {
|
|
attempts int // Number of failed attempts in current window
|
|
windowStart time.Time // When the current window started
|
|
backoffLevel int // Exponential backoff level (0 = base, 1 = 2x, 2 = 4x, etc.)
|
|
lockedUntil time.Time // When the lockout expires (if locked)
|
|
}
|
|
|
|
// RateLimiter provides IP-based rate limiting for password-related endpoints
|
|
type RateLimiter struct {
|
|
mu sync.RWMutex
|
|
entries map[string]*rateLimitEntry
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
// NewRateLimiter creates a new rate limiter with automatic cleanup
|
|
func NewRateLimiter() *RateLimiter {
|
|
rl := &RateLimiter{
|
|
entries: make(map[string]*rateLimitEntry),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
go rl.cleanupLoop()
|
|
return rl
|
|
}
|
|
|
|
// cleanupLoop periodically removes stale entries to prevent memory growth
|
|
func (rl *RateLimiter) cleanupLoop() {
|
|
ticker := time.NewTicker(cleanupInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
rl.cleanup()
|
|
case <-rl.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanup removes entries that have expired
|
|
func (rl *RateLimiter) cleanup() {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for ip, entry := range rl.entries {
|
|
// Remove if not locked and window has expired
|
|
windowDuration := rl.getWindowDuration(entry.backoffLevel)
|
|
if now.After(entry.lockedUntil) && now.Sub(entry.windowStart) > windowDuration {
|
|
delete(rl.entries, ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getWindowDuration calculates the window duration based on backoff level
|
|
func (rl *RateLimiter) getWindowDuration(backoffLevel int) time.Duration {
|
|
if backoffLevel > maxBackoffLevel {
|
|
backoffLevel = maxBackoffLevel
|
|
}
|
|
// Exponential backoff: 15min, 30min, 1hr, 2hr
|
|
multiplier := 1 << backoffLevel // 1, 2, 4, 8
|
|
return baseWindow * time.Duration(multiplier)
|
|
}
|
|
|
|
// IsAllowed checks if the IP is allowed to make an attempt.
|
|
// Returns (allowed, retryAfterSeconds)
|
|
func (rl *RateLimiter) IsAllowed(ip string) (bool, int) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
entry, exists := rl.entries[ip]
|
|
|
|
if !exists {
|
|
return true, 0
|
|
}
|
|
|
|
// Check if currently locked out
|
|
if now.Before(entry.lockedUntil) {
|
|
retryAfter := int(entry.lockedUntil.Sub(now).Seconds()) + 1
|
|
return false, retryAfter
|
|
}
|
|
|
|
// Check if window has expired (reset attempts)
|
|
windowDuration := rl.getWindowDuration(entry.backoffLevel)
|
|
if now.Sub(entry.windowStart) > windowDuration {
|
|
// Window expired, allow the attempt (will be tracked on failure)
|
|
return true, 0
|
|
}
|
|
|
|
// Check if under the limit
|
|
if entry.attempts < maxAttempts {
|
|
return true, 0
|
|
}
|
|
|
|
// At limit but not locked - shouldn't happen, but handle gracefully
|
|
return true, 0
|
|
}
|
|
|
|
// RecordFailure records a failed authentication attempt for the IP
|
|
func (rl *RateLimiter) RecordFailure(ip string) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
entry, exists := rl.entries[ip]
|
|
|
|
if !exists {
|
|
rl.entries[ip] = &rateLimitEntry{
|
|
attempts: 1,
|
|
windowStart: now,
|
|
backoffLevel: 0,
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if window has expired
|
|
windowDuration := rl.getWindowDuration(entry.backoffLevel)
|
|
if now.Sub(entry.windowStart) > windowDuration {
|
|
// Start new window, but increase backoff if they were previously locked
|
|
if entry.backoffLevel > 0 || entry.attempts >= maxAttempts {
|
|
entry.backoffLevel++
|
|
if entry.backoffLevel > maxBackoffLevel {
|
|
entry.backoffLevel = maxBackoffLevel
|
|
}
|
|
}
|
|
entry.attempts = 1
|
|
entry.windowStart = now
|
|
entry.lockedUntil = time.Time{}
|
|
return
|
|
}
|
|
|
|
entry.attempts++
|
|
|
|
// Lock out if exceeded attempts
|
|
if entry.attempts >= maxAttempts {
|
|
lockDuration := rl.getWindowDuration(entry.backoffLevel)
|
|
entry.lockedUntil = now.Add(lockDuration)
|
|
}
|
|
}
|
|
|
|
// RecordSuccess clears the rate limit entry for an IP after successful auth
|
|
func (rl *RateLimiter) RecordSuccess(ip string) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
delete(rl.entries, ip)
|
|
}
|
|
|
|
// Stop stops the cleanup goroutine
|
|
func (rl *RateLimiter) Stop() {
|
|
close(rl.stopCh)
|
|
}
|
|
|
|
// Global rate limiter instance for password endpoints
|
|
var passwordRateLimiter = NewRateLimiter()
|
|
|
|
// RateLimitMiddleware creates a Gin middleware that applies rate limiting
|
|
func RateLimitMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ip := c.ClientIP()
|
|
|
|
allowed, retryAfter := passwordRateLimiter.IsAllowed(ip)
|
|
if !allowed {
|
|
c.Header("Retry-After", fmt.Sprintf("%d", retryAfter))
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "Too many failed attempts. Please try again later.",
|
|
"retry_after": retryAfter,
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|