feat: Implement temporary API key based on system UUID for UI access
This commit introduces a new authentication mechanism for the web UI. Instead of requiring a pre-configured API key, a temporary API key is generated based on the system's UUID. This key is passed to the UI as a URL parameter and used for API requests. Changes: - Added a new utility to get the system UUID and generate a temporary API key. - Modified the `ccr ui` command to generate and pass the temporary API key. - Updated the authentication middleware to validate the temporary API key. - Adjusted the frontend to use the temporary API key from the URL. - Added a dedicated endpoint to test API access without modifying data. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
17
src/cli.ts
17
src/cli.ts
@@ -215,7 +215,22 @@ async function main() {
|
|||||||
|
|
||||||
// Get service info and open UI
|
// Get service info and open UI
|
||||||
const serviceInfo = await getServiceInfo();
|
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}`);
|
console.log(`Opening UI at ${uiUrl}`);
|
||||||
|
|
||||||
// Open URL in browser based on platform
|
// Open URL in browser based on platform
|
||||||
|
|||||||
12
src/index.ts
12
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) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
if(req.url.startsWith("/v1/messages")) {
|
if(req.url.startsWith("/v1/messages")) {
|
||||||
router(req, reply, config)
|
router(req, reply, config)
|
||||||
|
|||||||
@@ -1,14 +1,91 @@
|
|||||||
import { FastifyRequest, FastifyReply } from "fastify";
|
import { FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import { getTempAPIKey } from "../utils/systemUUID";
|
||||||
|
|
||||||
export const apiKeyAuth =
|
export const apiKeyAuth =
|
||||||
(config: any) =>
|
(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")) {
|
if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) {
|
||||||
return done();
|
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) {
|
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();
|
return done();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ import fastifyStatic from "@fastify/static";
|
|||||||
export const createServer = (config: any): Server => {
|
export const createServer = (config: any): Server => {
|
||||||
const server = new Server(config);
|
const server = new Server(config);
|
||||||
|
|
||||||
// Add endpoint to read config.json
|
// Add endpoint to read config.json with access control
|
||||||
server.app.get("/api/config", async () => {
|
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();
|
return await readConfigFile();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,8 +35,15 @@ export const createServer = (config: any): Server => {
|
|||||||
return { transformers: transformerList };
|
return { transformers: transformerList };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add endpoint to save config.json
|
// Add endpoint to save config.json with access control
|
||||||
server.app.post("/api/config", async (req) => {
|
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;
|
const newConfig = req.body;
|
||||||
|
|
||||||
// Backup existing config file if it exists
|
// Backup existing config file if it exists
|
||||||
@@ -40,8 +57,28 @@ export const createServer = (config: any): Server => {
|
|||||||
return { success: true, message: "Config saved successfully" };
|
return { success: true, message: "Config saved successfully" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add endpoint to restart the service
|
// Add endpoint for testing full access without modifying config
|
||||||
server.app.post("/api/restart", async (_, reply) => {
|
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 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" });
|
reply.send({ success: true, message: "Service restart initiated" });
|
||||||
|
|
||||||
// Restart the service after a short delay to allow response to be sent
|
// Restart the service after a short delay to allow response to be sent
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HOME_DIR,
|
HOME_DIR,
|
||||||
PLUGINS_DIR,
|
PLUGINS_DIR,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { getSystemUUID, generateTempAPIKey, getTempAPIKey } from "./systemUUID";
|
||||||
|
|
||||||
const ensureDir = async (dir_path: string) => {
|
const ensureDir = async (dir_path: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -135,3 +136,6 @@ export const initConfig = async () => {
|
|||||||
Object.assign(process.env, config);
|
Object.assign(process.env, config);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 导出系统UUID相关函数
|
||||||
|
export { getSystemUUID, generateTempAPIKey, getTempAPIKey };
|
||||||
|
|||||||
77
src/utils/systemUUID.ts
Normal file
77
src/utils/systemUUID.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
const uuid = await getSystemUUID();
|
||||||
|
return generateTempAPIKey(uuid);
|
||||||
|
}
|
||||||
@@ -128,6 +128,20 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
|||||||
fetchConfig();
|
fetchConfig();
|
||||||
}, [hasFetched, apiKey]);
|
}, [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 (
|
return (
|
||||||
<ConfigContext.Provider value={{ config, setConfig, error }}>
|
<ConfigContext.Provider value={{ config, setConfig, error }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -61,18 +61,23 @@ export function Login() {
|
|||||||
url: window.location.href
|
url: window.location.href
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Test the API key by fetching config (skip if apiKey is empty)
|
// Test the API key by fetching config
|
||||||
if (apiKey) {
|
|
||||||
await api.getConfig();
|
await api.getConfig();
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to dashboard
|
// Navigate to dashboard
|
||||||
// The ConfigProvider will handle fetching the config
|
// The ConfigProvider will handle fetching the config
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
// Clear the API key on failure
|
// Clear the API key on failure
|
||||||
api.setApiKey('');
|
api.setApiKey('');
|
||||||
|
|
||||||
|
// Check if it's an unauthorized error
|
||||||
|
if (error.message && error.message.includes('401')) {
|
||||||
setError(t('login.invalidApiKey'));
|
setError(t('login.invalidApiKey'));
|
||||||
|
} else {
|
||||||
|
// For other errors, still allow access (restricted mode)
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import type { Config, Provider, Transformer } from '@/types';
|
|||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
private tempApiKey: string | null;
|
||||||
|
|
||||||
constructor(baseUrl: string = '/api', apiKey: string = '') {
|
constructor(baseUrl: string = '/api', apiKey: string = '') {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
// Load API key from localStorage if available
|
// Load API key from localStorage if available
|
||||||
this.apiKey = apiKey || localStorage.getItem('apiKey') || '';
|
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
|
// Update base URL
|
||||||
@@ -27,13 +30,24 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update temp API key
|
||||||
|
setTempApiKey(tempApiKey: string | null) {
|
||||||
|
this.tempApiKey = tempApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
// Create headers with API key authentication
|
// Create headers with API key authentication
|
||||||
private createHeaders(contentType: string = 'application/json'): HeadersInit {
|
private createHeaders(contentType: string = 'application/json'): HeadersInit {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'X-API-Key': this.apiKey,
|
|
||||||
'Accept': 'application/json',
|
'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) {
|
if (contentType) {
|
||||||
headers['Content-Type'] = contentType;
|
headers['Content-Type'] = contentType;
|
||||||
}
|
}
|
||||||
@@ -126,6 +140,22 @@ class ApiClient {
|
|||||||
return this.post<Config>('/config', config);
|
return this.post<Config>('/config', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has full access
|
||||||
|
async checkFullAccess(): Promise<boolean> {
|
||||||
|
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>('/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
|
// Get providers
|
||||||
async getProviders(): Promise<Provider[]> {
|
async getProviders(): Promise<Provider[]> {
|
||||||
return this.get<Provider[]>('/api/providers');
|
return this.get<Provider[]>('/api/providers');
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export interface Config {
|
|||||||
API_TIMEOUT_MS: string;
|
API_TIMEOUT_MS: string;
|
||||||
PROXY_URL: string;
|
PROXY_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AccessLevel = 'restricted' | 'full';
|
||||||
|
|||||||
Reference in New Issue
Block a user