diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index 806b01d01..edd23daf7 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -66,30 +66,79 @@ export const Login: Component = (props) => { setLoading(true); try { - // Test the credentials directly first - const response = await fetch('/api/state', { + // Use the new login endpoint for better feedback + const response = await fetch('/api/login', { + method: 'POST', headers: { - 'Authorization': `Basic ${btoa(`${username()}:${password()}`)}`, - 'X-Requested-With': 'XMLHttpRequest', // Prevent browser auth popup + 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + username: username(), + password: password() + }), credentials: 'include' // Important for session cookie }); - if (response.ok) { + const data = await response.json(); + + if (response.ok && data.success) { // Credentials are valid, save them and notify parent setBasicAuth(username(), password()); props.onLogin(); + } else if (response.status === 403) { + // Account is locked + if (data.remainingMinutes) { + setError(`Account locked. Please try again in ${data.remainingMinutes} ${data.remainingMinutes === 1 ? 'minute' : 'minutes'}.`); + } else { + setError(data.message || 'Account temporarily locked due to too many failed attempts.'); + } + // Clear the input fields + setUsername(''); + setPassword(''); + } else if (response.status === 429) { + // Rate limited + setError(data.message || 'Too many requests. Please wait a moment and try again.'); } else if (response.status === 401) { - setError('Invalid username or password'); + // Invalid credentials with attempt information + if (data.remaining !== undefined && data.remaining > 0) { + setError(`${data.message || 'Invalid username or password.'} (${data.remaining} ${data.remaining === 1 ? 'attempt' : 'attempts'} remaining)`); + } else if (data.locked) { + setError(data.message || 'Invalid username or password. Account is now locked.'); + } else { + setError(data.message || 'Invalid username or password'); + } // Clear the input fields setUsername(''); setPassword(''); } else { - setError('Server error. Please try again.'); + setError(data.message || 'Server error. Please try again.'); } } catch (err) { - setError('Failed to connect to server'); + // Try the old method as fallback + try { + const response = await fetch('/api/state', { + headers: { + 'Authorization': `Basic ${btoa(`${username()}:${password()}`)}`, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + credentials: 'include' + }); + + if (response.ok) { + setBasicAuth(username(), password()); + props.onLogin(); + } else if (response.status === 401) { + setError('Invalid username or password'); + setUsername(''); + setPassword(''); + } else { + setError('Server error. Please try again.'); + } + } catch (fallbackErr) { + setError('Failed to connect to server'); + } } finally { setLoading(false); } @@ -210,15 +259,33 @@ const LoginForm: Component<{ -
+
- - - + + + + } + > + + + +
-

{error()}

+

{error()}

+ +

+ Lockouts automatically expire after the specified time. If you need immediate access, contact your administrator. +

+
diff --git a/internal/api/auth.go b/internal/api/auth.go index 9b35ac30d..666620503 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -4,6 +4,7 @@ import ( cryptorand "crypto/rand" "encoding/base64" "encoding/hex" + "fmt" "net/http" "strings" "sync" @@ -327,11 +328,27 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } // Check if account is locked out - if IsLockedOut(parts[0]) || IsLockedOut(clientIP) { + _, userLockedUntil, userLocked := GetLockoutInfo(parts[0]) + _, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP) + + if userLocked || ipLocked { + lockedUntil := userLockedUntil + if ipLocked && ipLockedUntil.After(lockedUntil) { + lockedUntil = ipLockedUntil + } + + remainingMinutes := int(time.Until(lockedUntil).Minutes()) + if remainingMinutes < 1 { + remainingMinutes = 1 + } + log.Warn().Str("user", parts[0]).Str("ip", clientIP).Msg("Account locked out") LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Account locked") if w != nil { - http.Error(w, "Account temporarily locked due to failed attempts", http.StatusForbidden) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(fmt.Sprintf(`{"error":"Account temporarily locked","message":"Too many failed attempts. Please try again in %d minutes.","lockedUntil":"%s"}`, + remainingMinutes, lockedUntil.Format(time.RFC3339)))) } return false } @@ -421,8 +438,32 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } else { // Failed login RecordFailedLogin(parts[0]) - RecordFailedLogin(GetClientIP(r)) - LogAuditEvent("login", parts[0], GetClientIP(r), r.URL.Path, false, "Invalid credentials") + RecordFailedLogin(clientIP) + LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Invalid credentials") + + // Get updated attempt counts + newUserAttempts, _, _ := GetLockoutInfo(parts[0]) + newIPAttempts, _, _ := GetLockoutInfo(clientIP) + + // Use the higher count for warning + attempts := newUserAttempts + if newIPAttempts > attempts { + attempts = newIPAttempts + } + + if r.URL.Path == "/api/login" && w != nil { + // For login endpoint, provide detailed error response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + remaining := maxFailedAttempts - attempts + if remaining > 0 { + w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`, + attempts, remaining, maxFailedAttempts))) + } else { + w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","locked":true,"message":"Account locked for 15 minutes"}`,))) + } + return false + } } } } diff --git a/internal/api/router.go b/internal/api/router.go index fde8e7eee..c3be18f5d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -187,6 +187,8 @@ func (r *Router) setupRoutes() { // Security routes r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword) r.mux.HandleFunc("/api/logout", r.handleLogout) + r.mux.HandleFunc("/api/login", r.handleLogin) + r.mux.HandleFunc("/api/security/reset-lockout", r.handleResetLockout) r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") @@ -1198,6 +1200,215 @@ func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) { }) } +// handleLogin handles login requests and provides detailed feedback about lockouts +func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", + "Only POST method is allowed", nil) + return + } + + // Parse request + var loginReq struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(req.Body).Decode(&loginReq); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", + "Invalid request body", nil) + return + } + + clientIP := GetClientIP(req) + + // Check if account is locked out before attempting login + _, userLockedUntil, userLocked := GetLockoutInfo(loginReq.Username) + _, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP) + + if userLocked || ipLocked { + lockedUntil := userLockedUntil + if ipLocked && ipLockedUntil.After(lockedUntil) { + lockedUntil = ipLockedUntil + } + + remainingMinutes := int(time.Until(lockedUntil).Minutes()) + if remainingMinutes < 1 { + remainingMinutes = 1 + } + + LogAuditEvent("login", loginReq.Username, clientIP, req.URL.Path, false, "Account locked") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "account_locked", + "message": fmt.Sprintf("Too many failed attempts. Account is locked for %d more minutes.", remainingMinutes), + "lockedUntil": lockedUntil.Format(time.RFC3339), + "remainingMinutes": remainingMinutes, + }) + return + } + + // Check rate limiting + if !authLimiter.Allow(clientIP) { + LogAuditEvent("login", loginReq.Username, clientIP, req.URL.Path, false, "Rate limited") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "rate_limit", + "message": "Too many requests. Please wait before trying again.", + }) + return + } + + // Verify credentials + if loginReq.Username == r.config.AuthUser && auth.CheckPasswordHash(loginReq.Password, r.config.AuthPass) { + // Clear failed login attempts + ClearFailedLogins(loginReq.Username) + ClearFailedLogins(clientIP) + + // Create session + token := generateSessionToken() + if token == "" { + writeErrorResponse(w, http.StatusInternalServerError, "session_error", + "Failed to create session", nil) + return + } + + // Store session persistently + userAgent := req.Header.Get("User-Agent") + GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP) + + // Track session for user + TrackUserSession(loginReq.Username, token) + + // Generate CSRF token + csrfToken := generateCSRFToken(token) + + // Get appropriate cookie settings based on proxy detection + isSecure, sameSitePolicy := getCookieSettings(req) + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "pulse_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, // 24 hours + }) + + // Set CSRF cookie (not HttpOnly so JS can read it) + http.SetCookie(w, &http.Cookie{ + Name: "pulse_csrf", + Value: csrfToken, + Path: "/", + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, // 24 hours + }) + + // Audit log successful login + LogAuditEvent("login", loginReq.Username, clientIP, req.URL.Path, true, "Successful login") + + // Return success + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Successfully logged in", + }) + } else { + // Failed login + RecordFailedLogin(loginReq.Username) + RecordFailedLogin(clientIP) + LogAuditEvent("login", loginReq.Username, clientIP, req.URL.Path, false, "Invalid credentials") + + // Get updated attempt counts + newUserAttempts, _, _ := GetLockoutInfo(loginReq.Username) + newIPAttempts, _, _ := GetLockoutInfo(clientIP) + + // Use the higher count for warning + attempts := newUserAttempts + if newIPAttempts > attempts { + attempts = newIPAttempts + } + + // Prepare response with attempt information + remaining := maxFailedAttempts - attempts + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + + if remaining > 0 { + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid_credentials", + "message": fmt.Sprintf("Invalid username or password. You have %d attempts remaining.", remaining), + "attempts": attempts, + "remaining": remaining, + "maxAttempts": maxFailedAttempts, + }) + } else { + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid_credentials", + "message": "Invalid username or password. Account is now locked for 15 minutes.", + "locked": true, + "lockoutDuration": "15 minutes", + }) + } + } +} + +// handleResetLockout allows administrators to manually reset account lockouts +func (r *Router) handleResetLockout(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", + "Only POST method is allowed", nil) + return + } + + // Parse request + var resetReq struct { + Identifier string `json:"identifier"` // Can be username or IP + } + + if err := json.NewDecoder(req.Body).Decode(&resetReq); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", + "Invalid request body", nil) + return + } + + if resetReq.Identifier == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_identifier", + "Identifier (username or IP) is required", nil) + return + } + + // Reset the lockout + ResetLockout(resetReq.Identifier) + + // Also clear failed login attempts + ClearFailedLogins(resetReq.Identifier) + + // Audit log the reset + LogAuditEvent("lockout_reset", "admin", GetClientIP(req), req.URL.Path, true, + fmt.Sprintf("Lockout reset for: %s", resetReq.Identifier)) + + log.Info(). + Str("identifier", resetReq.Identifier). + Str("reset_by", "admin"). + Str("ip", GetClientIP(req)). + Msg("Account lockout manually reset") + + // Return success + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Lockout reset for %s", resetReq.Identifier), + }) +} + // handleState handles state requests func (r *Router) handleState(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { diff --git a/internal/api/security.go b/internal/api/security.go index 01b1a2559..09ba186c5 100644 --- a/internal/api/security.go +++ b/internal/api/security.go @@ -220,6 +220,37 @@ func IsLockedOut(identifier string) bool { return failed.Count >= maxFailedAttempts } +// GetLockoutInfo returns lockout information for an identifier +func GetLockoutInfo(identifier string) (attempts int, lockedUntil time.Time, isLocked bool) { + failedMu.RLock() + defer failedMu.RUnlock() + + failed, exists := failedLogins[identifier] + if !exists { + return 0, time.Time{}, false + } + + // Check if lockout has expired + if time.Now().After(failed.LockedUntil) && failed.Count >= maxFailedAttempts { + // Lockout expired, treat as no attempts + return 0, time.Time{}, false + } + + isLocked = failed.Count >= maxFailedAttempts && time.Now().Before(failed.LockedUntil) + return failed.Count, failed.LockedUntil, isLocked +} + +// ResetLockout manually resets lockout for an identifier (admin function) +func ResetLockout(identifier string) { + failedMu.Lock() + defer failedMu.Unlock() + delete(failedLogins, identifier) + + log.Info(). + Str("identifier", identifier). + Msg("Lockout manually reset") +} + // Security Headers Middleware func SecurityHeaders(next http.Handler) http.Handler { return SecurityHeadersWithConfig(next, false, "")