feat: add ability to remove password authentication

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.
This commit is contained in:
Pulse Monitor 2025-08-13 20:39:26 +00:00
parent 0bd956a9db
commit f2f47b10fa
3 changed files with 263 additions and 6 deletions

View file

@ -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<RemovePasswordModalProps> = (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 (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
Remove Password Authentication
</h2>
<div class="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-700 dark:text-amber-300">
<strong>Warning:</strong> This will disable password authentication.
Pulse will be accessible without any login. Only do this if you're on a trusted network.
</p>
</div>
<form onSubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<input
type="password"
id="current-password"
value={currentPassword()}
onInput={(e) => 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
/>
</div>
<Show when={error()}>
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
</div>
</Show>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button
type="button"
onClick={handleClose}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={loading()}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading()}
>
{loading() ? 'Removing...' : 'Remove Password'}
</button>
</div>
</form>
</div>
</div>
</Portal>
</Show>
);
};

View file

@ -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 = () => {
<p class="text-sm text-gray-600 dark:text-gray-400">
Login required to access Pulse
</p>
<button
onClick={() => setShowPasswordModal(true)}
class="mt-4 px-4 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors"
>
Change Password
</button>
<div class="mt-4 flex gap-2">
<button
onClick={() => setShowPasswordModal(true)}
class="px-4 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors"
>
Change Password
</button>
<button
onClick={() => setShowRemovePasswordModal(true)}
class="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors"
>
Remove Password
</button>
</div>
</div>
</Show>
@ -2069,6 +2079,11 @@ const Settings: Component = () => {
isOpen={showPasswordModal()}
onClose={() => setShowPasswordModal(false)}
/>
<RemovePasswordModal
isOpen={showRemovePasswordModal()}
onClose={() => setShowRemovePasswordModal(false)}
/>
</>
);
};

View file

@ -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 {