feat(remote): 完善远程桌面认证机制
1. 修复 WebSocket 认证漏洞:WebSocket 连接现在需要认证(支持 URL 参数 password 或 Cookie token) 2. 支持 URL 参数自动登录:HTTP 请求带 ?password=xxx 参数时会自动验证并设置 cookie 3. 主程序添加密码配置: - RemoteDevice 类型添加 password 字段 - ConfigDialog 添加密码输入框 - 打开远程桌面时传递 password 参数 4. 修复 remote/public/js/app.js: - 从 URL 参数获取 password 并传递给 WebSocket 连接 - 移除错误的 token 当作 password 的代码 5. 添加密码变化检测:修改密码后自动刷新页面重新认证,无需重启 remote 服务 6. 文件传输 API 支持 password 参数
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
(function() {
|
||||
const password = getCookie('auth') || '';
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.hostname;
|
||||
const wsPort = window.location.port;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const password = urlParams.get('password');
|
||||
const wsUrlBase = wsPort ? `${wsProtocol}//${wsHost}:${wsPort}/ws` : `${wsProtocol}//${wsHost}/ws`;
|
||||
const WS_URL = password ? `${wsUrlBase}?password=${encodeURIComponent(password)}` : wsUrlBase;
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ class App {
|
||||
httpServer.renderLoginPage(res, '密码错误');
|
||||
});
|
||||
|
||||
httpServer.use((req, res, next) => {
|
||||
httpServer.use(async (req, res, next) => {
|
||||
if (!authService.hasPassword()) {
|
||||
res.locals.authenticated = true;
|
||||
return next();
|
||||
@@ -250,8 +250,31 @@ class App {
|
||||
if (token) {
|
||||
const decoded = tokenManager.verifyToken(token);
|
||||
if (decoded) {
|
||||
logger.info('HTTP auth: token valid from cookie', { path: req.path, ip: req.socket?.remoteAddress });
|
||||
res.locals.authenticated = true;
|
||||
return next();
|
||||
} else {
|
||||
logger.info('HTTP auth: token invalid from cookie', { path: req.path, ip: req.socket?.remoteAddress });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 URL 参数中的 password
|
||||
const urlPassword = req.query.password;
|
||||
if (urlPassword) {
|
||||
logger.info('HTTP auth: checking password from URL', {
|
||||
path: req.path,
|
||||
ip: req.socket?.remoteAddress,
|
||||
passwordLength: urlPassword.length
|
||||
});
|
||||
const isValid = await authService.authenticate(urlPassword);
|
||||
if (isValid) {
|
||||
const newToken = tokenManager.generateToken({ userId: 'default-user' });
|
||||
res.cookie('auth', newToken, { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||
logger.info('HTTP auth: password valid, token generated', { path: req.path, ip: req.socket?.remoteAddress });
|
||||
res.locals.authenticated = true;
|
||||
return next();
|
||||
} else {
|
||||
logger.warn('HTTP auth: password invalid', { path: req.path, ip: req.socket?.remoteAddress });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,18 +306,63 @@ class App {
|
||||
const originalHandlers = this.wss.listeners('connection');
|
||||
this.wss.removeAllListeners('connection');
|
||||
|
||||
const securityConfig = require('../utils/config').getSecurityConfig();
|
||||
const password = securityConfig.password;
|
||||
|
||||
// 未认证的连接也允许,用于剪贴板同步
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
this.wss.on('connection', async (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const isAuthenticated = url.searchParams.get('password') === password;
|
||||
const fullUrl = req.url;
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authMethod = '';
|
||||
|
||||
// 检查 URL 参数中的 password
|
||||
const urlPassword = url.searchParams.get('password');
|
||||
if (urlPassword) {
|
||||
logger.info('WebSocket auth: checking password from URL', {
|
||||
ip: req.socket?.remoteAddress,
|
||||
urlLength: fullUrl.length,
|
||||
passwordLength: urlPassword.length,
|
||||
passwordPrefix: urlPassword.substring(0, 2) + '***'
|
||||
});
|
||||
const isValid = await authService.authenticate(urlPassword);
|
||||
if (isValid) {
|
||||
isAuthenticated = true;
|
||||
authMethod = 'password_url';
|
||||
logger.info('WebSocket auth: password from URL valid', { ip: req.socket?.remoteAddress });
|
||||
} else {
|
||||
logger.warn('WebSocket auth: password from URL invalid', { ip: req.socket?.remoteAddress });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Cookie 中的 token
|
||||
if (!isAuthenticated && req.cookies && req.cookies.auth) {
|
||||
logger.info('WebSocket auth: checking token from cookie', { ip: req.socket?.remoteAddress });
|
||||
const decoded = tokenManager.verifyToken(req.cookies.auth);
|
||||
if (decoded) {
|
||||
isAuthenticated = true;
|
||||
authMethod = 'cookie_token';
|
||||
logger.info('WebSocket auth: token from cookie valid', { ip: req.socket?.remoteAddress });
|
||||
} else {
|
||||
logger.warn('WebSocket auth: token from cookie invalid', { ip: req.socket?.remoteAddress });
|
||||
}
|
||||
}
|
||||
|
||||
// 未认证,拒绝连接
|
||||
if (!isAuthenticated) {
|
||||
logger.warn('WebSocket authentication failed', {
|
||||
ip: req.socket?.remoteAddress,
|
||||
hasPassword: !!urlPassword,
|
||||
hasCookie: !!(req.cookies && req.cookies.auth),
|
||||
fullUrl: fullUrl.substring(0, 200)
|
||||
});
|
||||
ws.close(1008, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('WebSocket authenticated', { ip: req.socket?.remoteAddress, authMethod });
|
||||
|
||||
// 保存认证状态
|
||||
ws.isAuthenticated = isAuthenticated;
|
||||
ws.isAuthenticated = true;
|
||||
|
||||
// 处理输入消息(不检查认证)
|
||||
// 处理输入消息
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
@@ -304,7 +372,7 @@ class App {
|
||||
}
|
||||
});
|
||||
|
||||
// 调用原始 handlers(用于已认证的连接)
|
||||
// 调用原始 handlers
|
||||
originalHandlers.forEach(handler => {
|
||||
handler(ws, req);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface RemoteDevice {
|
||||
serverHost: string
|
||||
desktopPort: number
|
||||
gitPort: number
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface RemoteConfig {
|
||||
|
||||
@@ -41,10 +41,12 @@ export const TabContentCache: React.FC<TabContentCacheProps> = ({
|
||||
const urlParams = new URLSearchParams(queryString)
|
||||
const serverHost = urlParams.get('host') || ''
|
||||
const port = parseInt(urlParams.get('port') || '3000', 10)
|
||||
const password = urlParams.get('password') || undefined
|
||||
return (
|
||||
<FileTransferPage
|
||||
serverHost={serverHost}
|
||||
port={port}
|
||||
password={password}
|
||||
onClose={() => closeFile(file)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -165,7 +165,10 @@ export const RemotePage: React.FC = () => {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}`
|
||||
let url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}`
|
||||
if (selectedConfig.password) {
|
||||
url += `?password=${encodeURIComponent(selectedConfig.password)}`
|
||||
}
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `远程桌面${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
@@ -176,7 +179,7 @@ export const RemotePage: React.FC = () => {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.gitPort}`
|
||||
let url = `http://${selectedConfig.serverHost}:${selectedConfig.gitPort}`
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteGitFileItem(url, `远程 Git 仓库${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
@@ -187,7 +190,10 @@ export const RemotePage: React.FC = () => {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `file-transfer-panel?host=${encodeURIComponent(selectedConfig.serverHost)}&port=${selectedConfig.desktopPort}`
|
||||
let url = `file-transfer-panel?host=${encodeURIComponent(selectedConfig.serverHost)}&port=${selectedConfig.desktopPort}&device=${encodeURIComponent(selectedConfig.deviceName || '')}`
|
||||
if (selectedConfig.password) {
|
||||
url += `&password=${encodeURIComponent(selectedConfig.password)}`
|
||||
}
|
||||
const fileItem: FileItem = {
|
||||
name: `文件传输 - ${selectedConfig.deviceName}`,
|
||||
path: url,
|
||||
@@ -203,7 +209,10 @@ export const RemotePage: React.FC = () => {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/opencode`
|
||||
let url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/opencode`
|
||||
if (selectedConfig.password) {
|
||||
url += `?password=${encodeURIComponent(selectedConfig.password)}`
|
||||
}
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `OpenCode${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
@@ -214,7 +223,10 @@ export const RemotePage: React.FC = () => {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/openclaw`
|
||||
let url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/openclaw`
|
||||
if (selectedConfig.password) {
|
||||
url += `?password=${encodeURIComponent(selectedConfig.password)}`
|
||||
}
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `OpenClaw${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import { type RemoteEndpoints } from '@shared/modules/remote'
|
||||
import type { RemoteConfig, RemoteFileItem } from './types'
|
||||
import { fetchFiles as fetchSystemFiles } from '@/lib/api'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
|
||||
export type { RemoteFileItem } from './types'
|
||||
export { fetchSystemFiles }
|
||||
|
||||
export const fetchSystemDrives = async (): Promise<FileItem[]> => {
|
||||
const data = await fetchApi<{ items: FileItem[] }>('/api/files/drives')
|
||||
return data.items
|
||||
}
|
||||
|
||||
export const fetchSystemFiles = async (systemPath: string): Promise<FileItem[]> => {
|
||||
const data = await fetchApi<{ items: FileItem[] }>(`/api/files/system?path=${encodeURIComponent(systemPath)}`)
|
||||
return data.items
|
||||
}
|
||||
|
||||
export const fetchSystemFileContent = async (systemPath: string): Promise<Blob> => {
|
||||
const response = await fetch(`/api/files/system/content?path=${encodeURIComponent(systemPath)}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${response.statusText}`)
|
||||
}
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
const fetchApi = async <T>(url: string, options?: RequestInit): Promise<T> => {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.statusText}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
return result.data
|
||||
}
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<RemoteEndpoints>('remote')
|
||||
@@ -48,13 +74,23 @@ export const fetchRemoteFiles = async (
|
||||
path: string,
|
||||
password?: string
|
||||
): Promise<RemoteFileItem[]> => {
|
||||
const url = `http://${serverHost}:${port}/api/files?path=${encodeURIComponent(path)}`
|
||||
let url = `http://${serverHost}:${port}/api/files/browse?path=${encodeURIComponent(path)}`
|
||||
if (password) {
|
||||
url += `&password=${encodeURIComponent(password)}`
|
||||
}
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch remote files: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.items || []
|
||||
const items = data.items || []
|
||||
return items.map((item: { name: string; isDirectory: boolean; size: number; modified: Date }) => ({
|
||||
name: item.name,
|
||||
path: data.currentPath ? `${data.currentPath}/${item.name}` : item.name,
|
||||
type: item.isDirectory ? 'dir' : 'file',
|
||||
size: item.size,
|
||||
modified: item.modified?.toString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export const uploadFileToRemote = async (
|
||||
@@ -65,7 +101,10 @@ export const uploadFileToRemote = async (
|
||||
password?: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const url = `http://${serverHost}:${port}/api/files/upload`
|
||||
let url = `http://${serverHost}:${port}/api/files/upload`
|
||||
if (password) {
|
||||
url += `?password=${encodeURIComponent(password)}`
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (remotePath) {
|
||||
@@ -107,7 +146,10 @@ export const downloadFileFromRemote = async (
|
||||
password?: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<Blob> => {
|
||||
const url = `http://${serverHost}:${port}/api/files/download?path=${encodeURIComponent(remotePath)}&name=${encodeURIComponent(fileName)}`
|
||||
let url = `http://${serverHost}:${port}/api/files/${encodeURIComponent(fileName)}`
|
||||
if (password) {
|
||||
url += `?password=${encodeURIComponent(password)}`
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -154,6 +154,19 @@ export const ConfigDialog: React.FC<ConfigDialogProps> = ({
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
访问密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password || ''}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
placeholder="远程桌面访问密码"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -36,12 +36,26 @@ export const RemoteWebView: React.FC<RemoteWebViewProps> = ({ url, title, isDesk
|
||||
const clipboardWsRef = useRef<WebSocket | null>(null)
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string>('')
|
||||
|
||||
// 生成 WebSocket URL(不需要密码,WebSocket 允许未认证连接)
|
||||
// 检测密码变化,强制刷新页面
|
||||
const prevPasswordRef = useRef<string>('')
|
||||
useEffect(() => {
|
||||
const urlObj = new URL(url)
|
||||
const currentPassword = urlObj.searchParams.get('password') || ''
|
||||
if (prevPasswordRef.current && prevPasswordRef.current !== currentPassword) {
|
||||
console.log('[RemoteWebView] Password changed, reloading page')
|
||||
webviewRef.current?.reload()
|
||||
}
|
||||
prevPasswordRef.current = currentPassword
|
||||
}, [url])
|
||||
|
||||
// 生成 WebSocket URL(携带 password 参数进行认证)
|
||||
const getWsUrl = useCallback(() => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const wsProtocol = urlObj.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProtocol}//${urlObj.host}/ws`
|
||||
const password = urlObj.searchParams.get('password')
|
||||
const wsUrl = `${wsProtocol}//${urlObj.host}/ws`
|
||||
return password ? `${wsUrl}?password=${encodeURIComponent(password)}` : wsUrl
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api'
|
||||
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote, fetchSystemFileContent } from '../../api'
|
||||
import { type TransferItem } from '../../types'
|
||||
import { LocalFilePanel } from './LocalFilePanel'
|
||||
import { RemoteFilePanel } from './RemoteFilePanel'
|
||||
@@ -9,10 +9,11 @@ import { TransferQueue } from './TransferQueue'
|
||||
interface FileTransferPageProps {
|
||||
serverHost: string
|
||||
port: number
|
||||
password?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost, port, onClose }) => {
|
||||
export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost, port, password, onClose }) => {
|
||||
const [localSelected, setLocalSelected] = useState<FileItem | null>(null)
|
||||
const [remoteSelected, setRemoteSelected] = useState<RemoteFileItem | null>(null)
|
||||
const [transfers, setTransfers] = useState<TransferItem[]>([])
|
||||
@@ -38,11 +39,10 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(localSelected.path)}`)
|
||||
const blob = await response.blob()
|
||||
const blob = await fetchSystemFileContent(localSelected.path)
|
||||
const file = new File([blob], localSelected.name, { type: blob.type })
|
||||
|
||||
await uploadFileToRemote(serverHost, port, file, '', undefined, (progress) => {
|
||||
await uploadFileToRemote(serverHost, port, file, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
@@ -84,7 +84,7 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', undefined, (progress) => {
|
||||
await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
@@ -162,6 +162,7 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
||||
<RemoteFilePanel
|
||||
serverHost={serverHost}
|
||||
port={port}
|
||||
password={password}
|
||||
selectedFile={remoteSelected}
|
||||
onSelect={setRemoteSelected}
|
||||
onDownload={handleDownload}
|
||||
@@ -171,7 +172,8 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2 -my-1 cursor-row-resize hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors relative z-10"
|
||||
className="cursor-row-resize hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors relative z-10"
|
||||
style={{ height: 4, marginTop: 0, marginBottom: -4 }}
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface RemoteDevice {
|
||||
serverHost: string
|
||||
desktopPort: number
|
||||
gitPort: number
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface RemoteConfig {
|
||||
|
||||
Reference in New Issue
Block a user