Add brute force protection to authentication system

- Implemented IP-based blocking after 3 failed login attempts, with a 1-minute lockout period.
- Enhanced login handler to check for blocked IPs and record failed attempts.
- Added tests for brute force protection and successful login clearing failed attempts.
- Updated README and example configuration to document new security features.
This commit is contained in:
Andy Oknen 2025-07-30 09:19:05 +00:00
parent 113dcbb72a
commit a984fba30d
5 changed files with 309 additions and 20 deletions

View file

@ -9,6 +9,7 @@ This module provides a web interface for managing Yggdrasil node through a brows
- ✅ Development and production build modes
- ✅ Custom session-based authentication
- ✅ Beautiful login page (password-only)
- ✅ **Brute force protection** with IP blocking
- ✅ Session management with automatic cleanup
- ✅ IPv4 and IPv6 support
- ✅ Path traversal attack protection
@ -90,6 +91,10 @@ server.Stop()
- Custom session-based authentication (password protection)
- HttpOnly and Secure cookies
- Session expiration (24 hours)
- **Brute force protection**: IP blocking after 3 failed attempts
- **Temporary lockout**: 1-minute timeout for blocked IPs
- Automatic cleanup of expired blocks and sessions
- Real IP detection (supports X-Forwarded-For, X-Real-IP headers)
- Health check endpoint always accessible without authentication
## Testing

View file

