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:
2026-03-09 00:54:48 +08:00
parent 8531d916a3
commit 50cfc8835f
10 changed files with 187 additions and 32 deletions

View File

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

View File

@@ -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);
});

View File

@@ -4,6 +4,7 @@ export interface RemoteDevice {
serverHost: string
desktopPort: number
gitPort: number
password?: string
}
export interface RemoteConfig {

View File

@@ -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)}
/>
)

View 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)

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

@@ -14,6 +14,7 @@ export interface RemoteDevice {
serverHost: string
desktopPort: number
gitPort: number
password?: string
}
export interface RemoteConfig {