refactor(auth): 系统安全性增强
- 移除管理员凭证存储逻辑,改用 Cookie 管理会话 - 清理 admin-auth 中的存储函数,使用 Cookie 进行会话管理 - 更新 apiClient 请求逻辑,支持管理员 Cookie - 修改 auth store,调整登录和验证逻辑 - 添加 Cookie 处理函数,确保安全性和兼容性 - 更新测试脚本,验证 Cookie 功能
This commit is contained in:
1 parent
e46bcf0248
commit
8336649477
16 files changed
+415
-143
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user