feat: api接口新增access_token校验

This commit is contained in:
H3CoF6
2026-03-24 03:55:37 +08:00
parent 22b85439d3
commit 619cc84d15
4 changed files with 171 additions and 49 deletions

View File

@@ -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',

View File

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

View File

@@ -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 TokenAPI 将允许无鉴权访问', 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 &lt;token&gt;</code>
<code>?access_token=&lt;token&gt;</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>

View File

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