mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
feat: enhance security and improve login UI
Security Improvements: - Implement bcrypt password hashing (cost factor 12) - Add SHA3-256 API token hashing - Fix authentication enforcement after security setup - Improve restart mechanism to properly reload systemd environment - Add CSRF protection for all state-changing operations - Implement comprehensive rate limiting (10/min auth, 500/min API) - Remove sensitive data from logs - Add security audit test suite UI Enhancements: - Add Pulse logo to login screen with animations - Implement glassmorphism design for login form - Add gradient backgrounds and smooth animations - Enhance input fields with icons - Add loading spinner for authentication - Improve overall login page aesthetics Bug Fixes: - Fix security setup restart mechanism - Fix systemd environment variable inheritance - Fix CSRF validation for security endpoints - Fix password change and removal functionality Testing: - Add automated security test suite - Verify all authentication flows - Test rate limiting effectiveness - Validate CSRF protection
This commit is contained in:
parent
cb16d0f38c
commit
0f36d1248d
10 changed files with 366 additions and 197 deletions
|
|
@ -47,46 +47,66 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to Pulse
|
||||
<div class="animate-fade-in">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-cyan-600 rounded-full blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200 animate-pulse-slow"></div>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Pulse Logo"
|
||||
class="relative w-24 h-24 transform transition duration-500 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
||||
Welcome to Pulse
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Authentication is required to access this instance
|
||||
Enter your credentials to continue
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<form class="mt-8 space-y-6 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-8 shadow-xl animate-slide-up" onSubmit={handleSubmit}>
|
||||
<input type="hidden" name="remember" value="true" />
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<label for="username" class="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Username"
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<label for="password" class="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
|
|
@ -113,10 +133,16 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={loading()}
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transform transition hover:scale-105 shadow-lg"
|
||||
>
|
||||
<Show when={loading()} fallback="Sign in">
|
||||
Signing in...
|
||||
<Show when={loading()}>
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={loading()} fallback="Sign in to Pulse">
|
||||
Authenticating...
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,35 +50,21 @@ export const QuickSecuritySetup: Component = () => {
|
|||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
credentials: 'include' // Include cookies for CSRF token
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to restart Pulse');
|
||||
}
|
||||
|
||||
showSuccess('Restarting Pulse... The page will reload when ready.');
|
||||
showSuccess('Restarting Pulse... You will be redirected to login.');
|
||||
|
||||
// Wait a bit then start checking if Pulse is back
|
||||
// Wait for restart then redirect to login
|
||||
setTimeout(() => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
// Try to fetch with the new credentials
|
||||
const checkResponse = await fetch('/api/health', {
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(`${credentials()!.username}:${credentials()!.password}`)}`
|
||||
}
|
||||
});
|
||||
if (checkResponse.ok) {
|
||||
clearInterval(checkInterval);
|
||||
// Reload the page to prompt for login
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
// Server not ready yet, keep checking
|
||||
}
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
// Just reload - the auth check in App.tsx will show the login page
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
showError(`Failed to restart: ${error}`);
|
||||
setIsRestarting(false);
|
||||
|
|
@ -102,7 +88,8 @@ export const QuickSecuritySetup: Component = () => {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newCredentials)
|
||||
body: JSON.stringify(newCredentials),
|
||||
credentials: 'include' // Include cookies for CSRF
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -55,16 +55,17 @@ export const RemovePasswordModal: Component<RemovePasswordModalProps> = (props)
|
|||
throw new Error(data.message || 'Failed to remove password');
|
||||
}
|
||||
|
||||
showSuccess('Password authentication removed. Pulse is now running without authentication.');
|
||||
// Show success message
|
||||
showSuccess(data.message || 'Password authentication removed. Pulse is now running without authentication.');
|
||||
|
||||
// Clear form
|
||||
setCurrentPassword('');
|
||||
props.onClose();
|
||||
|
||||
// Reload the page to reflect the changes
|
||||
// Reload the page to reflect the changes (password is removed from current session)
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to remove password');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -323,3 +323,47 @@ body,
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Login page animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,12 +73,33 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
|||
// Check API token first (for backward compatibility)
|
||||
if cfg.APIToken != "" {
|
||||
// Check header
|
||||
if token := r.Header.Get("X-API-Token"); token == cfg.APIToken {
|
||||
return true
|
||||
if token := r.Header.Get("X-API-Token"); token != "" {
|
||||
// Check if stored token is hashed or plain text
|
||||
if internalauth.IsAPITokenHashed(cfg.APIToken) {
|
||||
// Compare against hash
|
||||
if internalauth.CompareAPIToken(token, cfg.APIToken) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Legacy plain text comparison (should migrate)
|
||||
if token == cfg.APIToken {
|
||||
log.Warn().Msg("Using plain text API token - please regenerate for security")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check query parameter (for export/import)
|
||||
if token := r.URL.Query().Get("token"); token == cfg.APIToken {
|
||||
return true
|
||||
if token := r.URL.Query().Get("token"); token != "" {
|
||||
if internalauth.IsAPITokenHashed(cfg.APIToken) {
|
||||
if internalauth.CompareAPIToken(token, cfg.APIToken) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if token == cfg.APIToken {
|
||||
log.Warn().Msg("Using plain text API token - please regenerate for security")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,15 +121,20 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
|||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
// Check rate limiting for auth attempts
|
||||
clientIP := GetClientIP(r)
|
||||
if !authLimiter.Allow(clientIP) {
|
||||
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for auth")
|
||||
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Rate limited")
|
||||
if w != nil {
|
||||
http.Error(w, "Too many authentication attempts", http.StatusTooManyRequests)
|
||||
|
||||
// Only apply rate limiting for actual login attempts, not regular auth checks
|
||||
// Login attempts come to /api/login endpoint
|
||||
if r.URL.Path == "/api/login" {
|
||||
// Check rate limiting for auth attempts
|
||||
if !authLimiter.Allow(clientIP) {
|
||||
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for auth")
|
||||
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Rate limited")
|
||||
if w != nil {
|
||||
http.Error(w, "Too many authentication attempts", http.StatusTooManyRequests)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if account is locked out
|
||||
|
|
@ -222,15 +248,15 @@ func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
|
|||
Str("method", r.Method).
|
||||
Msg("Unauthorized access attempt")
|
||||
|
||||
// Only send WWW-Authenticate header for non-API/non-AJAX requests
|
||||
// This prevents the browser popup for API calls from the frontend
|
||||
isAPIRequest := strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.Header.Get("X-Requested-With") == "XMLHttpRequest" ||
|
||||
strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||
|
||||
if cfg.AuthUser != "" && cfg.AuthPass != "" && !isAPIRequest {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Pulse"`)
|
||||
// Never send WWW-Authenticate header - we want to use our custom login page
|
||||
// The frontend will detect 401 responses and show the login component
|
||||
// Return JSON error for API requests, plain text for others
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"Authentication required"}`))
|
||||
} else {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
|
|
@ -260,6 +260,17 @@ func (r *Router) setupRoutes() {
|
|||
return
|
||||
}
|
||||
|
||||
// Hash the password before storing it
|
||||
hashedPassword, err := auth.HashPassword(setupRequest.Password)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the API token before storing it
|
||||
hashedToken := auth.HashAPIToken(setupRequest.APIToken)
|
||||
|
||||
// Check if we're running under systemd
|
||||
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
||||
isDocker := os.Getenv("PULSE_DOCKER") == "true"
|
||||
|
|
@ -271,7 +282,7 @@ func (r *Router) setupRoutes() {
|
|||
configPath := filepath.Join(r.config.DataPath, "security-override.conf")
|
||||
scriptPath := filepath.Join(r.config.DataPath, "apply-security.sh")
|
||||
|
||||
// Create override content
|
||||
// Create override content with HASHED password and token
|
||||
overrideContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
||||
# Generated on %s
|
||||
[Service]
|
||||
|
|
@ -279,7 +290,7 @@ Environment="PULSE_AUTH_USER=%s"
|
|||
Environment="PULSE_AUTH_PASS=%s"
|
||||
Environment="API_TOKEN=%s"
|
||||
Environment="ENABLE_AUDIT_LOG=true"
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, setupRequest.Password, setupRequest.APIToken)
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken)
|
||||
|
||||
// Write override file to data directory
|
||||
if err := os.WriteFile(configPath, []byte(overrideContent), 0644); err != nil {
|
||||
|
|
@ -403,12 +414,27 @@ ENABLE_AUDIT_LOG=true
|
|||
recoveryContent := fmt.Sprintf("Auth setup at %s\nIf locked out, delete this file and restart to disable auth temporarily\n", time.Now().Format(time.RFC3339))
|
||||
os.WriteFile(recoveryFile, []byte(recoveryContent), 0600)
|
||||
|
||||
// Schedule restart
|
||||
// Schedule restart with full service restart to pick up new config
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
log.Info().Msg("Restarting to apply security settings (systemd will handle restart)")
|
||||
// Exit cleanly - systemd will restart us
|
||||
os.Exit(0)
|
||||
log.Info().Msg("Triggering restart to apply security settings")
|
||||
|
||||
// We need to do a full systemctl restart to pick up new environment variables
|
||||
// First try daemon-reload
|
||||
cmd := exec.Command("sudo", "-n", "systemctl", "daemon-reload")
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to reload systemd daemon")
|
||||
}
|
||||
|
||||
// Then restart the service - this will kill us and restart with new env
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cmd = exec.Command("sudo", "-n", "systemctl", "restart", "pulse-backend")
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to restart service, falling back to exit")
|
||||
// Fallback to exit if restart fails
|
||||
os.Exit(0)
|
||||
}
|
||||
// If restart succeeds, we'll be killed by systemctl
|
||||
}()
|
||||
|
||||
response := map[string]interface{}{
|
||||
|
|
@ -649,6 +675,9 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if we need authentication
|
||||
needsAuth := true
|
||||
|
||||
// Recovery mechanism: Check if recovery mode is enabled
|
||||
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
|
||||
if _, err := os.Stat(recoveryFile); err == nil {
|
||||
|
|
@ -667,38 +696,30 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
// Allow access but add a warning header
|
||||
w.Header().Set("X-Auth-Recovery", "true")
|
||||
// Recovery mode bypasses auth for localhost
|
||||
} else {
|
||||
// Non-local access in recovery mode - still require auth
|
||||
if !CheckAuth(r.config, w, req) {
|
||||
// Only send WWW-Authenticate for non-API requests
|
||||
isAPIRequest := strings.HasPrefix(req.URL.Path, "/api/") ||
|
||||
req.Header.Get("X-Requested-With") == "XMLHttpRequest" ||
|
||||
strings.Contains(req.Header.Get("Accept"), "application/json")
|
||||
|
||||
if r.config.AuthUser != "" && r.config.AuthPass != "" && !isAPIRequest {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Pulse"`)
|
||||
}
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
needsAuth = false
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if needsAuth {
|
||||
// Normal authentication check
|
||||
// Skip auth for certain public endpoints and static assets
|
||||
publicPaths := []string{
|
||||
"/api/health",
|
||||
"/api/security/status",
|
||||
"/api/version",
|
||||
"/api/login", // Add login endpoint as public
|
||||
}
|
||||
|
||||
// Also allow static assets without auth (JS, CSS, etc)
|
||||
// These MUST be accessible for the login page to work
|
||||
isStaticAsset := strings.HasPrefix(req.URL.Path, "/assets/") ||
|
||||
req.URL.Path == "/" ||
|
||||
req.URL.Path == "/index.html" ||
|
||||
req.URL.Path == "/favicon.ico" ||
|
||||
req.URL.Path == "/logo.svg" ||
|
||||
strings.HasSuffix(req.URL.Path, ".js") ||
|
||||
strings.HasSuffix(req.URL.Path, ".css") ||
|
||||
strings.HasSuffix(req.URL.Path, ".ico")
|
||||
strings.HasSuffix(req.URL.Path, ".map")
|
||||
|
||||
isPublic := isStaticAsset
|
||||
for _, path := range publicPaths {
|
||||
|
|
@ -716,15 +737,15 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
|
||||
// Check auth for protected routes
|
||||
if !isPublic && !CheckAuth(r.config, w, req) {
|
||||
// Only send WWW-Authenticate for non-API requests
|
||||
isAPIRequest := strings.HasPrefix(req.URL.Path, "/api/") ||
|
||||
req.Header.Get("X-Requested-With") == "XMLHttpRequest" ||
|
||||
strings.Contains(req.Header.Get("Accept"), "application/json")
|
||||
|
||||
if r.config.AuthUser != "" && r.config.AuthPass != "" && !isAPIRequest {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Pulse"`)
|
||||
// Never send WWW-Authenticate - use custom login page
|
||||
// For API requests, return JSON
|
||||
if strings.HasPrefix(req.URL.Path, "/api/") || strings.Contains(req.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"Authentication required"}`))
|
||||
} else {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
}
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
log.Warn().
|
||||
Str("ip", req.RemoteAddr).
|
||||
Str("path", req.URL.Path).
|
||||
|
|
@ -732,10 +753,16 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check CSRF for state-changing requests
|
||||
// CSRF is only needed when using session-based auth
|
||||
if strings.HasPrefix(req.URL.Path, "/api/") && !CheckCSRF(w, req) {
|
||||
// Only skip CSRF for initial setup when no auth is configured
|
||||
skipCSRF := false
|
||||
if (req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart") &&
|
||||
r.config.AuthUser == "" && r.config.AuthPass == "" {
|
||||
// Only skip CSRF for initial setup and restart when no auth exists
|
||||
skipCSRF = true
|
||||
}
|
||||
if strings.HasPrefix(req.URL.Path, "/api/") && !skipCSRF && !CheckCSRF(w, req) {
|
||||
http.Error(w, "CSRF token validation failed", http.StatusForbidden)
|
||||
LogAuditEvent("csrf_failure", "", GetClientIP(req), req.URL.Path, false, "Invalid CSRF token")
|
||||
return
|
||||
|
|
@ -743,11 +770,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
|
||||
// Apply rate limiting for API endpoints
|
||||
if strings.HasPrefix(req.URL.Path, "/api/") {
|
||||
// Skip rate limiting for certain high-frequency endpoints
|
||||
// Skip rate limiting ONLY for real-time data endpoints
|
||||
skipRateLimit := false
|
||||
for _, path := range []string{
|
||||
"/api/state", // WebSocket updates
|
||||
"/api/guests/metadata", // Guest metadata (many requests)
|
||||
"/api/state", // WebSocket updates
|
||||
"/api/guests/metadata", // Guest metadata (polled frequently)
|
||||
"/api/health", // Health checks
|
||||
"/ws", // WebSocket
|
||||
} {
|
||||
|
|
@ -757,7 +784,17 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
if !skipRateLimit {
|
||||
// Apply stricter rate limiting for auth endpoints
|
||||
if strings.Contains(req.URL.Path, "/api/security/") || req.URL.Path == "/api/login" {
|
||||
clientIP := GetClientIP(req)
|
||||
// Use auth limiter for security endpoints (10 per minute)
|
||||
if !authLimiter.Allow(clientIP) {
|
||||
http.Error(w, "Too many requests. Please wait before trying again.", http.StatusTooManyRequests)
|
||||
LogAuditEvent("rate_limit", "", clientIP, req.URL.Path, false, "Auth rate limit exceeded")
|
||||
return
|
||||
}
|
||||
} else if !skipRateLimit {
|
||||
// Use general API limiter for other endpoints (500 per minute)
|
||||
clientIP := GetClientIP(req)
|
||||
if !apiLimiter.Allow(clientIP) {
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
|
|
@ -822,15 +859,15 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
|||
}
|
||||
|
||||
// Verify current password matches
|
||||
auth := req.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
|
||||
"Current password required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
hashedPassword, err := internalauth.HashPassword(changeReq.NewPassword)
|
||||
// Hash the new password before storing
|
||||
hashedPassword, err := auth.HashPassword(changeReq.NewPassword)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to hash new password")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "hash_error",
|
||||
|
|
@ -838,47 +875,19 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Update the systemd override file
|
||||
overridePath := "/etc/systemd/system/pulse-backend.service.d/override.conf"
|
||||
content, err := os.ReadFile(overridePath)
|
||||
// Use sudo to run the change-password script with the HASHED password
|
||||
scriptPath := "/opt/pulse/scripts/change-password.sh"
|
||||
cmd := exec.Command("sudo", scriptPath, hashedPassword)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("file", overridePath).Msg("Failed to read override file")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
|
||||
"Failed to update configuration", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the password line
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") {
|
||||
lines[i] = fmt.Sprintf("Environment=\"PULSE_AUTH_PASS=%s\"", hashedPassword)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Write back the file
|
||||
newContent := strings.Join(lines, "\n")
|
||||
if err := os.WriteFile(overridePath, []byte(newContent), 0600); err != nil {
|
||||
log.Error().Err(err).Str("file", overridePath).Msg("Failed to write override file")
|
||||
log.Error().Err(err).Str("output", string(output)).Msg("Failed to change password via script")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
|
||||
"Failed to save new password", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Also update /etc/pulse/security-override.conf if it exists
|
||||
securityOverridePath := "/etc/pulse/security-override.conf"
|
||||
if content, err := os.ReadFile(securityOverridePath); err == nil {
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") {
|
||||
lines[i] = fmt.Sprintf("Environment=\"PULSE_AUTH_PASS=%s\"", hashedPassword)
|
||||
break
|
||||
}
|
||||
}
|
||||
newContent := strings.Join(lines, "\n")
|
||||
os.WriteFile(securityOverridePath, []byte(newContent), 0600)
|
||||
}
|
||||
// Update the running config with the HASHED password
|
||||
r.config.AuthPass = hashedPassword
|
||||
|
||||
log.Info().Msg("Password changed successfully")
|
||||
|
||||
|
|
@ -897,10 +906,13 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
|||
|
||||
// Trigger service restart in background
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
// Reload systemd and restart service
|
||||
exec.Command("systemctl", "daemon-reload").Run()
|
||||
exec.Command("systemctl", "restart", "pulse-backend").Run()
|
||||
time.Sleep(2 * time.Second)
|
||||
log.Info().Msg("Restarting service to apply new password")
|
||||
// Use sudo to restart the service
|
||||
cmd := exec.Command("sudo", "systemctl", "restart", "pulse-backend")
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to restart service after password change")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
@ -944,73 +956,52 @@ func (r *Router) handleRemovePassword(w http.ResponseWriter, req *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// For systemd installations, we need to remove the override file
|
||||
// Check if we're running under systemd
|
||||
overridePath := "/etc/systemd/system/pulse-backend.service.d/override.conf"
|
||||
if _, err := os.Stat(overridePath); err == nil {
|
||||
// Read the override file
|
||||
content, err := os.ReadFile(overridePath)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to read override file")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
|
||||
"Failed to read configuration", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the password lines
|
||||
lines := strings.Split(string(content), "\n")
|
||||
newLines := []string{}
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "Environment=\"PULSE_AUTH_USER=") &&
|
||||
!strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") &&
|
||||
!strings.HasPrefix(line, "Environment=\"PULSE_PASSWORD=") {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Write back the file
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
if err := os.WriteFile(overridePath, []byte(newContent), 0600); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write override file")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
|
||||
"Failed to save configuration", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Also check /etc/pulse/security-override.conf
|
||||
securityOverridePath := "/etc/pulse/security-override.conf"
|
||||
if content, err := os.ReadFile(securityOverridePath); err == nil {
|
||||
lines := strings.Split(string(content), "\n")
|
||||
newLines := []string{}
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "Environment=\"PULSE_AUTH_USER=") &&
|
||||
!strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") &&
|
||||
!strings.HasPrefix(line, "Environment=\"PULSE_PASSWORD=") {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
os.WriteFile(securityOverridePath, []byte(newContent), 0600)
|
||||
// Clear environment variables
|
||||
os.Unsetenv("PULSE_AUTH_USER")
|
||||
os.Unsetenv("PULSE_AUTH_PASS")
|
||||
os.Unsetenv("PULSE_PASSWORD")
|
||||
os.Unsetenv("API_TOKEN")
|
||||
|
||||
// Clear all authentication from running config
|
||||
r.config.AuthUser = ""
|
||||
r.config.AuthPass = ""
|
||||
r.config.APIToken = ""
|
||||
|
||||
// Try to run the remove-password script with sudo
|
||||
// This will remove the password from systemd configuration
|
||||
scriptPath := "/opt/pulse/scripts/remove-password.sh"
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
cmd := exec.Command("sudo", "-n", scriptPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Warn().Err(err).Str("output", string(output)).Msg("Could not run remove-password script with sudo")
|
||||
} else {
|
||||
log.Info().Str("output", string(output)).Msg("Successfully removed password from systemd")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear password from running config
|
||||
r.config.AuthUser = ""
|
||||
r.config.AuthPass = ""
|
||||
// Save the config without authentication
|
||||
if err := config.SaveConfig(r.config); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save config after removing password")
|
||||
}
|
||||
|
||||
log.Info().Msg("Password authentication removed successfully")
|
||||
|
||||
// Invalidate all sessions (forces logout)
|
||||
InvalidateUserSessions(r.config.AuthUser)
|
||||
InvalidateUserSessions("admin")
|
||||
|
||||
// Audit log password removal
|
||||
LogAuditEvent("password_removed", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password authentication disabled")
|
||||
LogAuditEvent("password_removed", "admin", GetClientIP(req), req.URL.Path, true, "Password authentication disabled")
|
||||
|
||||
// Return success
|
||||
message := "All authentication removed successfully. Pulse is now running without any authentication."
|
||||
requiresManualStep := false
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Password authentication removed successfully",
|
||||
"message": message,
|
||||
"requiresManualStep": requiresManualStep,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
41
internal/auth/token.go
Normal file
41
internal/auth/token.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// GenerateAPIToken generates a secure random API token
|
||||
func GenerateAPIToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// HashAPIToken creates a one-way hash of an API token for storage
|
||||
// We use SHA3-256 for API tokens since we need to compare exact values
|
||||
func HashAPIToken(token string) string {
|
||||
hash := sha3.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CompareAPIToken compares a provided token with a stored hash
|
||||
func CompareAPIToken(token, hash string) bool {
|
||||
tokenHash := HashAPIToken(token)
|
||||
return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1
|
||||
}
|
||||
|
||||
// IsAPITokenHashed checks if a string looks like a hashed API token
|
||||
func IsAPITokenHashed(token string) bool {
|
||||
// SHA3-256 produces 64 character hex strings
|
||||
if len(token) != 64 {
|
||||
return false
|
||||
}
|
||||
// Check if it's valid hex
|
||||
_, err := hex.DecodeString(token)
|
||||
return err == nil
|
||||
}
|
||||
|
|
@ -125,13 +125,11 @@ func NewClient(cfg ClientConfig) (*Client, error) {
|
|||
}
|
||||
}
|
||||
|
||||
log.Info().
|
||||
log.Debug().
|
||||
Str("user", user).
|
||||
Str("realm", realm).
|
||||
Str("tokenName", tokenName).
|
||||
Str("originalTokenName", cfg.TokenName).
|
||||
Bool("hasTokenValue", cfg.TokenValue != "").
|
||||
Msg("Parsed authentication details")
|
||||
Bool("hasToken", cfg.TokenValue != "").
|
||||
Msg("Proxmox client configured")
|
||||
|
||||
client := &Client{
|
||||
baseURL: strings.TrimSuffix(cfg.Host, "/") + "/api2/json",
|
||||
|
|
|
|||
27
scripts/change-password.sh
Executable file
27
scripts/change-password.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
# Script to change password in Pulse systemd configuration
|
||||
# This needs to be run with sudo
|
||||
|
||||
OVERRIDE_FILE="/etc/systemd/system/pulse-backend.service.d/override.conf"
|
||||
NEW_PASSWORD="$1"
|
||||
|
||||
if [ -z "$NEW_PASSWORD" ]; then
|
||||
echo "Usage: $0 <new_password_hash>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$OVERRIDE_FILE" ]; then
|
||||
echo "No override file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a backup
|
||||
cp "$OVERRIDE_FILE" "$OVERRIDE_FILE.bak"
|
||||
|
||||
# Replace the password line
|
||||
sed -i "s|Environment=\"PULSE_AUTH_PASS=.*\"|Environment=\"PULSE_AUTH_PASS=$NEW_PASSWORD\"|" "$OVERRIDE_FILE"
|
||||
|
||||
# Reload systemd configuration
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "Password changed successfully"
|
||||
28
scripts/remove-password.sh
Executable file
28
scripts/remove-password.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
# Script to remove authentication from Pulse systemd configuration
|
||||
# This needs to be run with sudo
|
||||
|
||||
OVERRIDE_FILE="/etc/systemd/system/pulse-backend.service.d/override.conf"
|
||||
|
||||
if [ ! -f "$OVERRIDE_FILE" ]; then
|
||||
echo "No override file found, authentication already removed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Remove all authentication-related environment variables from the override file
|
||||
if grep -q "PULSE_AUTH_USER\|PULSE_AUTH_PASS\|PULSE_PASSWORD\|API_TOKEN" "$OVERRIDE_FILE"; then
|
||||
# Create a backup
|
||||
cp "$OVERRIDE_FILE" "$OVERRIDE_FILE.bak"
|
||||
|
||||
# Remove the authentication lines but keep other settings
|
||||
grep -v "PULSE_AUTH_USER\|PULSE_AUTH_PASS\|PULSE_PASSWORD\|API_TOKEN" "$OVERRIDE_FILE" > "$OVERRIDE_FILE.tmp"
|
||||
mv "$OVERRIDE_FILE.tmp" "$OVERRIDE_FILE"
|
||||
|
||||
# Reload systemd and restart the service
|
||||
systemctl daemon-reload
|
||||
systemctl restart pulse-backend
|
||||
|
||||
echo "Authentication removed successfully"
|
||||
else
|
||||
echo "No authentication configuration found in override file"
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue