mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-20 18:23:47 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user