@ -145,3 +145,143 @@ func TestHealthEndpointNoAuth(t *testing.T) {
t.Errorf("Expected 'OK', got '%s'", rr.Body.String())
}
}
func TestBruteForceProtection(t *testing.T) {
logger := log.New(nil, "test: ", log.Flags())
server := Server("127.0.0.1:0", "testpassword", logger)
// Test multiple failed attempts from same IP
clientIP := "192.168.1.100"
// First 3 attempts should be allowed but fail
for i := 1; i <= 3; i++ {
t.Run(fmt.Sprintf("Failed_attempt_%d", i), func(t *testing.T) {
loginData := `{"password":"wrongpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code)
}
})
}
// 4th attempt should be blocked
t.Run("Blocked_attempt", func(t *testing.T) {
loginData := `{"password":"wrongpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("Expected 429 for blocked attempt, got %d", rr.Code)
}
})
// Even correct password should be blocked during block period
t.Run("Correct_password_while_blocked", func(t *testing.T) {
loginData := `{"password":"testpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("Expected 429 even for correct password while blocked, got %d", rr.Code)
}
})
}
func TestBruteForceProtectionDifferentIPs(t *testing.T) {
logger := log.New(nil, "test: ", log.Flags())
server := Server("127.0.0.1:0", "testpassword", logger)
// Failed attempts from one IP shouldn't affect another IP
ip1 := "192.168.1.100"
ip2 := "192.168.1.101"
// Block first IP
for i := 1; i <= 3; i++ {
loginData := `{"password":"wrongpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = ip1 + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected 401 for failed attempt %d from IP1, got %d", i, rr.Code)
}
}
// Second IP should still be able to attempt login
t.Run("Different_IP_not_blocked", func(t *testing.T) {
loginData := `{"password":"wrongpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = ip2 + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected 401 for different IP (not blocked), got %d", rr.Code)
}
})
}
func TestSuccessfulLoginClearsFailedAttempts(t *testing.T) {
logger := log.New(nil, "test: ", log.Flags())
server := Server("127.0.0.1:0", "testpassword", logger)
clientIP := "192.168.1.100"
// Make 2 failed attempts
for i := 1; i <= 2; i++ {
loginData := `{"password":"wrongpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code)
}
}
// Successful login should clear failed attempts
t.Run("Successful_login_clears_attempts", func(t *testing.T) {
loginData := `{"password":"testpassword"}`
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":12345"
rr := httptest.NewRecorder()
server.loginHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected 200 for correct password, got %d", rr.Code)
}
// Verify failed attempts were cleared
server.attemptsMux.RLock()
_, exists := server.failedAttempts[clientIP]
server.attemptsMux.RUnlock()
if exists {
t.Error("Failed attempts should be cleared after successful login")
}
})
}

View file

@ -33,9 +33,13 @@
// - Session-based authentication with secure cookies
// - 24-hour session expiration
// - Automatic session cleanup
// - Brute force protection (3 failed attempts = 1 minute block)
// - IP-based blocking with automatic cleanup
//
// Security recommendations:
// - Use a strong, unique password (12+ characters)
// - Bind to localhost (127.0.0.1) unless you need remote access
// - Consider using HTTPS reverse proxy for production deployments
// - Sessions are stored in memory and lost on server restart
// - Sessions are stored in memory and lost on server restart
// - Failed login attempts are tracked per IP address
// - If behind a reverse proxy, ensure X-Forwarded-For headers are set correctly

View file

@ -16,24 +16,39 @@ import (
)
type WebUIServer struct {
server *http.Server
log core.Logger
listen string
password string
sessions map[string]time.Time // sessionID -> expiry time
sessionsMux sync.RWMutex
server *http.Server
log core.Logger
listen string
password string
sessions map[string]time.Time // sessionID -> expiry time
sessionsMux sync.RWMutex
failedAttempts map[string]*FailedLoginInfo // IP -> failed login info
attemptsMux sync.RWMutex
}
type LoginRequest struct {
Password string `json:"password"`
}
type FailedLoginInfo struct {
Count int
LastAttempt time.Time
BlockedUntil time.Time
}
const (
MaxFailedAttempts = 3
BlockDuration = 1 * time.Minute
AttemptWindow = 15 * time.Minute // Reset counter if no attempts in 15 minutes
)
func Server(listen string, password string, log core.Logger) *WebUIServer {
return &WebUIServer{
listen: listen,
password: password,
log: log,
sessions: make(map[string]time.Time),
listen: listen,
password: password,
log: log,
sessions: make(map[string]time.Time),
failedAttempts: make(map[string]*FailedLoginInfo),
}
}
@ -79,6 +94,89 @@ func (w *WebUIServer) createSession() string {
return sessionID
}
// getClientIP extracts the real client IP from request
func (w *WebUIServer) getClientIP(r *http.Request) string {
// Check for forwarded IP headers (for reverse proxies)
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
// Take the first IP in the chain
ips := strings.Split(forwarded, ",")
return strings.TrimSpace(ips[0])
}
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return realIP
}
// Extract IP from RemoteAddr
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}
// isIPBlocked checks if an IP address is currently blocked
func (w *WebUIServer) isIPBlocked(ip string) bool {
w.attemptsMux.RLock()
defer w.attemptsMux.RUnlock()
info, exists := w.failedAttempts[ip]
if !exists {
return false
}
return time.Now().Before(info.BlockedUntil)
}
// recordFailedAttempt records a failed login attempt for an IP
func (w *WebUIServer) recordFailedAttempt(ip string) {
w.attemptsMux.Lock()
defer w.attemptsMux.Unlock()
now := time.Now()
info, exists := w.failedAttempts[ip]
if !exists {
info = &FailedLoginInfo{}
w.failedAttempts[ip] = info
}
// Reset counter if last attempt was too long ago
if now.Sub(info.LastAttempt) > AttemptWindow {
info.Count = 0
}
info.Count++
info.LastAttempt = now
// Block IP if too many failed attempts
if info.Count >= MaxFailedAttempts {
info.BlockedUntil = now.Add(BlockDuration)
w.log.Warnf("IP %s blocked for %v after %d failed login attempts", ip, BlockDuration, info.Count)
}
}
// clearFailedAttempts clears failed attempts for an IP (on successful login)
func (w *WebUIServer) clearFailedAttempts(ip string) {
w.attemptsMux.Lock()
defer w.attemptsMux.Unlock()
delete(w.failedAttempts, ip)
}
// cleanupFailedAttempts removes old failed attempt records
func (w *WebUIServer) cleanupFailedAttempts() {
w.attemptsMux.Lock()
defer w.attemptsMux.Unlock()
now := time.Now()
for ip, info := range w.failedAttempts {
// Remove if block period has expired and no recent attempts
if now.After(info.BlockedUntil) && now.Sub(info.LastAttempt) > AttemptWindow {
delete(w.failedAttempts, ip)
}
}
}
// cleanupExpiredSessions removes expired sessions
func (w *WebUIServer) cleanupExpiredSessions() {
w.sessionsMux.Lock()
@ -126,13 +224,22 @@ func (w *WebUIServer) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
// loginHandler handles password authentication
// loginHandler handles password authentication with brute force protection
func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clientIP := w.getClientIP(r)
// Check if IP is blocked
if w.isIPBlocked(clientIP) {
w.log.Warnf("Blocked login attempt from %s (IP is temporarily blocked)", clientIP)
http.Error(rw, "Too many failed attempts. Please try again later.", http.StatusTooManyRequests)
return
}
var loginReq LoginRequest
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
http.Error(rw, "Invalid request", http.StatusBadRequest)
@ -141,11 +248,15 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
// Check password
if subtle.ConstantTimeCompare([]byte(loginReq.Password), []byte(w.password)) != 1 {
w.log.Debugf("Authentication failed for request from %s", r.RemoteAddr)
w.log.Debugf("Authentication failed for request from %s", clientIP)
w.recordFailedAttempt(clientIP)
http.Error(rw, "Invalid password", http.StatusUnauthorized)
return
}
// Successful login - clear any failed attempts
w.clearFailedAttempts(clientIP)
// Create session
sessionID := w.createSession()
@ -160,7 +271,7 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
MaxAge: 24 * 60 * 60, // 24 hours
})
w.log.Debugf("Successful authentication for request from %s", r.RemoteAddr)
w.log.Infof("Successful authentication for IP %s", clientIP)
rw.WriteHeader(http.StatusOK)
}
@ -196,12 +307,20 @@ func (w *WebUIServer) Start() error {
}
}
// Start session cleanup routine
// Start cleanup routines
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
w.cleanupExpiredSessions()
sessionTicker := time.NewTicker(1 * time.Hour)
attemptsTicker := time.NewTicker(5 * time.Minute) // Clean failed attempts more frequently
defer sessionTicker.Stop()
defer attemptsTicker.Stop()
for {
select {
case <-sessionTicker.C:
w.cleanupExpiredSessions()
case <-attemptsTicker.C:
w.cleanupFailedAttempts()
}
}
}()

View file

@ -63,6 +63,13 @@
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background-color: #f8f9fa;
border-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
.login-button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@ -144,8 +151,22 @@
if (response.ok) {
// Success - redirect to main page
window.location.href = '/';
} else if (response.status === 429) {
// Too many requests - IP blocked
errorMessage.textContent = 'Too many failed attempts. Please wait 1 minute before trying again.';
errorMessage.style.display = 'block';
document.getElementById('password').value = '';
document.getElementById('password').disabled = true;
// Re-enable after 1 minute
setTimeout(() => {
document.getElementById('password').disabled = false;
document.getElementById('password').focus();
errorMessage.style.display = 'none';
}, 60000);
} else {
// Show error message
// Invalid password
errorMessage.textContent = 'Invalid password. Please try again.';
errorMessage.style.display = 'block';
document.getElementById('password').value = '';
document.getElementById('password').focus();