From f2f47b10fabb56dfbfa4e422f37996863b3d7be3 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Wed, 13 Aug 2025 20:39:26 +0000 Subject: [PATCH] feat: add ability to remove password authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Feature: - Add "Remove Password" button in Settings → Security tab - Allows users to disable password authentication completely - Returns Pulse to open access mode (no auth required) - Requires current password confirmation for security Implementation: - New API endpoint: POST /api/security/remove-password - New modal component: RemovePasswordModal.tsx - Removes password from systemd override files - Clears auth configuration from running instance - Invalidates all sessions after removal This addresses the issue where users couldn't disable authentication once it was enabled. Now they can easily toggle between secured and open modes as needed for their use case. --- .../Settings/RemovePasswordModal.tsx | 130 ++++++++++++++++++ .../src/components/Settings/Settings.tsx | 27 +++- internal/api/router.go | 112 +++++++++++++++ 3 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/src/components/Settings/RemovePasswordModal.tsx diff --git a/frontend-modern/src/components/Settings/RemovePasswordModal.tsx b/frontend-modern/src/components/Settings/RemovePasswordModal.tsx new file mode 100644 index 000000000..46b7c7ab5 --- /dev/null +++ b/frontend-modern/src/components/Settings/RemovePasswordModal.tsx @@ -0,0 +1,130 @@ +import { Component, createSignal, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { showSuccess } from '@/utils/toast'; + +interface RemovePasswordModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const RemovePasswordModal: Component = (props) => { + const [currentPassword, setCurrentPassword] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + setError(''); + + if (!currentPassword()) { + setError('Current password is required'); + return; + } + + setLoading(true); + + try { + const response = await fetch('/api/security/remove-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(`admin:${currentPassword()}`)}`, + }, + body: JSON.stringify({ + currentPassword: currentPassword(), + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to remove password'); + } + + showSuccess('Password authentication removed. Pulse is now running without authentication.'); + + // Clear form + setCurrentPassword(''); + props.onClose(); + + // Reload the page to reflect the changes + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (err: any) { + setError(err.message || 'Failed to remove password'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setCurrentPassword(''); + setError(''); + props.onClose(); + }; + + return ( + + +
+
+

+ Remove Password Authentication +

+ +
+

+ Warning: This will disable password authentication. + Pulse will be accessible without any login. Only do this if you're on a trusted network. +

+
+ +
+
+
+ + setCurrentPassword(e.currentTarget.value)} + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-200" + placeholder="Enter current password to confirm" + required + /> +
+ + +
+

{error()}

+
+
+
+ +
+ + +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 431953eff..14e0ab1da 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -5,6 +5,7 @@ import { NodeModal } from './NodeModal'; import { APITokenManager } from './APITokenManager'; import { QuickSecuritySetup } from './QuickSecuritySetup'; import { ChangePasswordModal } from './ChangePasswordModal'; +import { RemovePasswordModal } from './RemovePasswordModal'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; @@ -94,6 +95,7 @@ const Settings: Component = () => { const [currentNodeType, setCurrentNodeType] = createSignal<'pve' | 'pbs'>('pve'); const [modalResetKey, setModalResetKey] = createSignal(0); const [showPasswordModal, setShowPasswordModal] = createSignal(false); + const [showRemovePasswordModal, setShowRemovePasswordModal] = createSignal(false); // System settings const [pollingInterval, setPollingInterval] = createSignal(5); @@ -1489,12 +1491,20 @@ const Settings: Component = () => {

Login required to access Pulse

- +
+ + +
@@ -2069,6 +2079,11 @@ const Settings: Component = () => { isOpen={showPasswordModal()} onClose={() => setShowPasswordModal(false)} /> + + setShowRemovePasswordModal(false)} + /> ); }; diff --git a/internal/api/router.go b/internal/api/router.go index 375a12e9d..f45407bdf 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,6 +1,7 @@ package api import ( + base64Pkg "encoding/base64" "encoding/json" "fmt" "net/http" @@ -189,6 +190,7 @@ func (r *Router) setupRoutes() { // Security routes r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword) + r.mux.HandleFunc("/api/security/remove-password", r.handleRemovePassword) 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") @@ -902,6 +904,116 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request) }() } +// handleRemovePassword handles password removal requests +func (r *Router) handleRemovePassword(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 removeReq struct { + CurrentPassword string `json:"currentPassword"` + } + + if err := json.NewDecoder(req.Body).Decode(&removeReq); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", + "Invalid request body", nil) + return + } + + // Verify current password matches + auth := req.Header.Get("Authorization") + if auth == "" { + // Try the provided password + if removeReq.CurrentPassword == "" { + writeErrorResponse(w, http.StatusUnauthorized, "unauthorized", + "Current password required", nil) + return + } + // Create basic auth header from provided password + credentials := base64Pkg.StdEncoding.EncodeToString([]byte(r.config.AuthUser + ":" + removeReq.CurrentPassword)) + req.Header.Set("Authorization", "Basic "+credentials) + } + + // Verify authentication + if !CheckAuth(r.config, nil, req) { + writeErrorResponse(w, http.StatusUnauthorized, "invalid_password", + "Current password is incorrect", nil) + 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 password from running config + r.config.AuthUser = "" + r.config.AuthPass = "" + + log.Info().Msg("Password authentication removed successfully") + + // Invalidate all sessions (forces logout) + InvalidateUserSessions(r.config.AuthUser) + + // Audit log password removal + LogAuditEvent("password_removed", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password authentication disabled") + + // Return success + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Password authentication removed successfully", + }) +} + // handleState handles state requests func (r *Router) handleState(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet {