Skip to content

Commit 9cd5587

Browse files
musistudioclaude
andcommitted
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 <[email protected]>
1 parent 4334f40 commit 9cd5587

File tree

10 files changed

+287
-16
lines changed

10 files changed

+287
-16
lines changed

src/cli.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,22 @@ async function main() {
215215

216216
// Get service info and open UI
217217
const serviceInfo = await getServiceInfo();
218-
const uiUrl = `${serviceInfo.endpoint}/ui/`;
218+
219+
// Generate temporary API key based on system UUID
220+
let tempApiKey = "";
221+
try {
222+
const { getTempAPIKey } = require("./utils");
223+
tempApiKey = await getTempAPIKey();
224+
} catch (error: any) {
225+
console.warn("Warning: Failed to generate temporary API key:", error.message);
226+
console.warn("Continuing without temporary API key...");
227+
}
228+
229+
// Add temporary API key as URL parameter if successfully generated
230+
const uiUrl = tempApiKey
231+
? `${serviceInfo.endpoint}/ui/?tempApiKey=${tempApiKey}`
232+
: `${serviceInfo.endpoint}/ui/`;
233+
219234
console.log(`Opening UI at ${uiUrl}`);
220235

221236
// Open URL in browser based on platform

src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,17 @@ async function run(options: RunOptions = {}) {
9393
),
9494
},
9595
});
96-
server.addHook("preHandler", apiKeyAuth(config));
96+
// Add async preHandler hook for authentication
97+
server.addHook("preHandler", async (req, reply) => {
98+
return new Promise((resolve, reject) => {
99+
const done = (err?: Error) => {
100+
if (err) reject(err);
101+
else resolve();
102+
};
103+
// Call the async auth function
104+
apiKeyAuth(config)(req, reply, done).catch(reject);
105+
});
106+
});
97107
server.addHook("preHandler", async (req, reply) => {
98108
if(req.url.startsWith("/v1/messages")) {
99109
router(req, reply, config)

src/middleware/auth.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,94 @@
11
import { FastifyRequest, FastifyReply } from "fastify";
2+
import { getTempAPIKey } from "../utils/systemUUID";
23

34
export const apiKeyAuth =
45
(config: any) =>
5-
(req: FastifyRequest, reply: FastifyReply, done: () => void) => {
6+
async (req: FastifyRequest, reply: FastifyReply, done: () => void) => {
7+
// Check for temp API key in query parameters or headers
8+
let tempApiKey = null;
9+
if (req.query && (req.query as any).tempApiKey) {
10+
tempApiKey = (req.query as any).tempApiKey;
11+
} else if (req.headers['x-temp-api-key']) {
12+
tempApiKey = req.headers['x-temp-api-key'] as string;
13+
}
14+
15+
// If temp API key is provided, validate it
16+
if (tempApiKey) {
17+
try {
18+
const expectedTempKey = await getTempAPIKey();
19+
20+
// If temp key matches, grant temporary full access
21+
if (tempApiKey === expectedTempKey) {
22+
(req as any).accessLevel = "full";
23+
(req as any).isTempAccess = true;
24+
return done();
25+
}
26+
} catch (error) {
27+
// If there's an error generating temp key, continue with normal auth
28+
console.warn("Failed to verify temporary API key:", error);
29+
}
30+
}
31+
32+
// Public endpoints that don't require authentication
633
if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) {
734
return done();
835
}
36+
937
const apiKey = config.APIKEY;
38+
const isConfigEndpoint = req.url.startsWith("/api/config");
1039

40+
// For config endpoints, we implement granular access control
41+
if (isConfigEndpoint) {
42+
// Attach access level to request for later use
43+
(req as any).accessLevel = "restricted";
44+
45+
// If no API key is set in config, allow restricted access
46+
if (!apiKey) {
47+
(req as any).accessLevel = "restricted";
48+
return done();
49+
}
50+
51+
// Check for temporary access via query parameter (for UI)
52+
if ((req as any).isTempAccess) {
53+
return done();
54+
}
55+
56+
// If API key is set, check authentication
57+
const authKey: string =
58+
req.headers.authorization || req.headers["x-api-key"];
59+
60+
if (!authKey) {
61+
(req as any).accessLevel = "restricted";
62+
return done();
63+
}
64+
65+
let token = "";
66+
if (authKey.startsWith("Bearer")) {
67+
token = authKey.split(" ")[1];
68+
} else {
69+
token = authKey;
70+
}
71+
72+
if (token !== apiKey) {
73+
(req as any).accessLevel = "restricted";
74+
return done();
75+
}
76+
77+
// Full access for authenticated users
78+
(req as any).accessLevel = "full";
79+
return done();
80+
}
81+
82+
// For non-config endpoints, use existing logic
1183
if (!apiKey) {
1284
return done();
1385
}
1486

87+
// Check for temporary access via query parameter (for UI)
88+
if ((req as any).isTempAccess) {
89+
return done();
90+
}
91+
1592
const authKey: string =
1693
req.headers.authorization || req.headers["x-api-key"];
1794
if (!authKey) {

src/server.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,18 @@ import fastifyStatic from "@fastify/static";
88
export const createServer = (config: any): Server => {
99
const server = new Server(config);
1010

11-
// Add endpoint to read config.json
12-
server.app.get("/api/config", async () => {
11+
// Add endpoint to read config.json with access control
12+
server.app.get("/api/config", async (req, reply) => {
13+
// Get access level from request (set by auth middleware)
14+
const accessLevel = (req as any).accessLevel || "restricted";
15+
16+
// If restricted access, return 401
17+
if (accessLevel === "restricted") {
18+
reply.status(401).send("API key required to access configuration");
19+
return;
20+
}
21+
22+
// For full access (including temp API key), return complete config
1323
return await readConfigFile();
1424
});
1525

@@ -25,8 +35,15 @@ export const createServer = (config: any): Server => {
2535
return { transformers: transformerList };
2636
});
2737

28-
// Add endpoint to save config.json
29-
server.app.post("/api/config", async (req) => {
38+
// Add endpoint to save config.json with access control
39+
server.app.post("/api/config", async (req, reply) => {
40+
// Only allow full access users to save config
41+
const accessLevel = (req as any).accessLevel || "restricted";
42+
if (accessLevel !== "full") {
43+
reply.status(403).send("Full access required to modify configuration");
44+
return;
45+
}
46+
3047
const newConfig = req.body;
3148

3249
// Backup existing config file if it exists
@@ -39,9 +56,29 @@ export const createServer = (config: any): Server => {
3956
await writeConfigFile(newConfig);
4057
return { success: true, message: "Config saved successfully" };
4158
});
59+
60+
// Add endpoint for testing full access without modifying config
61+
server.app.post("/api/config/test", async (req, reply) => {
62+
// Only allow full access users to test config access
63+
const accessLevel = (req as any).accessLevel || "restricted";
64+
if (accessLevel !== "full") {
65+
reply.status(403).send("Full access required to test configuration access");
66+
return;
67+
}
68+
69+
// Return success without modifying anything
70+
return { success: true, message: "Access granted" };
71+
});
4272

43-
// Add endpoint to restart the service
44-
server.app.post("/api/restart", async (_, reply) => {
73+
// Add endpoint to restart the service with access control
74+
server.app.post("/api/restart", async (req, reply) => {
75+
// Only allow full access users to restart service
76+
const accessLevel = (req as any).accessLevel || "restricted";
77+
if (accessLevel !== "full") {
78+
reply.status(403).send("Full access required to restart service");
79+
return;
80+
}
81+
4582
reply.send({ success: true, message: "Service restart initiated" });
4683

4784
// Restart the service after a short delay to allow response to be sent

src/utils/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
HOME_DIR,
99
PLUGINS_DIR,
1010
} from "../constants";
11+
import { getSystemUUID, generateTempAPIKey, getTempAPIKey } from "./systemUUID";
1112

1213
const ensureDir = async (dir_path: string) => {
1314
try {
@@ -135,3 +136,6 @@ export const initConfig = async () => {
135136
Object.assign(process.env, config);
136137
return config;
137138
};
139+
140+
// 导出系统UUID相关函数
141+
export { getSystemUUID, generateTempAPIKey, getTempAPIKey };

src/utils/systemUUID.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { execSync } from "child_process";
2+
import { createHash } from "crypto";
3+
import os from "os";
4+
5+
/**
6+
* 跨平台获取系统UUID
7+
* @returns 系统UUID字符串
8+
*/
9+
export async function getSystemUUID(): Promise<string> {
10+
const platform = os.platform();
11+
12+
try {
13+
let uuid: string;
14+
15+
switch (platform) {
16+
case "win32": // Windows
17+
uuid = execSync("wmic csproduct get UUID", { encoding: "utf8" })
18+
.split("\n")[1]
19+
.trim();
20+
break;
21+
22+
case "darwin": // macOS
23+
uuid = execSync(
24+
"system_profiler SPHardwareDataType | grep 'Hardware UUID'",
25+
{ encoding: "utf8" }
26+
)
27+
.split(":")[1]
28+
.trim();
29+
break;
30+
31+
case "linux": // Linux
32+
// 尝试使用 dmidecode (需要 root 权限)
33+
try {
34+
uuid = execSync("dmidecode -s system-uuid", { encoding: "utf8" }).trim();
35+
} catch (dmidecodeError) {
36+
// 如果 dmidecode 失败,尝试读取 sysfs (不需要 root 权限,但可能没有权限)
37+
try {
38+
uuid = execSync("cat /sys/class/dmi/id/product_uuid", { encoding: "utf8" }).trim();
39+
} catch (sysfsError) {
40+
throw new Error("无法在Linux系统上获取系统UUID,可能需要root权限");
41+
}
42+
}
43+
break;
44+
45+
default:
46+
throw new Error(`不支持的操作系统: ${platform}`);
47+
}
48+
49+
return uuid;
50+
} catch (error) {
51+
throw new Error(`获取系统UUID失败: ${error instanceof Error ? error.message : String(error)}`);
52+
}
53+
}
54+
55+
/**
56+
* 基于系统UUID生成固定的临时API密钥
57+
* @param systemUUID 系统UUID
58+
* @returns 生成的API密钥
59+
*/
60+
export function generateTempAPIKey(systemUUID: string): string {
61+
// 使用SHA-256哈希算法确保一致性
62+
const hash = createHash("sha256");
63+
hash.update(systemUUID);
64+
// 添加盐值以增加安全性
65+
hash.update("claude-code-router-temp-key-salt");
66+
// 生成32字符的十六进制字符串
67+
return hash.digest("hex").substring(0, 32);
68+
}
69+
70+
/**
71+
* 获取临时API密钥(完整的便利函数)
72+
* @returns 临时API密钥
73+
*/
74+
export async function getTempAPIKey(): Promise<string> {
75+
const uuid = await getSystemUUID();
76+
return generateTempAPIKey(uuid);
77+
}

ui/src/components/ConfigProvider.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
128128
fetchConfig();
129129
}, [hasFetched, apiKey]);
130130

131+
// Check if user has full access
132+
useEffect(() => {
133+
const checkAccess = async () => {
134+
if (config) {
135+
const hasFullAccess = await api.checkFullAccess();
136+
// Store access level in a global state or context if needed
137+
// For now, we'll just log it
138+
console.log('User has full access:', hasFullAccess);
139+
}
140+
};
141+
142+
checkAccess();
143+
}, [config]);
144+
131145
return (
132146
<ConfigContext.Provider value={{ config, setConfig, error }}>
133147
{children}

ui/src/components/Login.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,23 @@ export function Login() {
6161
url: window.location.href
6262
}));
6363

64-
// Test the API key by fetching config (skip if apiKey is empty)
65-
if (apiKey) {
66-
await api.getConfig();
67-
}
64+
// Test the API key by fetching config
65+
await api.getConfig();
6866

6967
// Navigate to dashboard
7068
// The ConfigProvider will handle fetching the config
7169
navigate('/dashboard');
72-
} catch {
70+
} catch (error: any) {
7371
// Clear the API key on failure
7472
api.setApiKey('');
75-
setError(t('login.invalidApiKey'));
73+
74+
// Check if it's an unauthorized error
75+
if (error.message && error.message.includes('401')) {
76+
setError(t('login.invalidApiKey'));
77+
} else {
78+
// For other errors, still allow access (restricted mode)
79+
navigate('/dashboard');
80+
}
7681
}
7782
};
7883

0 commit comments

Comments
 (0)