refactor(auth): 系统安全性增强

- 移除管理员凭证存储逻辑,改用 Cookie 管理会话
- 清理 admin-auth 中的存储函数,使用 Cookie 进行会话管理
- 更新 apiClient 请求逻辑,支持管理员 Cookie
- 修改 auth store,调整登录和验证逻辑
- 添加 Cookie 处理函数,确保安全性和兼容性
- 更新测试脚本,验证 Cookie 功能
This commit is contained in:
Jeff Gu committed 2026-04-14 16:32:07 +08:00
1 parent e46bcf0248
commit 8336649477
16 files changed
+415 -143

No files matched your search

+8 -8
View File
@@ -14,7 +14,7 @@ import {
UsageLogFilters,
UsageLogSearchData,
} from '@/types'
import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth'
import { clearAdminCredentials } from '@/lib/admin-auth'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
@@ -37,6 +37,8 @@ type RequestBehavior = {
skipUnauthorizedHandler?: boolean
}
const isAdminEndpoint = (endpoint: string): boolean => endpoint.startsWith('/api/admin/')
export function setErrorHandlers(handlers: {
onUnauthorized?: () => void
onError?: (error: ApiError) => void
@@ -54,12 +56,6 @@ async function requestInterceptor(config: RequestInit): Promise<RequestInit> {
headers.set('Content-Type', 'application/json')
}
// 添加 admin token
const credential = getStoredAdminCredential()
if (credential) {
headers.set(credential.headerName, credential.token)
}
return { ...config, headers }
}
@@ -165,7 +161,10 @@ async function request<T>(
try {
const config = await requestInterceptor(options)
const response = await fetch(url, config)
const requestConfig = isAdminEndpoint(endpoint)
? { ...config, credentials: 'include' as const }
: config
const response = await fetch(url, requestConfig)
return responseInterceptor(response, behavior)
} catch (error) {
// 处理网络错误
@@ -291,6 +290,7 @@ export const apiClient = {
const url = `${BASE_URL}${endpoint}`
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
+5 -52
View File
@@ -1,59 +1,10 @@
export const ADMIN_SESSION_TOKEN_KEY = "adminSessionToken";
export const LEGACY_ADMIN_TOKEN_KEY = "adminToken";
export const ADMIN_SESSION_HEADER = "x-admin-session";
export const LEGACY_ADMIN_TOKEN_HEADER = "x-admin-token";
const canUseLocalStorage = (): boolean => {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
};
export const getStoredAdminSessionToken = (): string | null => {
if (!canUseLocalStorage()) {
return null;
}
const value = window.localStorage.getItem(ADMIN_SESSION_TOKEN_KEY);
return value && value.trim().length > 0 ? value.trim() : null;
};
export const getStoredLegacyAdminToken = (): string | null => {
if (!canUseLocalStorage()) {
return null;
}
const value = window.localStorage.getItem(LEGACY_ADMIN_TOKEN_KEY);
return value && value.trim().length > 0 ? value.trim() : null;
};
export const getStoredAdminCredential = (): { headerName: string; token: string } | null => {
const sessionToken = getStoredAdminSessionToken();
if (sessionToken) {
return {
headerName: ADMIN_SESSION_HEADER,
token: sessionToken,
};
}
const legacyToken = getStoredLegacyAdminToken();
if (legacyToken) {
return {
headerName: LEGACY_ADMIN_TOKEN_HEADER,
token: legacyToken,
};
}
return null;
};
export const storeAdminSessionToken = (token: string): void => {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY);
window.localStorage.setItem(ADMIN_SESSION_TOKEN_KEY, token);
};
export const clearAdminCredentials = (): void => {
if (!canUseLocalStorage()) {
return;
@@ -64,7 +15,9 @@ export const clearAdminCredentials = (): void => {
};
export const getAdminCredentialScopeToken = (): string => {
return getStoredAdminSessionToken()
|| getStoredLegacyAdminToken()
|| "anonymous";
if (typeof window === "undefined") {
return "server";
}
return window.location.origin || "anonymous";
};
+9 -16
View File
@@ -2,11 +2,7 @@ import { create } from 'zustand'
import { apiClient } from '@/api/client'
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
import { type AdminLoginResponse } from '@/types'
import {
clearAdminCredentials,
getStoredAdminCredential,
storeAdminSessionToken,
} from '@/lib/admin-auth'
import { clearAdminCredentials } from '@/lib/admin-auth'
interface AuthState {
isAuthenticated: boolean
@@ -38,13 +34,14 @@ export const useAuthStore = create<AuthState>((set) => ({
throw new Error('登录响应无效')
}
if (loginResult.sessionToken) {
storeAdminSessionToken(loginResult.sessionToken)
clearAdminCredentials()
if (!loginResult.requiresVerification) {
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
return loginResult
}
if (!loginResult.requiresVerification || !loginResult.challengeId) {
if (!loginResult.challengeId) {
throw new Error('登录响应缺少验证码挑战信息')
}
@@ -68,11 +65,11 @@ export const useAuthStore = create<AuthState>((set) => ({
const response = await apiClient.verifyAdminLogin(challengeId, code)
const loginResult = response.data as AdminLoginResponse
if (!loginResult.sessionToken) {
throw new Error('登录响应缺少会话令牌')
if (!loginResult || loginResult.requiresVerification) {
throw new Error('验证码验证后未建立会话')
}
storeAdminSessionToken(loginResult.sessionToken)
clearAdminCredentials()
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
} catch (error) {
clearAdminCredentials()
@@ -99,13 +96,9 @@ export const useAuthStore = create<AuthState>((set) => ({
},
checkAuth: async () => {
if (!getStoredAdminCredential()) {
set({ isAuthenticated: false, isLoading: false })
return
}
set({ isLoading: true })
try {
clearAdminCredentials()
await apiClient.checkAuth()
set({ isAuthenticated: true, isLoading: false })
} catch (error) {
+9 -4
View File
@@ -5,11 +5,13 @@ import { z } from "zod";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config";
import {
clearAdminSessionCookie,
createAdminLoginChallenge,
createAdminSession,
deleteAdminLoginChallenge,
getAdminSessionHeaderName,
getAdminSessionTokenFromRequest,
invalidateAdminSession,
setAdminSessionCookie,
sendAdminLoginCodeNotification,
sendAdminLoginResultNotification,
verifyAdminLoginChallenge,
@@ -36,6 +38,7 @@ const buildDirectLoginResponse = async (
c: Context<HonoCustomType>
) => {
const session = await createAdminSession(c);
setAdminSessionCookie(c, session.sessionToken, session.expiresAt);
return {
success: true,
@@ -43,7 +46,7 @@ const buildDirectLoginResponse = async (
requiresVerification: false,
challengeId: null,
challengeExpiresAt: null,
sessionToken: session.sessionToken,
sessionToken: null,
sessionExpiresAt: session.expiresAt,
},
message: "Login successful",
@@ -188,6 +191,7 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
}
const session = await createAdminSession(c);
setAdminSessionCookie(c, session.sessionToken, session.expiresAt);
await sendAdminLoginResultNotification(
securityConfig,
@@ -203,7 +207,7 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
requiresVerification: false,
challengeId: null,
challengeExpiresAt: null,
sessionToken: session.sessionToken,
sessionToken: null,
sessionExpiresAt: session.expiresAt,
},
message: "Login verified successfully",
@@ -222,9 +226,10 @@ export class AdminLogoutEndpoint extends OpenAPIRoute {
};
async handle(c: Context<HonoCustomType>) {
const sessionToken = c.req.header(getAdminSessionHeaderName());
const sessionToken = getAdminSessionTokenFromRequest(c);
await invalidateAdminSession(c, sessionToken);
clearAdminSessionCookie(c);
return {
success: true,
+49 -3
View File
@@ -1,10 +1,12 @@
import { Context } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
const ADMIN_SESSION_HEADER = "x-admin-session";
const ADMIN_SESSION_COOKIE_NAME = "oaw_admin_session";
const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000;
const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5;
const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai";
const LOCAL_DEV_HOSTNAMES = new Set(["0.0.0.0", "127.0.0.1", "::1", "localhost"]);
type LoginRequestMetadata = {
clientIp: string;
@@ -144,8 +146,52 @@ const buildBaseMessageLines = (
];
};
export const getAdminSessionHeaderName = (): string => {
return ADMIN_SESSION_HEADER;
const isSecureCookieRequest = (c: Context<HonoCustomType>): boolean => {
const requestUrl = new URL(c.req.url);
return requestUrl.protocol === "https:" && !LOCAL_DEV_HOSTNAMES.has(requestUrl.hostname);
};
const buildAdminSessionCookieOptions = (
c: Context<HonoCustomType>,
expiresAt?: string
) => {
return {
httpOnly: true,
maxAge: Math.floor(ADMIN_SESSION_TTL_MS / 1000),
path: "/api/admin",
sameSite: "Lax" as const,
secure: isSecureCookieRequest(c),
...(expiresAt ? { expires: new Date(expiresAt) } : {}),
};
};
export const getAdminSessionTokenFromRequest = (
c: Context<HonoCustomType>
): string | null => {
return getCookie(c, ADMIN_SESSION_COOKIE_NAME) || null;
};
export const setAdminSessionCookie = (
c: Context<HonoCustomType>,
sessionToken: string,
expiresAt: string
): void => {
setCookie(
c,
ADMIN_SESSION_COOKIE_NAME,
sessionToken,
buildAdminSessionCookieOptions(c, expiresAt)
);
};
export const clearAdminSessionCookie = (
c: Context<HonoCustomType>
): void => {
deleteCookie(
c,
ADMIN_SESSION_COOKIE_NAME,
buildAdminSessionCookieOptions(c)
);
};
export const createAdminSession = async (
+13 -5
View File
@@ -32,7 +32,11 @@ import {
UsageLogSearchEndpoint,
} from "./analytics_api"
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared"
import {
clearAdminSessionCookie,
getAdminSessionTokenFromRequest,
validateAdminSession,
} from "./auth_shared"
const app = new Hono<HonoCustomType>()
export const api = fromHono(app)
@@ -54,11 +58,15 @@ app.use('/api/admin/*', async (c, next) => {
return;
}
const sessionToken = c.req.header(getAdminSessionHeaderName());
const sessionToken = getAdminSessionTokenFromRequest(c);
if (await validateAdminSession(c, sessionToken)) {
await next();
return;
if (sessionToken) {
if (await validateAdminSession(c, sessionToken)) {
await next();
return;
}
clearAdminSessionCookie(c);
}
const systemConfig = await getSystemConfig(c);
+10 -5
View File
@@ -1,6 +1,10 @@
import { Context } from "hono"
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
import { buildAzureTargetUrl } from "./shared/azure-target-url"
import {
buildUpstreamRequestHeaders,
OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
const buildProxyRequest = (
request: Request,
@@ -10,11 +14,12 @@ const buildProxyRequest = (
const targetUrl = buildAzureTargetUrl(request, config.endpoint)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Authorization")
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("api-key", apiKey)
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
"api-key": apiKey,
},
})
return new Request(targetUrl, {
method: request.method,
+10 -5
View File
@@ -4,6 +4,10 @@ import {
checkoutResponsesUsageData,
} from "./shared/responses-stream-utils"
import { buildAzureTargetUrl } from "./shared/azure-target-url"
import {
buildUpstreamRequestHeaders,
OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
const buildProxyRequest = (
request: Request,
@@ -13,11 +17,12 @@ const buildProxyRequest = (
const targetUrl = buildAzureTargetUrl(request, config.endpoint)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Authorization")
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("api-key", apiKey)
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
"api-key": apiKey,
},
})
return new Request(targetUrl, {
method: request.method,
+12 -15
View File
@@ -1,6 +1,10 @@
import { Context } from "hono"
import { DEFAULT_CLAUDE_API_VERSION } from "../channel-config"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
import {
buildUpstreamRequestHeaders,
CLAUDE_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
/**
* Claude API Proxy Provider
@@ -44,11 +48,7 @@ type ClaudeStreamEvent = {
}
/**
* Builds the proxy request for Claude API
* - Removes Authorization header (client may send OpenAI format)
* - Sets x-api-key header (Claude uses this instead of Authorization)
* - Sets anthropic-version header (required by Claude API)
* - Preserves all other headers from original request
* Builds the proxy request for Claude API with an allowlisted header set.
*/
const buildProxyRequest = (
request: Request,
@@ -59,16 +59,13 @@ const buildProxyRequest = (
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
// Remove Authorization header (client may send OpenAI format: "Authorization: Bearer sk-xxx")
targetHeaders.delete("Authorization")
targetHeaders.delete("x-api-key")
// Claude uses x-api-key header instead of Authorization
targetHeaders.set("x-api-key", apiKey)
// Claude requires anthropic-version header
targetHeaders.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION)
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: CLAUDE_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
"anthropic-version": DEFAULT_CLAUDE_API_VERSION,
"x-api-key": apiKey,
},
})
return new Request(targetUrl, {
method: request.method,
+10 -5
View File
@@ -1,6 +1,10 @@
import { Context } from "hono"
import { normalizeUsage as normalizeResponsesUsage } from "./shared/usage-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
import {
buildUpstreamRequestHeaders,
OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
type OpenAIStreamChoice = {
index?: number;
@@ -80,12 +84,13 @@ const buildProxyRequest = (
config: ChannelConfig
): Request => {
const targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/chat/completions");
const targetHeaders = new Headers(request.headers);
const apiKey = config.api_key || "";
targetHeaders.delete("Authorization");
targetHeaders.delete("x-api-key");
targetHeaders.set("Authorization", `Bearer ${apiKey}`);
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
Authorization: `Bearer ${apiKey}`,
},
});
return new Request(targetUrl, {
method: request.method,
+10 -4
View File
@@ -1,6 +1,10 @@
import { Context } from "hono"
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
import {
buildUpstreamRequestHeaders,
OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
const buildProxyRequest = (
request: Request,
@@ -11,10 +15,12 @@ const buildProxyRequest = (
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname, "/v1", config.type)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("Authorization", `Bearer ${apiKey}`)
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
Authorization: `Bearer ${apiKey}`,
},
})
return new Request(targetUrl, {
method: request.method,
+10 -4
View File
@@ -4,6 +4,10 @@ import {
checkoutResponsesUsageData,
} from "./shared/responses-stream-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
import {
buildUpstreamRequestHeaders,
OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
} from "./shared/upstream-request-headers"
const buildProxyRequest = (
request: Request,
@@ -14,10 +18,12 @@ const buildProxyRequest = (
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("Authorization", `Bearer ${apiKey}`)
const targetHeaders = buildUpstreamRequestHeaders(request, {
allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST,
overrideHeaders: {
Authorization: `Bearer ${apiKey}`,
},
})
return new Request(targetUrl, {
method: request.method,
@@ -0,0 +1,86 @@
const BLOCKED_HEADER_NAMES = new Set([
"api-key",
"authorization",
"content-length",
"cookie",
"host",
"true-client-ip",
"x-admin-session",
"x-admin-token",
"x-api-key",
"x-real-ip",
]);
const BLOCKED_HEADER_PREFIXES = [
"cf-",
"x-forwarded-",
];
const normalizeHeaderName = (value: string): string => {
return value.trim().toLowerCase();
};
const shouldForwardHeader = (
name: string,
allowlist: Set<string>
): boolean => {
if (!allowlist.has(name)) {
return false;
}
if (BLOCKED_HEADER_NAMES.has(name)) {
return false;
}
return !BLOCKED_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix));
};
type BuildUpstreamHeadersOptions = {
allowHeaders: string[];
overrideHeaders?: Record<string, string | null | undefined>;
};
export const OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST = [
"accept",
"accept-encoding",
"content-type",
"idempotency-key",
"openai-beta",
];
export const CLAUDE_UPSTREAM_HEADER_ALLOWLIST = [
"accept",
"accept-encoding",
"anthropic-beta",
"content-type",
"idempotency-key",
];
export const buildUpstreamRequestHeaders = (
request: Request,
options: BuildUpstreamHeadersOptions
): Headers => {
const allowlist = new Set(options.allowHeaders.map(normalizeHeaderName));
const headers = new Headers();
for (const [name, value] of request.headers.entries()) {
const normalizedName = normalizeHeaderName(name);
if (!value || !shouldForwardHeader(normalizedName, allowlist)) {
continue;
}
headers.set(normalizedName, value);
}
for (const [name, value] of Object.entries(options.overrideHeaders || {})) {
const normalizedName = normalizeHeaderName(name);
headers.delete(normalizedName);
if (typeof value === "string" && value.length > 0) {
headers.set(normalizedName, value);
}
}
return headers;
};
+68
View File
@@ -5,6 +5,56 @@
*/
const PORT = 9999
const INSPECTABLE_REQUEST_KEYS = ["chat-completions", "messages", "models", "responses"] as const
type InspectableRequestKey = typeof INSPECTABLE_REQUEST_KEYS[number]
type CapturedRequest = {
body: unknown
headers: Record<string, string>
method: string
path: string
}
const capturedRequests = new Map<InspectableRequestKey, CapturedRequest>()
const toHeaderRecord = (headers: Headers): Record<string, string> => {
return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [key.toLowerCase(), value]))
}
const getCaptureKey = (path: string): InspectableRequestKey | null => {
if (path.endsWith("/chat/completions")) {
return "chat-completions"
}
if (path.endsWith("/messages")) {
return "messages"
}
if (path.endsWith("/responses")) {
return "responses"
}
if (path.endsWith("/models")) {
return "models"
}
return null
}
const rememberRequest = (req: Request, path: string, body: unknown) => {
const captureKey = getCaptureKey(path)
if (!captureKey) {
return
}
capturedRequests.set(captureKey, {
body,
headers: toHeaderRecord(req.headers),
method: req.method,
path,
})
}
// --- OpenAI Chat Completions (non-stream) ---
const openaiChatResponse = (model: string) => ({
@@ -87,11 +137,29 @@ const server = Bun.serve({
async fetch(req) {
const url = new URL(req.url)
const path = url.pathname
const inspectKey = url.searchParams.get("key")
if (path === "/__reset" && req.method === "POST") {
capturedRequests.clear()
return Response.json({ ok: true })
}
if (path === "/__inspect" && req.method === "GET") {
if (!inspectKey || !INSPECTABLE_REQUEST_KEYS.includes(inspectKey as InspectableRequestKey)) {
return Response.json({
keys: INSPECTABLE_REQUEST_KEYS,
}, { status: 400 })
}
return Response.json(capturedRequests.get(inspectKey as InspectableRequestKey) || null)
}
const body = req.method === "POST" ? await req.json().catch(() => ({})) : {}
const model = (body as any).model || "mock-model"
const isStream = (body as any).stream === true
console.log(`[MOCK] ${req.method} ${path} model=${model} stream=${isStream}`)
rememberRequest(req, path, body)
// OpenAI Chat Completions
if (path.endsWith("/chat/completions")) {
+90 -8
View File
@@ -2,7 +2,11 @@
# E2E tests against mock upstream
# Prerequisites: mock-upstream running on :9999, wrangler dev on :8788
API="http://localhost:8788"
API="${API:-http://localhost:8787}"
MOCK_API="${MOCK_API:-http://localhost:9999}"
WRANGLER_CONFIG="${WRANGLER_CONFIG:-wrangler.local.jsonc}"
DB_NAME="${DB_NAME:-one-api}"
DB_EXEC=(npx wrangler d1 execute "$DB_NAME" --local --config "$WRANGLER_CONFIG" --command)
PASS=0
FAIL=0
TOTAL=0
@@ -21,6 +25,20 @@ assert() {
fi
}
assert_not_contains() {
local name="$1" unexpected="$2" actual="$3"
TOTAL=$((TOTAL + 1))
if echo "$actual" | grep -q "$unexpected"; then
echo " ❌ #$TOTAL $name"
echo " unexpected: $unexpected"
echo " actual: $(echo "$actual" | head -1)"
FAIL=$((FAIL + 1))
else
echo " ✅ #$TOTAL $name"
PASS=$((PASS + 1))
fi
}
assert_http() {
local name="$1" expected_code="$2" expected_body="$3" actual="$4"
local code=$(echo "$actual" | tail -1)
@@ -39,6 +57,15 @@ assert_http() {
do_get() { curl -s -w "\n%{http_code}" "$@"; }
do_post() { curl -s -w "\n%{http_code}" -X POST "$@"; }
reset_mock_inspection() { curl -s -X POST "$MOCK_API/__reset" > /dev/null; }
get_mock_inspection() { curl -sG "$MOCK_API/__inspect" --data-urlencode "key=$1"; }
if [[ -f ".dev.vars" ]]; then
set -a
# shellcheck disable=SC1091
source ".dev.vars"
set +a
fi
echo ""
echo "========================================="
@@ -78,10 +105,10 @@ R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H
assert_http "no model → 400" "400" "Model is required" "$R"
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"nonexistent","messages":[]}')
assert_http "unmapped model → 400" "400" "Model not mapped" "$R"
assert_http "unmapped model → 400" "400" "Model not supported" "$R"
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","messages":[]}')
assert_http "limited token, wrong model → 400" "400" "Model not mapped" "$R"
assert_http "limited token, wrong model → 400" "400" "Model not supported" "$R"
# ---- /v1/chat/completions via openai provider (non-stream) ----
echo ""
@@ -97,6 +124,22 @@ echo "--- POST /v1/chat/completions (openai, stream) ---"
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}')
assert_http "openai stream → 200 + SSE" "200" "data:" "$R"
reset_mock_inspection
R=$(do_post \
-H "Authorization: Bearer sk-mock-openai" \
-H "x-api-key: leaked-client-key" \
-H "Cookie: session=leak-me" \
-H "X-Forwarded-For: 203.0.113.10" \
"$API/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"header check"}]}')
assert_http "openai header whitelist → 200" "200" "Mock response" "$R"
INSPECT=$(get_mock_inspection "chat-completions")
assert "openai upstream auth replaced with channel key" "\"authorization\":\"Bearer mock-key\"" "$INSPECT"
assert_not_contains "openai upstream strips client x-api-key" "leaked-client-key" "$INSPECT"
assert_not_contains "openai upstream strips cookie" "\"cookie\":" "$INSPECT"
assert_not_contains "openai upstream strips x-forwarded-for" "\"x-forwarded-for\":" "$INSPECT"
# ---- /v1/chat/completions via azure-openai (non-stream) ----
echo ""
echo "--- POST /v1/chat/completions (azure-openai, non-stream) ---"
@@ -125,6 +168,21 @@ echo "--- POST /v1/messages (claude, stream) ---"
R=$(do_post -H "x-api-key: sk-mock-all" "$API/v1/messages" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"hi"}]}')
assert_http "claude stream → 200 + SSE" "200" "event:" "$R"
reset_mock_inspection
R=$(do_post \
-H "x-api-key: sk-mock-all" \
-H "Cookie: session=leak-me" \
-H "X-Forwarded-For: 203.0.113.11" \
"$API/v1/messages" \
-H "Content-Type: application/json" \
-d '{"model":"claude-3-opus","max_tokens":100,"messages":[{"role":"user","content":"header check"}]}')
assert_http "claude header whitelist → 200" "200" "Mock Claude response" "$R"
INSPECT=$(get_mock_inspection "messages")
assert "claude upstream x-api-key replaced with channel key" "\"x-api-key\":\"mock-key\"" "$INSPECT"
assert_not_contains "claude upstream strips client token value" "sk-mock-all" "$INSPECT"
assert_not_contains "claude upstream strips cookie" "\"cookie\":" "$INSPECT"
assert_not_contains "claude upstream strips x-forwarded-for" "\"x-forwarded-for\":" "$INSPECT"
# ---- /v1/messages via claude-to-openai (non-stream) ----
echo ""
echo "--- POST /v1/messages (claude-to-openai, non-stream) ---"
@@ -140,7 +198,7 @@ R=$(do_post "$API/v1/responses" -H "Content-Type: application/json" -d '{"model"
assert_http "no auth → 401" "401" "Authorization header" "$R"
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o","input":"hi"}')
assert_http "non-responses model → 400" "400" "Model not mapped" "$R"
assert_http "non-responses model → 400" "400" "Model not supported" "$R"
# ---- /v1/responses via openai-responses (non-stream) ----
echo ""
@@ -174,10 +232,34 @@ assert_http "azure responses stream → 200 + SSE" "200" "event:" "$R"
echo ""
echo "--- Quota check ---"
npx wrangler d1 execute one-api-workers --local --command "UPDATE api_token SET usage = 99999999 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
"${DB_EXEC[@]}" "UPDATE api_token SET usage = 99999999 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[]}')
assert_http "quota exceeded → 402" "402" "Quota exceeded" "$R"
npx wrangler d1 execute one-api-workers --local --command "UPDATE api_token SET usage = 0 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
"${DB_EXEC[@]}" "UPDATE api_token SET usage = 0 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
# ---- Admin cookie session ----
echo ""
echo "--- Admin session cookie ---"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":false,\"telegramBotToken\":\"\",\"telegramChatId\":\"\",\"verifiedFingerprint\":\"\",\"verifiedAt\":null},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1
COOKIE_JAR=$(mktemp)
LOGIN_PAYLOAD=$(printf '{"token":"%s"}' "$ADMIN_TOKEN")
R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -c "$COOKIE_JAR" -d "$LOGIN_PAYLOAD")
assert_http "admin login → 200" "200" "\"requiresVerification\":false" "$R"
assert "admin login response keeps session token out of body" "\"sessionToken\":null" "$R"
assert "admin login sets session cookie" "oaw_admin_session" "$(cat "$COOKIE_JAR")"
R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel")
assert_http "admin cookie grants access" "200" "\"success\":true" "$R"
R=$(do_post -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$API/api/admin/auth/logout")
assert_http "admin logout → 200" "200" "\"success\":true" "$R"
R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel")
assert_http "admin cookie invalid after logout" "401" "Unauthorized" "$R"
rm -f "$COOKIE_JAR"
# ---- Error recovery ----
echo ""
@@ -197,10 +279,10 @@ echo "========================================="
echo ""
echo "Cleaning up test data..."
for key in test-openai test-azure test-claude test-c2o test-responses test-azure-resp; do
npx wrangler d1 execute one-api-workers --local --command "DELETE FROM channel_config WHERE key = '$key'" > /dev/null 2>&1
"${DB_EXEC[@]}" "DELETE FROM channel_config WHERE key = '$key'" > /dev/null 2>&1
done
for key in sk-mock-all sk-mock-openai sk-mock-quota; do
npx wrangler d1 execute one-api-workers --local --command "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1
"${DB_EXEC[@]}" "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1
done
echo "Cleanup done."
+16 -9
View File
@@ -3,34 +3,41 @@
# All channels point to http://localhost:9999
MOCK="http://localhost:9999"
API_URL="${API_URL:-http://localhost:8787}"
WRANGLER_CONFIG="${WRANGLER_CONFIG:-wrangler.local.jsonc}"
DB_NAME="${DB_NAME:-one-api}"
DB_EXEC=(npx wrangler d1 execute "$DB_NAME" --local --config "$WRANGLER_CONFIG" --command)
echo "Setting up test channels and tokens..."
# Warm up the worker so schema migrations run against the same local D1 database.
curl -s "$API_URL/api/admin/channel" > /dev/null 2>&1 || true
# Channel: openai type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')"
# Channel: azure-openai type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure', '{\"name\":\"test-azure\",\"type\":\"azure-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2024-02-01\",\"deployment_mapper\":{\"gpt-4o-azure\":\"gpt-4o-deployment\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure', '{\"name\":\"test-azure\",\"type\":\"azure-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2024-02-01\",\"deployment_mapper\":{\"gpt-4o-azure\":\"gpt-4o-deployment\"}}')"
# Channel: claude type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-claude', '{\"name\":\"test-claude\",\"type\":\"claude\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-3-opus\":\"claude-3-opus-20240229\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-claude', '{\"name\":\"test-claude\",\"type\":\"claude\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-3-opus\":\"claude-3-opus-20240229\"}}')"
# Channel: claude-to-openai type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-c2o', '{\"name\":\"test-c2o\",\"type\":\"claude-to-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-via-openai\":\"claude-3-opus\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-c2o', '{\"name\":\"test-c2o\",\"type\":\"claude-to-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-via-openai\":\"claude-3-opus\"}}')"
# Channel: openai-responses type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-responses', '{\"name\":\"test-responses\",\"type\":\"openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o-resp\":\"gpt-4o\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-responses', '{\"name\":\"test-responses\",\"type\":\"openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o-resp\":\"gpt-4o\"}}')"
# Channel: azure-openai-responses type
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure-resp', '{\"name\":\"test-azure-resp\",\"type\":\"azure-openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2025-01-01\",\"deployment_mapper\":{\"gpt-4o-azure-resp\":\"gpt-4o-deployment\"}}')"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure-resp', '{\"name\":\"test-azure-resp\",\"type\":\"azure-openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2025-01-01\",\"deployment_mapper\":{\"gpt-4o-azure-resp\":\"gpt-4o-deployment\"}}')"
# Token: access to all test channels only
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-all', '{\"name\":\"mock-all\",\"channel_keys\":[\"test-openai\",\"test-azure\",\"test-claude\",\"test-c2o\",\"test-responses\",\"test-azure-resp\"],\"total_quota\":99999999}', 0)"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-all', '{\"name\":\"mock-all\",\"channel_keys\":[\"test-openai\",\"test-azure\",\"test-claude\",\"test-c2o\",\"test-responses\",\"test-azure-resp\"],\"total_quota\":99999999}', 0)"
# Token: limited to openai only
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-openai', '{\"name\":\"mock-openai\",\"channel_keys\":[\"test-openai\"],\"total_quota\":99999999}', 0)"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-openai', '{\"name\":\"mock-openai\",\"channel_keys\":[\"test-openai\"],\"total_quota\":99999999}', 0)"
# Token: quota almost full
npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-quota', '{\"name\":\"mock-quota\",\"channel_keys\":[\"test-openai\"],\"total_quota\":100}', 99)"
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-quota', '{\"name\":\"mock-quota\",\"channel_keys\":[\"test-openai\"],\"total_quota\":100}', 99)"
echo "Test data setup complete!"