mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-24 07:55:06 +03:00
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:
parent
113dcbb72a
commit
a984fba30d
5 changed files with 309 additions and 20 deletions
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue