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

+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;
};