feat(ui): add AI_WORKSPACE_AUTH_TOKEN reset feature with double confirmation
This commit is contained in:
parent
9fed1c1cf9
commit
c4db1282ea
65
api/auth.go
65
api/auth.go
@ -1,11 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@ -96,3 +101,63 @@ func constantTimeTokenEqual(actual string, expected string) bool {
|
|||||||
}
|
}
|
||||||
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResetAuthRequest struct {
|
||||||
|
CurrentToken string `json:"currentToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateNewToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthConfig) ResetAuthToken(currentToken string) (string, error) {
|
||||||
|
if !a.Required() {
|
||||||
|
return "", fmt.Errorf("auth is not required")
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := false
|
||||||
|
for _, expected := range a.tokens {
|
||||||
|
if constantTimeTokenEqual(currentToken, expected) {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return "", fmt.Errorf("invalid current token")
|
||||||
|
}
|
||||||
|
|
||||||
|
newToken := generateNewToken()
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
path1 := filepath.Join(home, ".ai_workspace_auth_token")
|
||||||
|
path2 := filepath.Join(home, ".config", "xworkspace", "auth-token")
|
||||||
|
os.MkdirAll(filepath.Dir(path2), 0700)
|
||||||
|
os.WriteFile(path1, []byte(newToken), 0600)
|
||||||
|
os.WriteFile(path2, []byte(newToken), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.tokens = append([]string{newToken}, a.tokens...)
|
||||||
|
|
||||||
|
// Schedule a delayed restart of related system services so the response can be sent first.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
services := []string{
|
||||||
|
"plus.svc.xworkspace.litellm",
|
||||||
|
"plus.svc.xworkspace.openclaw",
|
||||||
|
"plus.svc.xworkspace.hermes",
|
||||||
|
"plus.svc.xworkspace.vault",
|
||||||
|
"plus.svc.xworkspace.bridge",
|
||||||
|
"plus.svc.xworkspace.qmd",
|
||||||
|
"plus.svc.xworkspace.api",
|
||||||
|
"plus.svc.xworkspace.console",
|
||||||
|
}
|
||||||
|
uid := fmt.Sprintf("gui/%d", os.Getuid())
|
||||||
|
for _, svc := range services {
|
||||||
|
_ = exec.Command("launchctl", "kickstart", "-k", fmt.Sprintf("%s/%s", uid, svc)).Run()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return newToken, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func (s *Server) ListenAndServe(addr string) error {
|
|||||||
func (s *Server) routes() http.Handler {
|
func (s *Server) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/auth/status", s.authStatusHandler)
|
mux.HandleFunc("/auth/status", s.authStatusHandler)
|
||||||
|
mux.HandleFunc("/auth/reset", s.resetAuthHandler)
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/services", s.servicesHandler)
|
mux.HandleFunc("/services", s.servicesHandler)
|
||||||
mux.HandleFunc("/portal/services", s.portalServicesHandler)
|
mux.HandleFunc("/portal/services", s.portalServicesHandler)
|
||||||
@ -125,3 +126,23 @@ func writeJSON(w http.ResponseWriter, v any) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) resetAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ResetAuthRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newToken, err := s.auth.ResetAuthToken(req.CurrentToken)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"token": newToken})
|
||||||
|
}
|
||||||
|
|||||||
BIN
api/xworkspace-api
Executable file
BIN
api/xworkspace-api
Executable file
Binary file not shown.
@ -20,6 +20,7 @@ import { Topbar } from './Topbar';
|
|||||||
import { WorkspaceTabs } from './WorkspaceTabs';
|
import { WorkspaceTabs } from './WorkspaceTabs';
|
||||||
import { WorkspaceHome } from './WorkspaceHome';
|
import { WorkspaceHome } from './WorkspaceHome';
|
||||||
import { ServicePanel } from './ServicePanel';
|
import { ServicePanel } from './ServicePanel';
|
||||||
|
import { ResetAuthModal } from './ResetAuthModal';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||||
@ -38,6 +39,7 @@ export function AppShell() {
|
|||||||
const [tokenLoaded, setTokenLoaded] = useState(false);
|
const [tokenLoaded, setTokenLoaded] = useState(false);
|
||||||
const [authError, setAuthError] = useState(false);
|
const [authError, setAuthError] = useState(false);
|
||||||
const [authChecking, setAuthChecking] = useState(false);
|
const [authChecking, setAuthChecking] = useState(false);
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@ -193,6 +195,7 @@ export function AppShell() {
|
|||||||
onOpen={openTab}
|
onOpen={openTab}
|
||||||
onToggleLanguage={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))}
|
onToggleLanguage={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))}
|
||||||
onToggleTheme={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))}
|
onToggleTheme={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))}
|
||||||
|
onResetAuthClick={() => setShowResetModal(true)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
remoteMode={remoteMode}
|
remoteMode={remoteMode}
|
||||||
onToggleRemoteMode={toggleRemoteMode}
|
onToggleRemoteMode={toggleRemoteMode}
|
||||||
@ -221,6 +224,21 @@ export function AppShell() {
|
|||||||
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} portalServices={portalServicesConfig} onOpenService={openTab} />
|
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} portalServices={portalServicesConfig} onOpenService={openTab} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{showResetModal && (
|
||||||
|
<ResetAuthModal
|
||||||
|
labels={labels}
|
||||||
|
onClose={() => setShowResetModal(false)}
|
||||||
|
onResetSuccess={(newToken) => {
|
||||||
|
window.localStorage.setItem('xworkspace-bridge-token', newToken);
|
||||||
|
setAuthToken(newToken);
|
||||||
|
setTokenInput(newToken);
|
||||||
|
setShowResetModal(false);
|
||||||
|
alert(`Token reset successfully! New token has been saved to your browser session.\n\nPlease note: System services are restarting and may be briefly unavailable.`);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
141
dashboard/src/components/ResetAuthModal.tsx
Normal file
141
dashboard/src/components/ResetAuthModal.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Labels } from '@/lib/data';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export function ResetAuthModal({
|
||||||
|
labels,
|
||||||
|
onClose,
|
||||||
|
onResetSuccess
|
||||||
|
}: {
|
||||||
|
labels: Labels;
|
||||||
|
onClose: () => void;
|
||||||
|
onResetSuccess: (newToken: string) => void;
|
||||||
|
}) {
|
||||||
|
const [currentToken, setCurrentToken] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentToken.trim()) {
|
||||||
|
setError('Please enter the current token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${window.localStorage.getItem('xworkspace-bridge-token') || ''}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json();
|
||||||
|
throw new Error(errData.error || 'Failed to reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.token) {
|
||||||
|
onResetSuccess(data.token);
|
||||||
|
} else {
|
||||||
|
throw new Error('No token returned from server');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred while resetting');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--card-bg, #fff)',
|
||||||
|
color: 'var(--text, #333)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
width: '400px',
|
||||||
|
maxWidth: '90%',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ marginTop: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Icon name="terminal" /> Reset Auth Token
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#d32f2f', fontSize: '0.9rem', marginBottom: '16px' }}>
|
||||||
|
<strong>Warning:</strong> This will invalidate the current token. All connected services (LiteLLM, OpenClaw, Vault, Hermes) will automatically restart and drop existing connections. You will need the new token to log in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleReset}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||||
|
Confirm Current Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentToken}
|
||||||
|
onChange={(e) => setCurrentToken(e.target.value)}
|
||||||
|
placeholder="Paste current token here to confirm..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border, #ccc)',
|
||||||
|
backgroundColor: 'var(--input-bg, #fff)',
|
||||||
|
color: 'var(--text, #333)',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#d32f2f', marginBottom: '16px', fontSize: '0.9rem' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border, #ccc)',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text, #333)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
background: '#d32f2f',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting...' : 'Reset Token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export function Sidebar({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onToggleLanguage,
|
onToggleLanguage,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
|
onResetAuthClick,
|
||||||
theme,
|
theme,
|
||||||
remoteMode,
|
remoteMode,
|
||||||
onToggleRemoteMode,
|
onToggleRemoteMode,
|
||||||
@ -25,6 +26,7 @@ export function Sidebar({
|
|||||||
onOpen: (item: NavItem) => void;
|
onOpen: (item: NavItem) => void;
|
||||||
onToggleLanguage: () => void;
|
onToggleLanguage: () => void;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
|
onResetAuthClick: () => void;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
remoteMode: boolean;
|
remoteMode: boolean;
|
||||||
onToggleRemoteMode: () => void;
|
onToggleRemoteMode: () => void;
|
||||||
@ -73,6 +75,17 @@ export function Sidebar({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-tools">
|
<div className="sidebar-tools">
|
||||||
|
<button
|
||||||
|
className="sidebar-tool-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset Auth Token"
|
||||||
|
title="Reset Auth Token"
|
||||||
|
onClick={onResetAuthClick}
|
||||||
|
style={{ color: '#d32f2f' }}
|
||||||
|
>
|
||||||
|
<Icon name="terminal" />
|
||||||
|
<strong>{!collapsed ? 'Reset Token' : ''}</strong>
|
||||||
|
</button>
|
||||||
<button className="sidebar-tool-button" type="button" aria-label={collapsed ? labels.expand : labels.collapse} onClick={onToggle}>
|
<button className="sidebar-tool-button" type="button" aria-label={collapsed ? labels.expand : labels.collapse} onClick={onToggle}>
|
||||||
<Icon name={collapsed ? 'panel-expand' : 'panel-collapse'} />
|
<Icon name={collapsed ? 'panel-expand' : 'panel-collapse'} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user