feat: improve account lockout mechanism with recovery options

addresses #360

- Add detailed lockout feedback in login API responses showing remaining attempts
- Display lockout warnings in the frontend with attempt counters
- Show time remaining when account is locked (15 minute lockout duration)
- Add visual indicators (lock icon) for lockout status
- Implement /api/security/reset-lockout endpoint for admin recovery
- Store lockout information with expiration tracking
- Provide clear user guidance about lockout duration and recovery
This commit is contained in:
Pulse Monitor
2025-08-27 21:43:25 +00:00
parent 112c994d2d
commit 2e41532d48
4 changed files with 367 additions and 17 deletions
+80 -13
View File
@@ -66,30 +66,79 @@ export const Login: Component<LoginProps> = (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<{
</div>
<Show when={error()}>
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div class={`rounded-md p-4 ${
error().includes('locked') ? 'bg-orange-50 dark:bg-orange-900/20' : 'bg-red-50 dark:bg-red-900/20'
}`}>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<Show
when={error().includes('locked')}
fallback={
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
}
>
<svg class="h-5 w-5 text-orange-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</Show>
</div>
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-200">{error()}</p>
<p class={`text-sm ${
error().includes('locked') ? 'text-orange-800 dark:text-orange-200' : 'text-red-800 dark:text-red-200'
}`}>{error()}</p>
<Show when={error().includes('locked') && error().includes('minute')}>
<p class="text-xs mt-1 text-orange-700 dark:text-orange-300">
Lockouts automatically expire after the specified time. If you need immediate access, contact your administrator.
</p>
</Show>
</div>
</div>
</div>
+45 -4
View File
@@ -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
}
}
}
}
+211
View File
@@ -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 {
+31
View File
@@ -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, "")