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 {