mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-16 19:50:10 +00:00
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:
parent
0bd956a9db
commit
f2f47b10fa
3 changed files with 263 additions and 6 deletions
130
frontend-modern/src/components/Settings/RemovePasswordModal.tsx
Normal file
130
frontend-modern/src/components/Settings/RemovePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue