mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: api接口新增access_token校验
This commit is contained in:
@@ -52,13 +52,14 @@ interface ConfigSchema {
|
|||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
messagePushEnabled: boolean
|
messagePushEnabled: boolean
|
||||||
|
httpApiToken: string
|
||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ export class ConfigService {
|
|||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
httpApiToken: '',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
|
|||||||
@@ -246,49 +246,102 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 解析 POST 请求的 JSON Body
|
||||||
*/
|
*/
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||||
// 设置 CORS 头
|
if (req.method !== 'POST') return {}
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
return new Promise((resolve) => {
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
let body = ''
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
req.on('data', chunk => { body += chunk.toString() })
|
||||||
|
req.on('end', () => {
|
||||||
if (req.method === 'OPTIONS') {
|
try {
|
||||||
res.writeHead(204)
|
resolve(JSON.parse(body))
|
||||||
res.end()
|
} catch {
|
||||||
return
|
resolve({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', () => resolve({}))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
/**
|
||||||
const pathname = url.pathname
|
* 鉴权拦截器
|
||||||
|
*/
|
||||||
|
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||||
|
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||||
|
if (!expectedToken) return true
|
||||||
|
|
||||||
try {
|
const authHeader = req.headers.authorization
|
||||||
// 路由处理
|
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
const token = authHeader.substring(7).trim()
|
||||||
this.sendJson(res, { status: 'ok' })
|
if (token === expectedToken) return true
|
||||||
} else if (pathname === '/api/v1/push/messages') {
|
}
|
||||||
this.handleMessagePushStream(req, res)
|
|
||||||
} else if (pathname === '/api/v1/messages') {
|
const queryToken = url.searchParams.get('access_token')
|
||||||
await this.handleMessages(url, res)
|
if (queryToken && queryToken.trim() === expectedToken) return true
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
|
||||||
await this.handleSessions(url, res)
|
const bodyToken = body['access_token']
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
if (bodyToken && String(bodyToken).trim() === expectedToken) return true
|
||||||
await this.handleContacts(url, res)
|
|
||||||
} else if (pathname === '/api/v1/group-members') {
|
return false
|
||||||
await this.handleGroupMembers(url, res)
|
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
|
||||||
this.handleMediaRequest(pathname, res)
|
|
||||||
} else {
|
|
||||||
this.sendError(res, 404, 'Not Found')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[HttpService] Request error:', error)
|
|
||||||
this.sendError(res, 500, String(error))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HTTP 请求 (重构后)
|
||||||
|
*/
|
||||||
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
||||||
|
const pathname = url.pathname
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bodyParams = await this.parseBody(req)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(bodyParams)) {
|
||||||
|
if (!url.searchParams.has(key)) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||||
|
if (!this.verifyToken(req, url, bodyParams)) {
|
||||||
|
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
|
this.sendJson(res, { status: 'ok' })
|
||||||
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
|
this.handleMessagePushStream(req, res)
|
||||||
|
} else if (pathname === '/api/v1/messages') {
|
||||||
|
await this.handleMessages(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
|
await this.handleSessions(url, res)
|
||||||
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
|
await this.handleGroupMembers(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
|
} else {
|
||||||
|
this.sendError(res, 404, 'Not Found')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpService] Request error:', error)
|
||||||
|
this.sendError(res, 500, String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
private startMessagePushHeartbeat(): void {
|
private startMessagePushHeartbeat(): void {
|
||||||
if (this.messagePushHeartbeatTimer) return
|
if (this.messagePushHeartbeatTimer) return
|
||||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
|
|
||||||
|
const [httpApiToken, setHttpApiToken] = useState('')
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -111,6 +113,23 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateRandomToken = async () => {
|
||||||
|
// 生成 32 字符的十六进制随机字符串 (16 bytes)
|
||||||
|
const array = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
setHttpApiToken(token)
|
||||||
|
await configService.setHttpApiToken(token)
|
||||||
|
showMessage('已生成并保存新的 Access Token', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearApiToken = async () => {
|
||||||
|
setHttpApiToken('')
|
||||||
|
await configService.setHttpApiToken('')
|
||||||
|
showMessage('已清除 Access Token,API 将允许无鉴权访问', true)
|
||||||
|
}
|
||||||
|
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
@@ -192,6 +211,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
checkWaylandStatus()
|
checkWaylandStatus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHelloAvailable(isWindows)
|
setHelloAvailable(isWindows)
|
||||||
@@ -319,6 +340,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||||
|
|
||||||
|
const savedHttpApiToken = await configService.getHttpApiToken()
|
||||||
|
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
|
||||||
|
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
setAuthUseHello(savedAuthUseHello)
|
setAuthUseHello(savedAuthUseHello)
|
||||||
setIsLockMode(savedIsLockMode)
|
setIsLockMode(savedIsLockMode)
|
||||||
@@ -1901,6 +1926,36 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Access Token (鉴权凭证)</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
设置后,请求头需携带 <code>Authorization: Bearer <token></code>,
|
||||||
|
或者参数中携带 <code>?access_token=<token></code>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiToken}
|
||||||
|
placeholder="留空表示不验证 Token"
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setHttpApiToken(val)
|
||||||
|
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary" onClick={generateRandomToken}>
|
||||||
|
<RefreshCw size={14} style={{ marginRight: 4 }} /> 随机生成
|
||||||
|
</button>
|
||||||
|
{httpApiToken && (
|
||||||
|
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{httpApiRunning && (
|
{httpApiRunning && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>API 地址</label>
|
<label>API 地址</label>
|
||||||
@@ -1956,18 +2011,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||||
<div className="api-url-display">
|
<div className="api-url-display">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
|
||||||
showMessage('已复制推送地址', true)
|
showMessage('已复制推送地址', true)
|
||||||
}}
|
}}
|
||||||
title="复制"
|
title="复制"
|
||||||
>
|
>
|
||||||
<Copy size={16} />
|
<Copy size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
HTTP_API_TOKEN: 'httpApiToken',
|
||||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
QUOTE_LAYOUT: 'quoteLayout',
|
QUOTE_LAYOUT: 'quoteLayout',
|
||||||
@@ -117,6 +118,17 @@ export async function getDbPath(): Promise<string | null> {
|
|||||||
return value as string | null
|
return value as string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取api access_token
|
||||||
|
export async function getHttpApiToken(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
|
||||||
|
return (value as string) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置access_token
|
||||||
|
export async function setHttpApiToken(token: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
|
||||||
|
}
|
||||||
|
|
||||||
// 设置数据库路径
|
// 设置数据库路径
|
||||||
export async function setDbPath(path: string): Promise<void> {
|
export async function setDbPath(path: string): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.DB_PATH, path)
|
await config.set(CONFIG_KEYS.DB_PATH, path)
|
||||||
|
|||||||
Reference in New Issue
Block a user