Harden API auth and token handling
This commit is contained in:
parent
77282bd3a6
commit
7075cef326
11 changed files with 268 additions and 87 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
import { Component, createSignal, Show, onMount } from 'solid-js';
|
import { Component, createSignal, Show, onMount } from 'solid-js';
|
||||||
import { showSuccess, showError } from '@/utils/toast';
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import { clearStoredAPIToken } from '@/utils/tokenStorage';
|
||||||
|
import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient';
|
||||||
import { STORAGE_KEYS } from '@/constants';
|
import { STORAGE_KEYS } from '@/constants';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||||
|
|
@ -11,7 +13,6 @@ export const FirstRunSetup: Component = () => {
|
||||||
const [password, setPassword] = createSignal('');
|
const [password, setPassword] = createSignal('');
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||||
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
|
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
|
||||||
const [, setApiToken] = createSignal('');
|
|
||||||
const [isSettingUp, setIsSettingUp] = createSignal(false);
|
const [isSettingUp, setIsSettingUp] = createSignal(false);
|
||||||
const [showCredentials, setShowCredentials] = createSignal(false);
|
const [showCredentials, setShowCredentials] = createSignal(false);
|
||||||
const [savedUsername, setSavedUsername] = createSignal('');
|
const [savedUsername, setSavedUsername] = createSignal('');
|
||||||
|
|
@ -98,7 +99,7 @@ export const FirstRunSetup: Component = () => {
|
||||||
|
|
||||||
// Generate API token
|
// Generate API token
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
setApiToken(token);
|
setApiClientToken(token);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/security/quick-setup', {
|
const response = await fetch('/api/security/quick-setup', {
|
||||||
|
|
@ -139,7 +140,8 @@ export const FirstRunSetup: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('apiToken');
|
clearStoredAPIToken();
|
||||||
|
clearApiClientToken();
|
||||||
} catch (storageError) {
|
} catch (storageError) {
|
||||||
console.warn('Unable to clear cached API token', storageError);
|
console.warn('Unable to clear cached API token', storageError);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { formatRelativeTime } from '@/utils/format';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import type { DockerHost, Host } from '@/types/api';
|
import type { DockerHost, Host } from '@/types/api';
|
||||||
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||||
|
import { setStoredAPIToken } from '@/utils/tokenStorage';
|
||||||
|
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { ApiIcon } from '@/components/icons/ApiIcon';
|
import { ApiIcon } from '@/components/icons/ApiIcon';
|
||||||
|
|
@ -261,14 +263,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
showSuccess('New API token generated. Copy it below while it is still visible.');
|
showSuccess('New API token generated. Copy it below while it is still visible.');
|
||||||
props.onTokensChanged?.();
|
props.onTokensChanged?.();
|
||||||
|
|
||||||
try {
|
setStoredAPIToken(token);
|
||||||
window.localStorage.setItem('apiToken', token);
|
setApiClientToken(token);
|
||||||
window.dispatchEvent(
|
|
||||||
new StorageEvent('storage', { key: 'apiToken', newValue: token }),
|
|
||||||
);
|
|
||||||
} catch (storageErr) {
|
|
||||||
console.warn('Unable to persist API token in localStorage', storageErr);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to generate API token', err);
|
console.error('Failed to generate API token', err);
|
||||||
showError('Failed to generate API token');
|
showError('Failed to generate API token');
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Component, createSignal, Show, createMemo, createEffect } from 'solid-j
|
||||||
import { apiFetch } from '@/utils/apiClient';
|
import { apiFetch } from '@/utils/apiClient';
|
||||||
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
||||||
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||||
|
import { setStoredAPIToken } from '@/utils/tokenStorage';
|
||||||
|
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
|
||||||
|
|
||||||
interface CommandBuilderProps {
|
interface CommandBuilderProps {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
@ -203,20 +205,14 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
|
|
||||||
reopenLatestTokenDialog();
|
reopenLatestTokenDialog();
|
||||||
// Auto-populate the command builder
|
// Auto-populate the command builder
|
||||||
setTokenInput(newToken);
|
setTokenInput(newToken);
|
||||||
if (props.onTokenGenerated) {
|
if (props.onTokenGenerated) {
|
||||||
props.onTokenGenerated(newToken, record);
|
props.onTokenGenerated(newToken, record);
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('apiToken', newToken);
|
|
||||||
window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: newToken }));
|
|
||||||
} catch (storageErr) {
|
|
||||||
console.warn('Unable to persist API token in localStorage', storageErr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStoredAPIToken(newToken);
|
||||||
|
setApiClientToken(newToken);
|
||||||
|
|
||||||
window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.');
|
window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token generation failed:', error);
|
console.error('Token generation failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Component, createSignal, Show } from 'solid-js';
|
import { Component, createSignal, Show } from 'solid-js';
|
||||||
import { showSuccess, showError } from '@/utils/toast';
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import { clearStoredAPIToken } from '@/utils/tokenStorage';
|
||||||
|
import { clearApiToken as clearApiClientToken } from '@/utils/apiClient';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
||||||
|
|
||||||
|
|
@ -125,7 +127,8 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('apiToken');
|
clearStoredAPIToken();
|
||||||
|
clearApiClientToken();
|
||||||
} catch (storageError) {
|
} catch (storageError) {
|
||||||
console.warn('Unable to clear cached API token', storageError);
|
console.warn('Unable to clear cached API token', storageError);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import { useNavigate, useLocation } from '@solidjs/router';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { showSuccess, showError } from '@/utils/toast';
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import {
|
||||||
|
clearStoredAPIToken,
|
||||||
|
getStoredAPIToken,
|
||||||
|
setStoredAPIToken,
|
||||||
|
} from '@/utils/tokenStorage';
|
||||||
|
import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient';
|
||||||
import { NodeModal } from './NodeModal';
|
import { NodeModal } from './NodeModal';
|
||||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||||
import { DockerAgents } from './DockerAgents';
|
import { DockerAgents } from './DockerAgents';
|
||||||
|
|
@ -1723,7 +1729,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
if (
|
if (
|
||||||
!hasPasswordAuth &&
|
!hasPasswordAuth &&
|
||||||
securityStatus()?.apiTokenConfigured &&
|
securityStatus()?.apiTokenConfigured &&
|
||||||
!localStorage.getItem('apiToken')
|
!getStoredAPIToken()
|
||||||
) {
|
) {
|
||||||
setApiTokenModalSource('export');
|
setApiTokenModalSource('export');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
|
|
@ -1747,7 +1753,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add API token if configured
|
// Add API token if configured
|
||||||
const apiToken = localStorage.getItem('apiToken');
|
const apiToken = getStoredAPIToken();
|
||||||
if (apiToken) {
|
if (apiToken) {
|
||||||
headers['X-API-Token'] = apiToken;
|
headers['X-API-Token'] = apiToken;
|
||||||
}
|
}
|
||||||
|
|
@ -1767,9 +1773,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||||
if (!hasPasswordAuth) {
|
if (!hasPasswordAuth) {
|
||||||
// Clear invalid token if we had one
|
// Clear invalid token if we had one
|
||||||
const hadToken = localStorage.getItem('apiToken');
|
const hadToken = getStoredAPIToken();
|
||||||
if (hadToken) {
|
if (hadToken) {
|
||||||
localStorage.removeItem('apiToken');
|
clearStoredAPIToken();
|
||||||
|
clearApiClientToken();
|
||||||
showError('Invalid or expired API token. Please re-enter.');
|
showError('Invalid or expired API token. Please re-enter.');
|
||||||
setApiTokenModalSource('export');
|
setApiTokenModalSource('export');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
|
|
@ -1827,7 +1834,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
if (
|
if (
|
||||||
!hasPasswordAuth &&
|
!hasPasswordAuth &&
|
||||||
securityStatus()?.apiTokenConfigured &&
|
securityStatus()?.apiTokenConfigured &&
|
||||||
!localStorage.getItem('apiToken')
|
!getStoredAPIToken()
|
||||||
) {
|
) {
|
||||||
setApiTokenModalSource('import');
|
setApiTokenModalSource('import');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
|
|
@ -1861,7 +1868,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add API token if configured
|
// Add API token if configured
|
||||||
const apiToken = localStorage.getItem('apiToken');
|
const apiToken = getStoredAPIToken();
|
||||||
if (apiToken) {
|
if (apiToken) {
|
||||||
headers['X-API-Token'] = apiToken;
|
headers['X-API-Token'] = apiToken;
|
||||||
}
|
}
|
||||||
|
|
@ -1884,9 +1891,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||||
if (!hasPasswordAuth) {
|
if (!hasPasswordAuth) {
|
||||||
// Clear invalid token if we had one
|
// Clear invalid token if we had one
|
||||||
const hadToken = localStorage.getItem('apiToken');
|
const hadToken = getStoredAPIToken();
|
||||||
if (hadToken) {
|
if (hadToken) {
|
||||||
localStorage.removeItem('apiToken');
|
clearStoredAPIToken();
|
||||||
|
clearApiClientToken();
|
||||||
showError('Invalid or expired API token. Please re-enter.');
|
showError('Invalid or expired API token. Please re-enter.');
|
||||||
setApiTokenModalSource('import');
|
setApiTokenModalSource('import');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
|
|
@ -6437,7 +6445,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (apiTokenInput()) {
|
if (apiTokenInput()) {
|
||||||
localStorage.setItem('apiToken', apiTokenInput());
|
const tokenValue = apiTokenInput()!;
|
||||||
|
setStoredAPIToken(tokenValue);
|
||||||
|
setApiClientToken(tokenValue);
|
||||||
const source = apiTokenModalSource();
|
const source = apiTokenModalSource();
|
||||||
setShowApiTokenModal(false);
|
setShowApiTokenModal(false);
|
||||||
setApiTokenInput('');
|
setApiTokenInput('');
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,21 @@ const readStoredToken = (primaryKey: string, legacyKeys: readonly string[] = [])
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const primary = window.localStorage.getItem(primaryKey);
|
let storage: Storage | undefined;
|
||||||
|
try {
|
||||||
|
storage = window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
storage = undefined;
|
||||||
|
}
|
||||||
|
if (!storage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const primary = storage.getItem(primaryKey);
|
||||||
if (primary) {
|
if (primary) {
|
||||||
return primary;
|
return primary;
|
||||||
}
|
}
|
||||||
for (const key of legacyKeys) {
|
for (const key of legacyKeys) {
|
||||||
const value = window.localStorage.getItem(key);
|
const value = storage.getItem(key);
|
||||||
if (value) {
|
if (value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -69,16 +78,22 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let storage: Storage | undefined;
|
||||||
|
try {
|
||||||
|
storage = window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
storage = undefined;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (current) {
|
if (current) {
|
||||||
window.localStorage.setItem(storageKey, current);
|
storage?.setItem(storageKey, current);
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current }));
|
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current }));
|
||||||
} catch (eventErr) {
|
} catch (eventErr) {
|
||||||
console.debug('Unable to dispatch storage event for token update', eventErr);
|
console.debug('Unable to dispatch storage event for token update', eventErr);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(storageKey);
|
storage?.removeItem(storageKey);
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null }));
|
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null }));
|
||||||
} catch (eventErr) {
|
} catch (eventErr) {
|
||||||
|
|
@ -86,7 +101,7 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Unable to persist API token in localStorage', err);
|
console.warn('Unable to persist API token in sessionStorage', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,21 @@ class ApiClient {
|
||||||
sessionStorage.removeItem('pulse_auth_user');
|
sessionStorage.removeItem('pulse_auth_user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearApiToken() {
|
||||||
|
this.apiToken = null;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem('pulse_auth');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed?.type === 'token') {
|
||||||
|
sessionStorage.removeItem('pulse_auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem('pulse_auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we have any auth configured
|
// Check if we have any auth configured
|
||||||
hasAuth(): boolean {
|
hasAuth(): boolean {
|
||||||
return !!(this.authHeader || this.apiToken);
|
return !!(this.authHeader || this.apiToken);
|
||||||
|
|
@ -263,5 +278,6 @@ export const setBasicAuth = (username: string, password: string) =>
|
||||||
apiClient.setBasicAuth(username, password);
|
apiClient.setBasicAuth(username, password);
|
||||||
export const setApiToken = (token: string) => apiClient.setApiToken(token);
|
export const setApiToken = (token: string) => apiClient.setApiToken(token);
|
||||||
export const clearAuth = () => apiClient.clearAuth();
|
export const clearAuth = () => apiClient.clearAuth();
|
||||||
|
export const clearApiToken = () => apiClient.clearApiToken();
|
||||||
export const hasAuth = () => apiClient.hasAuth();
|
export const hasAuth = () => apiClient.hasAuth();
|
||||||
export const checkAuthRequired = () => apiClient.checkAuthRequired();
|
export const checkAuthRequired = () => apiClient.checkAuthRequired();
|
||||||
|
|
|
||||||
98
frontend-modern/src/utils/tokenStorage.ts
Normal file
98
frontend-modern/src/utils/tokenStorage.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { onCleanup } from 'solid-js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pulse_api_token';
|
||||||
|
|
||||||
|
let memoryToken: string | null = null;
|
||||||
|
const listeners = new Set<(value: string | null) => void>();
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | undefined => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notify = (value: string | null) => {
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener(value);
|
||||||
|
} catch {
|
||||||
|
// Ignore listener failures to avoid breaking notification flow.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY, newValue: value ?? null }));
|
||||||
|
} catch {
|
||||||
|
// Some browsers disallow programmatic StorageEvent dispatch; ignore failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeValue = (value: string | null) => {
|
||||||
|
memoryToken = value;
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) {
|
||||||
|
notify(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (value === null) {
|
||||||
|
storage.removeItem(STORAGE_KEY);
|
||||||
|
} else {
|
||||||
|
storage.setItem(STORAGE_KEY, value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If sessionStorage is unavailable (e.g., Safari private mode), fall back to memory token only.
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredAPIToken = (): string | null => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return memoryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = storage.getItem(STORAGE_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
memoryToken = stored;
|
||||||
|
}
|
||||||
|
return stored ?? memoryToken;
|
||||||
|
} catch {
|
||||||
|
return memoryToken;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setStoredAPIToken = (token: string): void => {
|
||||||
|
writeValue(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearStoredAPIToken = (): void => {
|
||||||
|
writeValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeAPIToken = (listener: (token: string | null) => void): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener(getStoredAPIToken());
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAPITokenSubscription = (listener: (token: string | null) => void): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
listener(getStoredAPIToken());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unsubscribe = subscribeAPIToken(listener);
|
||||||
|
onCleanup(unsubscribe);
|
||||||
|
};
|
||||||
|
|
@ -230,7 +230,6 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// API-only mode: when only API token is configured (no password auth)
|
// API-only mode: when only API token is configured (no password auth)
|
||||||
// Allow read-only endpoints for the UI to work
|
|
||||||
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
|
||||||
// Check if an API token was provided
|
// Check if an API token was provided
|
||||||
providedToken := r.Header.Get("X-API-Token")
|
providedToken := r.Header.Get("X-API-Token")
|
||||||
|
|
@ -251,32 +250,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// No token provided - allow read-only endpoints for UI
|
// Require a valid token for all requests in API-only mode
|
||||||
if r.Method == "GET" || r.URL.Path == "/ws" {
|
|
||||||
// Allow these endpoints without auth for UI to function
|
|
||||||
allowedPaths := []string{
|
|
||||||
"/api/state",
|
|
||||||
"/api/config/nodes",
|
|
||||||
"/api/config/system",
|
|
||||||
"/api/settings",
|
|
||||||
"/api/discover",
|
|
||||||
"/api/security/status",
|
|
||||||
"/api/version",
|
|
||||||
"/api/health",
|
|
||||||
"/api/updates/check",
|
|
||||||
"/api/system/diagnostics",
|
|
||||||
"/api/guests/metadata",
|
|
||||||
"/ws", // WebSocket for real-time updates
|
|
||||||
}
|
|
||||||
for _, path := range allowedPaths {
|
|
||||||
if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path+"/") {
|
|
||||||
log.Debug().Str("path", r.URL.Path).Msg("Allowing read-only access in API-only mode")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require token for everything else
|
|
||||||
if w != nil {
|
if w != nil {
|
||||||
w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
|
w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
|
||||||
http.Error(w, "API token required", http.StatusUnauthorized)
|
http.Error(w, "API token required", http.StatusUnauthorized)
|
||||||
|
|
@ -342,7 +316,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||||
// Check basic auth
|
// Check basic auth
|
||||||
if cfg.AuthUser != "" && cfg.AuthPass != "" {
|
if cfg.AuthUser != "" && cfg.AuthPass != "" {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
log.Debug().Str("auth_header", auth).Str("url", r.URL.Path).Msg("Checking auth")
|
authScheme := "none"
|
||||||
|
if auth != "" {
|
||||||
|
if idx := strings.IndexByte(auth, ' '); idx != -1 {
|
||||||
|
authScheme = strings.ToLower(auth[:idx])
|
||||||
|
} else {
|
||||||
|
authScheme = strings.ToLower(auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Debug().Str("auth_scheme", authScheme).Str("url", r.URL.Path).Msg("Checking Authorization header")
|
||||||
if auth != "" {
|
if auth != "" {
|
||||||
const prefix = "Basic "
|
const prefix = "Basic "
|
||||||
if strings.HasPrefix(auth, prefix) {
|
if strings.HasPrefix(auth, prefix) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||||
|
|
@ -525,32 +526,85 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
|
||||||
func (h *NotificationHandlers) HandleNotifications(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationHandlers) HandleNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/notifications")
|
path := strings.TrimPrefix(r.URL.Path, "/api/notifications")
|
||||||
|
|
||||||
|
requireAnyScope := func(required string, scopes ...string) bool {
|
||||||
|
record := getAPITokenRecordFromRequest(r)
|
||||||
|
if record == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope != "" && record.HasScope(scope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondMissingScope(w, required)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case path == "/email" && r.Method == http.MethodGet:
|
case path == "/email" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetEmailConfig(w, r)
|
h.GetEmailConfig(w, r)
|
||||||
case path == "/email" && r.Method == http.MethodPut:
|
case path == "/email" && r.Method == http.MethodPut:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.UpdateEmailConfig(w, r)
|
h.UpdateEmailConfig(w, r)
|
||||||
case path == "/apprise" && r.Method == http.MethodGet:
|
case path == "/apprise" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetAppriseConfig(w, r)
|
h.GetAppriseConfig(w, r)
|
||||||
case path == "/apprise" && r.Method == http.MethodPut:
|
case path == "/apprise" && r.Method == http.MethodPut:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.UpdateAppriseConfig(w, r)
|
h.UpdateAppriseConfig(w, r)
|
||||||
case path == "/webhooks" && r.Method == http.MethodGet:
|
case path == "/webhooks" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetWebhooks(w, r)
|
h.GetWebhooks(w, r)
|
||||||
case path == "/webhooks" && r.Method == http.MethodPost:
|
case path == "/webhooks" && r.Method == http.MethodPost:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.CreateWebhook(w, r)
|
h.CreateWebhook(w, r)
|
||||||
case path == "/webhooks/test" && r.Method == http.MethodPost:
|
case path == "/webhooks/test" && r.Method == http.MethodPost:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.TestWebhook(w, r)
|
h.TestWebhook(w, r)
|
||||||
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodPut:
|
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodPut:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.UpdateWebhook(w, r)
|
h.UpdateWebhook(w, r)
|
||||||
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.DeleteWebhook(w, r)
|
h.DeleteWebhook(w, r)
|
||||||
case path == "/webhook-templates" && r.Method == http.MethodGet:
|
case path == "/webhook-templates" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetWebhookTemplates(w, r)
|
h.GetWebhookTemplates(w, r)
|
||||||
case path == "/webhook-history" && r.Method == http.MethodGet:
|
case path == "/webhook-history" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetWebhookHistory(w, r)
|
h.GetWebhookHistory(w, r)
|
||||||
case path == "/email-providers" && r.Method == http.MethodGet:
|
case path == "/email-providers" && r.Method == http.MethodGet:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.GetEmailProviders(w, r)
|
h.GetEmailProviders(w, r)
|
||||||
case path == "/test" && r.Method == http.MethodPost:
|
case path == "/test" && r.Method == http.MethodPost:
|
||||||
|
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.TestNotification(w, r)
|
h.TestNotification(w, r)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
|
|
||||||
|
|
@ -137,45 +137,54 @@ func (r *Router) setupRoutes() {
|
||||||
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
|
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
|
||||||
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
||||||
r.mux.HandleFunc("/api/version", r.handleVersion)
|
r.mux.HandleFunc("/api/version", r.handleVersion)
|
||||||
r.mux.HandleFunc("/api/storage/", r.handleStorage)
|
r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage)))
|
||||||
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
|
r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts)))
|
||||||
r.mux.HandleFunc("/api/charts", r.handleCharts)
|
r.mux.HandleFunc("/api/charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleCharts)))
|
||||||
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
|
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
|
||||||
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))
|
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))
|
||||||
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
|
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
|
||||||
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
|
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
|
||||||
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
|
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
|
||||||
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
|
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
|
||||||
r.mux.HandleFunc("/api/config", r.handleConfig)
|
r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig)))
|
||||||
r.mux.HandleFunc("/api/backups", r.handleBackups)
|
r.mux.HandleFunc("/api/backups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
|
||||||
r.mux.HandleFunc("/api/backups/", r.handleBackups)
|
r.mux.HandleFunc("/api/backups/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
|
||||||
r.mux.HandleFunc("/api/backups/unified", r.handleBackups)
|
r.mux.HandleFunc("/api/backups/unified", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
|
||||||
r.mux.HandleFunc("/api/backups/pve", r.handleBackupsPVE)
|
r.mux.HandleFunc("/api/backups/pve", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPVE)))
|
||||||
r.mux.HandleFunc("/api/backups/pbs", r.handleBackupsPBS)
|
r.mux.HandleFunc("/api/backups/pbs", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPBS)))
|
||||||
r.mux.HandleFunc("/api/snapshots", r.handleSnapshots)
|
r.mux.HandleFunc("/api/snapshots", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleSnapshots)))
|
||||||
|
|
||||||
// Guest metadata routes
|
// Guest metadata routes
|
||||||
r.mux.HandleFunc("/api/guests/metadata", guestMetadataHandler.HandleGetMetadata)
|
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
|
||||||
r.mux.HandleFunc("/api/guests/metadata/", func(w http.ResponseWriter, req *http.Request) {
|
r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !ensureScope(w, req, config.ScopeMonitoringRead) {
|
||||||
|
return
|
||||||
|
}
|
||||||
guestMetadataHandler.HandleGetMetadata(w, req)
|
guestMetadataHandler.HandleGetMetadata(w, req)
|
||||||
case http.MethodPut, http.MethodPost:
|
case http.MethodPut, http.MethodPost:
|
||||||
|
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
guestMetadataHandler.HandleUpdateMetadata(w, req)
|
guestMetadataHandler.HandleUpdateMetadata(w, req)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
|
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
guestMetadataHandler.HandleDeleteMetadata(w, req)
|
guestMetadataHandler.HandleDeleteMetadata(w, req)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Update routes
|
// Update routes
|
||||||
r.mux.HandleFunc("/api/updates/check", updateHandlers.HandleCheckUpdates)
|
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
|
||||||
r.mux.HandleFunc("/api/updates/apply", updateHandlers.HandleApplyUpdate)
|
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))
|
||||||
r.mux.HandleFunc("/api/updates/status", updateHandlers.HandleUpdateStatus)
|
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
|
||||||
r.mux.HandleFunc("/api/updates/plan", updateHandlers.HandleGetUpdatePlan)
|
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
|
||||||
r.mux.HandleFunc("/api/updates/history", updateHandlers.HandleListUpdateHistory)
|
r.mux.HandleFunc("/api/updates/history", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleListUpdateHistory)))
|
||||||
r.mux.HandleFunc("/api/updates/history/entry", updateHandlers.HandleGetUpdateHistoryEntry)
|
r.mux.HandleFunc("/api/updates/history/entry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdateHistoryEntry)))
|
||||||
|
|
||||||
// Config management routes
|
// Config management routes
|
||||||
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) {
|
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|
@ -860,10 +869,10 @@ func (r *Router) setupRoutes() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Alert routes
|
// Alert routes
|
||||||
r.mux.HandleFunc("/api/alerts/", r.alertHandlers.HandleAlerts)
|
r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, r.alertHandlers.HandleAlerts))
|
||||||
|
|
||||||
// Notification routes
|
// Notification routes
|
||||||
r.mux.HandleFunc("/api/notifications/", r.notificationHandlers.HandleNotifications)
|
r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications))
|
||||||
|
|
||||||
// Settings routes
|
// Settings routes
|
||||||
r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))
|
r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue