diff --git a/src/cli.ts b/src/cli.ts index 114e068..bcbabee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -215,7 +215,22 @@ async function main() { // Get service info and open UI const serviceInfo = await getServiceInfo(); - const uiUrl = `${serviceInfo.endpoint}/ui/`; + + // Generate temporary API key based on system UUID + let tempApiKey = ""; + try { + const { getTempAPIKey } = require("./utils"); + tempApiKey = await getTempAPIKey(); + } catch (error: any) { + console.warn("Warning: Failed to generate temporary API key:", error.message); + console.warn("Continuing without temporary API key..."); + } + + // Add temporary API key as URL parameter if successfully generated + const uiUrl = tempApiKey + ? `${serviceInfo.endpoint}/ui/?tempApiKey=${tempApiKey}` + : `${serviceInfo.endpoint}/ui/`; + console.log(`Opening UI at ${uiUrl}`); // Open URL in browser based on platform diff --git a/src/index.ts b/src/index.ts index 971d8d1..eba09b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,7 +93,17 @@ async function run(options: RunOptions = {}) { ), }, }); - server.addHook("preHandler", apiKeyAuth(config)); + // Add async preHandler hook for authentication + server.addHook("preHandler", async (req, reply) => { + return new Promise((resolve, reject) => { + const done = (err?: Error) => { + if (err) reject(err); + else resolve(); + }; + // Call the async auth function + apiKeyAuth(config)(req, reply, done).catch(reject); + }); + }); server.addHook("preHandler", async (req, reply) => { if(req.url.startsWith("/v1/messages")) { router(req, reply, config) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 061d3d8..f287039 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,17 +1,94 @@ import { FastifyRequest, FastifyReply } from "fastify"; +import { getTempAPIKey } from "../utils/systemUUID"; export const apiKeyAuth = (config: any) => - (req: FastifyRequest, reply: FastifyReply, done: () => void) => { + async (req: FastifyRequest, reply: FastifyReply, done: () => void) => { + // Check for temp API key in query parameters or headers + let tempApiKey = null; + if (req.query && (req.query as any).tempApiKey) { + tempApiKey = (req.query as any).tempApiKey; + } else if (req.headers['x-temp-api-key']) { + tempApiKey = req.headers['x-temp-api-key'] as string; + } + + // If temp API key is provided, validate it + if (tempApiKey) { + try { + const expectedTempKey = await getTempAPIKey(); + + // If temp key matches, grant temporary full access + if (tempApiKey === expectedTempKey) { + (req as any).accessLevel = "full"; + (req as any).isTempAccess = true; + return done(); + } + } catch (error) { + // If there's an error generating temp key, continue with normal auth + console.warn("Failed to verify temporary API key:", error); + } + } + + // Public endpoints that don't require authentication if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) { return done(); } - const apiKey = config.APIKEY; + const apiKey = config.APIKEY; + const isConfigEndpoint = req.url.startsWith("/api/config"); + + // For config endpoints, we implement granular access control + if (isConfigEndpoint) { + // Attach access level to request for later use + (req as any).accessLevel = "restricted"; + + // If no API key is set in config, allow restricted access + if (!apiKey) { + (req as any).accessLevel = "restricted"; + return done(); + } + + // Check for temporary access via query parameter (for UI) + if ((req as any).isTempAccess) { + return done(); + } + + // If API key is set, check authentication + const authKey: string = + req.headers.authorization || req.headers["x-api-key"]; + + if (!authKey) { + (req as any).accessLevel = "restricted"; + return done(); + } + + let token = ""; + if (authKey.startsWith("Bearer")) { + token = authKey.split(" ")[1]; + } else { + token = authKey; + } + + if (token !== apiKey) { + (req as any).accessLevel = "restricted"; + return done(); + } + + // Full access for authenticated users + (req as any).accessLevel = "full"; + return done(); + } + + // For non-config endpoints, use existing logic if (!apiKey) { return done(); } + // Check for temporary access via query parameter (for UI) + if ((req as any).isTempAccess) { + return done(); + } + const authKey: string = req.headers.authorization || req.headers["x-api-key"]; if (!authKey) { diff --git a/src/server.ts b/src/server.ts index bb8db13..ead0f6e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,8 +8,18 @@ import fastifyStatic from "@fastify/static"; export const createServer = (config: any): Server => { const server = new Server(config); - // Add endpoint to read config.json - server.app.get("/api/config", async () => { + // Add endpoint to read config.json with access control + server.app.get("/api/config", async (req, reply) => { + // Get access level from request (set by auth middleware) + const accessLevel = (req as any).accessLevel || "restricted"; + + // If restricted access, return 401 + if (accessLevel === "restricted") { + reply.status(401).send("API key required to access configuration"); + return; + } + + // For full access (including temp API key), return complete config return await readConfigFile(); }); @@ -25,8 +35,15 @@ export const createServer = (config: any): Server => { return { transformers: transformerList }; }); - // Add endpoint to save config.json - server.app.post("/api/config", async (req) => { + // Add endpoint to save config.json with access control + server.app.post("/api/config", async (req, reply) => { + // Only allow full access users to save config + const accessLevel = (req as any).accessLevel || "restricted"; + if (accessLevel !== "full") { + reply.status(403).send("Full access required to modify configuration"); + return; + } + const newConfig = req.body; // Backup existing config file if it exists @@ -39,9 +56,29 @@ export const createServer = (config: any): Server => { await writeConfigFile(newConfig); return { success: true, message: "Config saved successfully" }; }); + + // Add endpoint for testing full access without modifying config + server.app.post("/api/config/test", async (req, reply) => { + // Only allow full access users to test config access + const accessLevel = (req as any).accessLevel || "restricted"; + if (accessLevel !== "full") { + reply.status(403).send("Full access required to test configuration access"); + return; + } + + // Return success without modifying anything + return { success: true, message: "Access granted" }; + }); - // Add endpoint to restart the service - server.app.post("/api/restart", async (_, reply) => { + // Add endpoint to restart the service with access control + server.app.post("/api/restart", async (req, reply) => { + // Only allow full access users to restart service + const accessLevel = (req as any).accessLevel || "restricted"; + if (accessLevel !== "full") { + reply.status(403).send("Full access required to restart service"); + return; + } + reply.send({ success: true, message: "Service restart initiated" }); // Restart the service after a short delay to allow response to be sent diff --git a/src/utils/index.ts b/src/utils/index.ts index 1581679..d47cf21 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,6 +8,7 @@ import { HOME_DIR, PLUGINS_DIR, } from "../constants"; +import { getSystemUUID, generateTempAPIKey, getTempAPIKey } from "./systemUUID"; const ensureDir = async (dir_path: string) => { try { @@ -135,3 +136,6 @@ export const initConfig = async () => { Object.assign(process.env, config); return config; }; + +// 导出系统UUID相关函数 +export { getSystemUUID, generateTempAPIKey, getTempAPIKey }; diff --git a/src/utils/systemUUID.ts b/src/utils/systemUUID.ts new file mode 100644 index 0000000..df19ffd --- /dev/null +++ b/src/utils/systemUUID.ts @@ -0,0 +1,77 @@ +import { execSync } from "child_process"; +import { createHash } from "crypto"; +import os from "os"; + +/** + * 跨平台获取系统UUID + * @returns 系统UUID字符串 + */ +export async function getSystemUUID(): Promise { + const platform = os.platform(); + + try { + let uuid: string; + + switch (platform) { + case "win32": // Windows + uuid = execSync("wmic csproduct get UUID", { encoding: "utf8" }) + .split("\n")[1] + .trim(); + break; + + case "darwin": // macOS + uuid = execSync( + "system_profiler SPHardwareDataType | grep 'Hardware UUID'", + { encoding: "utf8" } + ) + .split(":")[1] + .trim(); + break; + + case "linux": // Linux + // 尝试使用 dmidecode (需要 root 权限) + try { + uuid = execSync("dmidecode -s system-uuid", { encoding: "utf8" }).trim(); + } catch (dmidecodeError) { + // 如果 dmidecode 失败,尝试读取 sysfs (不需要 root 权限,但可能没有权限) + try { + uuid = execSync("cat /sys/class/dmi/id/product_uuid", { encoding: "utf8" }).trim(); + } catch (sysfsError) { + throw new Error("无法在Linux系统上获取系统UUID,可能需要root权限"); + } + } + break; + + default: + throw new Error(`不支持的操作系统: ${platform}`); + } + + return uuid; + } catch (error) { + throw new Error(`获取系统UUID失败: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * 基于系统UUID生成固定的临时API密钥 + * @param systemUUID 系统UUID + * @returns 生成的API密钥 + */ +export function generateTempAPIKey(systemUUID: string): string { + // 使用SHA-256哈希算法确保一致性 + const hash = createHash("sha256"); + hash.update(systemUUID); + // 添加盐值以增加安全性 + hash.update("claude-code-router-temp-key-salt"); + // 生成32字符的十六进制字符串 + return hash.digest("hex").substring(0, 32); +} + +/** + * 获取临时API密钥(完整的便利函数) + * @returns 临时API密钥 + */ +export async function getTempAPIKey(): Promise { + const uuid = await getSystemUUID(); + return generateTempAPIKey(uuid); +} \ No newline at end of file diff --git a/ui/src/components/ConfigProvider.tsx b/ui/src/components/ConfigProvider.tsx index aae4d34..85e2a96 100644 --- a/ui/src/components/ConfigProvider.tsx +++ b/ui/src/components/ConfigProvider.tsx @@ -128,6 +128,20 @@ export function ConfigProvider({ children }: ConfigProviderProps) { fetchConfig(); }, [hasFetched, apiKey]); + // Check if user has full access + useEffect(() => { + const checkAccess = async () => { + if (config) { + const hasFullAccess = await api.checkFullAccess(); + // Store access level in a global state or context if needed + // For now, we'll just log it + console.log('User has full access:', hasFullAccess); + } + }; + + checkAccess(); + }, [config]); + return ( {children} diff --git a/ui/src/components/Login.tsx b/ui/src/components/Login.tsx index d3dac51..bbfe08f 100644 --- a/ui/src/components/Login.tsx +++ b/ui/src/components/Login.tsx @@ -61,18 +61,23 @@ export function Login() { url: window.location.href })); - // Test the API key by fetching config (skip if apiKey is empty) - if (apiKey) { - await api.getConfig(); - } + // Test the API key by fetching config + await api.getConfig(); // Navigate to dashboard // The ConfigProvider will handle fetching the config navigate('/dashboard'); - } catch { + } catch (error: any) { // Clear the API key on failure api.setApiKey(''); - setError(t('login.invalidApiKey')); + + // Check if it's an unauthorized error + if (error.message && error.message.includes('401')) { + setError(t('login.invalidApiKey')); + } else { + // For other errors, still allow access (restricted mode) + navigate('/dashboard'); + } } }; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 10fb051..ae7f7eb 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -4,11 +4,14 @@ import type { Config, Provider, Transformer } from '@/types'; class ApiClient { private baseUrl: string; private apiKey: string; + private tempApiKey: string | null; constructor(baseUrl: string = '/api', apiKey: string = '') { this.baseUrl = baseUrl; // Load API key from localStorage if available this.apiKey = apiKey || localStorage.getItem('apiKey') || ''; + // Load temp API key from URL if available + this.tempApiKey = new URLSearchParams(window.location.search).get('tempApiKey'); } // Update base URL @@ -26,14 +29,25 @@ class ApiClient { localStorage.removeItem('apiKey'); } } + + // Update temp API key + setTempApiKey(tempApiKey: string | null) { + this.tempApiKey = tempApiKey; + } // Create headers with API key authentication private createHeaders(contentType: string = 'application/json'): HeadersInit { const headers: Record = { - 'X-API-Key': this.apiKey, 'Accept': 'application/json', }; + // Use temp API key if available, otherwise use regular API key + if (this.tempApiKey) { + headers['X-Temp-API-Key'] = this.tempApiKey; + } else if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + } + if (contentType) { headers['Content-Type'] = contentType; } @@ -126,6 +140,22 @@ class ApiClient { return this.post('/config', config); } + // Check if user has full access + async checkFullAccess(): Promise { + try { + // Try to access test endpoint (won't actually modify anything) + // This will return 403 if user doesn't have full access + await this.post('/config/test', { test: true }); + return true; + } catch (error: any) { + if (error.message && error.message.includes('403')) { + return false; + } + // For other errors, assume user has access (to avoid blocking legitimate users) + return true; + } + } + // Get providers async getProviders(): Promise { return this.get('/api/providers'); diff --git a/ui/src/types.ts b/ui/src/types.ts index 5edd5ae..5bd9cec 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -40,3 +40,5 @@ export interface Config { API_TIMEOUT_MS: string; PROXY_URL: string; } + +export type AccessLevel = 'restricted' | 'full';