mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-25 08:25:07 +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
|
@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue