Spaces:
Paused
feat: dashboard login gate for public deployments (#148)
Browse files* feat: dashboard login gate for public deployments (#141)
Cookie-based session auth when proxy_api_key is set and request is non-localhost.
API routes (/v1/*) unaffected, Electron (localhost) auto-bypassed.
- In-memory session store with TTL from session.ttl_minutes config
- Dashboard auth middleware with path-based allowlist
- Login/logout/status endpoints with per-IP brute-force protection
- HTTPS auto-detection: Secure flag on cookie when X-Forwarded-Proto: https
- Remote session cannot clear proxy_api_key (prevents disabling login gate)
- Frontend LoginGate component with i18n (en/zh), conditional logout button
* fix: dashboard login security hardening
- Timing-safe password comparison (crypto.timingSafeEqual)
- Rate limiter cleanup of expired entries to prevent memory leak
- Extract shared parseSessionCookie to src/utils/parse-cookie.ts
- Remove silent password.trim() that could cause subtle mismatches
- Logout button visible on mobile (icon-only on small screens)
- Fix fragile cleanup timer test (use vi.setSystemTime consistently)
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +8 -0
- shared/hooks/use-dashboard-auth.ts +60 -0
- shared/i18n/translations.ts +14 -0
- src/auth/__tests__/dashboard-session.test.ts +87 -0
- src/auth/dashboard-session.ts +81 -0
- src/index.ts +9 -0
- src/middleware/__tests__/dashboard-auth.test.ts +152 -0
- src/middleware/dashboard-auth.ts +50 -0
- src/routes/__tests__/dashboard-login.test.ts +303 -0
- src/routes/admin/health.ts +3 -2
- src/routes/admin/settings.ts +11 -0
- src/routes/dashboard-login.ts +144 -0
- src/utils/__tests__/is-localhost.test.ts +24 -0
- src/utils/is-localhost.ts +6 -0
- src/utils/parse-cookie.ts +8 -0
- web/src/App.tsx +77 -2
- web/src/components/Header.tsx +14 -1
|
@@ -14,6 +14,14 @@
|
|
| 14 |
|
| 15 |
### Added
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
- 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
|
| 18 |
- Dashboard 卡片/表格显示玫红色 `Banned`/`已封禁` 状态徽章
|
| 19 |
- 状态筛选下拉新增 `Banned` 选项
|
|
|
|
| 14 |
|
| 15 |
### Added
|
| 16 |
|
| 17 |
+
- Dashboard 登录门(#141):当 `proxy_api_key` 已配置且请求来自非 localhost 时,需输入密码才能访问控制台
|
| 18 |
+
- Cookie-based session,TTL 由 `session.ttl_minutes` 控制(默认 60 分钟)
|
| 19 |
+
- `POST /auth/dashboard-login`、`POST /auth/dashboard-logout`、`GET /auth/dashboard-status` 端点
|
| 20 |
+
- API 路由(`/v1/*`)不受影响,Electron(localhost)自动跳过
|
| 21 |
+
- 简单防暴力:同 IP 5 次/60s 限制
|
| 22 |
+
- HTTPS 自动检测:反代 `X-Forwarded-Proto: https` 时 cookie 加 `Secure` flag
|
| 23 |
+
- 远程 session 禁止清空 `proxy_api_key`(防止误操作导致登录门失效)
|
| 24 |
+
- Header 显示条件性退出按钮
|
| 25 |
- 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
|
| 26 |
- Dashboard 卡片/表格显示玫红色 `Banned`/`已封禁` 状态徽章
|
| 27 |
- 状态筛选下拉新增 `Banned` 选项
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
|
| 3 |
+
export type DashboardAuthStatus = "loading" | "login" | "authenticated";
|
| 4 |
+
|
| 5 |
+
export function useDashboardAuth() {
|
| 6 |
+
const [status, setStatus] = useState<DashboardAuthStatus>("loading");
|
| 7 |
+
const [error, setError] = useState<string | null>(null);
|
| 8 |
+
const [isRemoteSession, setIsRemoteSession] = useState(false);
|
| 9 |
+
|
| 10 |
+
const checkStatus = useCallback(async () => {
|
| 11 |
+
try {
|
| 12 |
+
const res = await fetch("/auth/dashboard-status");
|
| 13 |
+
const data: { required: boolean; authenticated: boolean } = await res.json();
|
| 14 |
+
if (!data.required || data.authenticated) {
|
| 15 |
+
setStatus("authenticated");
|
| 16 |
+
setIsRemoteSession(data.required && data.authenticated);
|
| 17 |
+
} else {
|
| 18 |
+
setStatus("login");
|
| 19 |
+
}
|
| 20 |
+
} catch {
|
| 21 |
+
// If status endpoint fails, assume no gate (backwards compat)
|
| 22 |
+
setStatus("authenticated");
|
| 23 |
+
}
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
checkStatus();
|
| 28 |
+
}, [checkStatus]);
|
| 29 |
+
|
| 30 |
+
const login = useCallback(async (password: string) => {
|
| 31 |
+
setError(null);
|
| 32 |
+
try {
|
| 33 |
+
const res = await fetch("/auth/dashboard-login", {
|
| 34 |
+
method: "POST",
|
| 35 |
+
headers: { "Content-Type": "application/json" },
|
| 36 |
+
body: JSON.stringify({ password }),
|
| 37 |
+
});
|
| 38 |
+
if (res.ok) {
|
| 39 |
+
setStatus("authenticated");
|
| 40 |
+
setIsRemoteSession(true);
|
| 41 |
+
} else {
|
| 42 |
+
const data = await res.json();
|
| 43 |
+
setError(data.error || "Login failed");
|
| 44 |
+
}
|
| 45 |
+
} catch {
|
| 46 |
+
setError("Network error");
|
| 47 |
+
}
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const logout = useCallback(async () => {
|
| 51 |
+
try {
|
| 52 |
+
await fetch("/auth/dashboard-logout", { method: "POST" });
|
| 53 |
+
} finally {
|
| 54 |
+
setStatus("login");
|
| 55 |
+
setIsRemoteSession(false);
|
| 56 |
+
}
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
return { status, error, login, logout, isRemoteSession };
|
| 60 |
+
}
|
|
@@ -220,6 +220,13 @@ export const translations = {
|
|
| 220 |
last24h: "Last 24h",
|
| 221 |
last3d: "Last 3d",
|
| 222 |
last7d: "Last 7d",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
},
|
| 224 |
zh: {
|
| 225 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -445,6 +452,13 @@ export const translations = {
|
|
| 445 |
last24h: "最近 24h",
|
| 446 |
last3d: "最近 3 天",
|
| 447 |
last7d: "最近 7 天",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
},
|
| 449 |
} as const;
|
| 450 |
|
|
|
|
| 220 |
last24h: "Last 24h",
|
| 221 |
last3d: "Last 3d",
|
| 222 |
last7d: "Last 7d",
|
| 223 |
+
dashboardLogin: "Dashboard Login",
|
| 224 |
+
dashboardPassword: "Password",
|
| 225 |
+
dashboardLoginBtn: "Log In",
|
| 226 |
+
dashboardLoginError: "Invalid password",
|
| 227 |
+
dashboardLoginRequired: "Enter the proxy API key to access the dashboard.",
|
| 228 |
+
dashboardLogout: "Logout",
|
| 229 |
+
dashboardTooManyAttempts: "Too many attempts. Try again later.",
|
| 230 |
},
|
| 231 |
zh: {
|
| 232 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 452 |
last24h: "最近 24h",
|
| 453 |
last3d: "最近 3 天",
|
| 454 |
last7d: "最近 7 天",
|
| 455 |
+
dashboardLogin: "控制台登录",
|
| 456 |
+
dashboardPassword: "密码",
|
| 457 |
+
dashboardLoginBtn: "登录",
|
| 458 |
+
dashboardLoginError: "密码错误",
|
| 459 |
+
dashboardLoginRequired: "请输入代理 API 密钥以访问控制台。",
|
| 460 |
+
dashboardLogout: "退出登录",
|
| 461 |
+
dashboardTooManyAttempts: "尝试次数过多,请稍后再试。",
|
| 462 |
},
|
| 463 |
} as const;
|
| 464 |
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
| 2 |
+
|
| 3 |
+
vi.mock("../../config.js", () => ({
|
| 4 |
+
getConfig: vi.fn(() => ({
|
| 5 |
+
session: { ttl_minutes: 1, cleanup_interval_minutes: 1 },
|
| 6 |
+
})),
|
| 7 |
+
}));
|
| 8 |
+
|
| 9 |
+
import {
|
| 10 |
+
createSession,
|
| 11 |
+
validateSession,
|
| 12 |
+
deleteSession,
|
| 13 |
+
getSessionCount,
|
| 14 |
+
startSessionCleanup,
|
| 15 |
+
stopSessionCleanup,
|
| 16 |
+
_resetForTest,
|
| 17 |
+
} from "../dashboard-session.js";
|
| 18 |
+
|
| 19 |
+
beforeEach(() => {
|
| 20 |
+
_resetForTest();
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
afterEach(() => {
|
| 24 |
+
stopSessionCleanup();
|
| 25 |
+
_resetForTest();
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe("dashboard-session", () => {
|
| 29 |
+
it("createSession returns a session with id", () => {
|
| 30 |
+
const session = createSession();
|
| 31 |
+
expect(session.id).toBeTruthy();
|
| 32 |
+
expect(typeof session.id).toBe("string");
|
| 33 |
+
expect(session.expiresAt).toBeGreaterThan(Date.now());
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
it("validateSession returns true for valid session", () => {
|
| 37 |
+
const session = createSession();
|
| 38 |
+
expect(validateSession(session.id)).toBe(true);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it("validateSession returns false for nonexistent session", () => {
|
| 42 |
+
expect(validateSession("nonexistent")).toBe(false);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("validateSession returns false for expired session", () => {
|
| 46 |
+
const session = createSession();
|
| 47 |
+
// Manually expire it
|
| 48 |
+
vi.spyOn(Date, "now").mockReturnValue(session.expiresAt + 1);
|
| 49 |
+
expect(validateSession(session.id)).toBe(false);
|
| 50 |
+
vi.restoreAllMocks();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("deleteSession invalidates the session", () => {
|
| 54 |
+
const session = createSession();
|
| 55 |
+
expect(validateSession(session.id)).toBe(true);
|
| 56 |
+
deleteSession(session.id);
|
| 57 |
+
expect(validateSession(session.id)).toBe(false);
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("getSessionCount tracks correctly", () => {
|
| 61 |
+
expect(getSessionCount()).toBe(0);
|
| 62 |
+
createSession();
|
| 63 |
+
createSession();
|
| 64 |
+
expect(getSessionCount()).toBe(2);
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("cleanup removes expired sessions", () => {
|
| 68 |
+
vi.useFakeTimers();
|
| 69 |
+
const baseTime = Date.now();
|
| 70 |
+
|
| 71 |
+
const s1 = createSession();
|
| 72 |
+
const s2 = createSession();
|
| 73 |
+
expect(getSessionCount()).toBe(2);
|
| 74 |
+
|
| 75 |
+
// Expire both sessions (ttl_minutes=1 → 60_000ms)
|
| 76 |
+
vi.setSystemTime(baseTime + 60_001);
|
| 77 |
+
|
| 78 |
+
// Start cleanup and advance to trigger interval (cleanup_interval_minutes=1 → 60_000ms)
|
| 79 |
+
startSessionCleanup();
|
| 80 |
+
vi.advanceTimersByTime(60_001);
|
| 81 |
+
|
| 82 |
+
expect(validateSession(s1.id)).toBe(false);
|
| 83 |
+
expect(validateSession(s2.id)).toBe(false);
|
| 84 |
+
|
| 85 |
+
vi.useRealTimers();
|
| 86 |
+
});
|
| 87 |
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dashboard Session Store — in-memory session management for web dashboard login gate.
|
| 3 |
+
*
|
| 4 |
+
* Sessions are cookie-based and protect the dashboard when proxy_api_key is set
|
| 5 |
+
* and requests come from non-localhost origins. TTL is configured via session.ttl_minutes.
|
| 6 |
+
*
|
| 7 |
+
* Sessions are NOT persisted — server restart requires re-login, which is acceptable.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { randomUUID } from "crypto";
|
| 11 |
+
import { getConfig } from "../config.js";
|
| 12 |
+
|
| 13 |
+
export interface DashboardSession {
|
| 14 |
+
id: string;
|
| 15 |
+
createdAt: number;
|
| 16 |
+
expiresAt: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const sessions = new Map<string, DashboardSession>();
|
| 20 |
+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
| 21 |
+
|
| 22 |
+
export function createSession(): DashboardSession {
|
| 23 |
+
const config = getConfig();
|
| 24 |
+
const ttlMs = config.session.ttl_minutes * 60_000;
|
| 25 |
+
const now = Date.now();
|
| 26 |
+
const session: DashboardSession = {
|
| 27 |
+
id: randomUUID(),
|
| 28 |
+
createdAt: now,
|
| 29 |
+
expiresAt: now + ttlMs,
|
| 30 |
+
};
|
| 31 |
+
sessions.set(session.id, session);
|
| 32 |
+
return session;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function validateSession(id: string): boolean {
|
| 36 |
+
const session = sessions.get(id);
|
| 37 |
+
if (!session) return false;
|
| 38 |
+
if (Date.now() > session.expiresAt) {
|
| 39 |
+
sessions.delete(id);
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
return true;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function deleteSession(id: string): void {
|
| 46 |
+
sessions.delete(id);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function getSessionCount(): number {
|
| 50 |
+
return sessions.size;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function cleanupExpired(): void {
|
| 54 |
+
const now = Date.now();
|
| 55 |
+
for (const [id, session] of sessions) {
|
| 56 |
+
if (now > session.expiresAt) {
|
| 57 |
+
sessions.delete(id);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function startSessionCleanup(): void {
|
| 63 |
+
if (cleanupTimer) return;
|
| 64 |
+
const config = getConfig();
|
| 65 |
+
const intervalMs = config.session.cleanup_interval_minutes * 60_000;
|
| 66 |
+
cleanupTimer = setInterval(cleanupExpired, intervalMs);
|
| 67 |
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function stopSessionCleanup(): void {
|
| 71 |
+
if (cleanupTimer) {
|
| 72 |
+
clearInterval(cleanupTimer);
|
| 73 |
+
cleanupTimer = null;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/** Reset all sessions — for tests only. */
|
| 78 |
+
export function _resetForTest(): void {
|
| 79 |
+
sessions.clear();
|
| 80 |
+
stopSessionCleanup();
|
| 81 |
+
}
|
|
@@ -7,6 +7,7 @@ import { RefreshScheduler } from "./auth/refresh-scheduler.js";
|
|
| 7 |
import { requestId } from "./middleware/request-id.js";
|
| 8 |
import { logger } from "./middleware/logger.js";
|
| 9 |
import { errorHandler } from "./middleware/error-handler.js";
|
|
|
|
| 10 |
import { createAuthRoutes } from "./routes/auth.js";
|
| 11 |
import { createAccountRoutes } from "./routes/accounts.js";
|
| 12 |
import { createChatRoutes } from "./routes/chat.js";
|
|
@@ -26,6 +27,8 @@ import { loadStaticModels } from "./models/model-store.js";
|
|
| 26 |
import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
|
| 27 |
import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
|
| 28 |
import { UsageStatsStore } from "./auth/usage-stats.js";
|
|
|
|
|
|
|
| 29 |
|
| 30 |
export interface ServerHandle {
|
| 31 |
close: () => Promise<void>;
|
|
@@ -69,6 +72,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 69 |
app.use("*", requestId);
|
| 70 |
app.use("*", logger);
|
| 71 |
app.use("*", errorHandler);
|
|
|
|
| 72 |
|
| 73 |
// Mount routes
|
| 74 |
const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
|
|
@@ -81,6 +85,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 81 |
const usageStats = new UsageStatsStore();
|
| 82 |
const webRoutes = createWebRoutes(accountPool, usageStats);
|
| 83 |
|
|
|
|
| 84 |
app.route("/", authRoutes);
|
| 85 |
app.route("/", accountRoutes);
|
| 86 |
app.route("/", chatRoutes);
|
|
@@ -119,6 +124,9 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 119 |
}
|
| 120 |
console.log();
|
| 121 |
|
|
|
|
|
|
|
|
|
|
| 122 |
// Start background update checkers
|
| 123 |
// (Electron has its own native auto-updater — skip proxy update checker)
|
| 124 |
startUpdateChecker();
|
|
@@ -152,6 +160,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 152 |
stopProxyUpdateChecker();
|
| 153 |
stopModelRefresh();
|
| 154 |
stopQuotaRefresh();
|
|
|
|
| 155 |
refreshScheduler.destroy();
|
| 156 |
proxyPool.destroy();
|
| 157 |
cookieJar.destroy();
|
|
|
|
| 7 |
import { requestId } from "./middleware/request-id.js";
|
| 8 |
import { logger } from "./middleware/logger.js";
|
| 9 |
import { errorHandler } from "./middleware/error-handler.js";
|
| 10 |
+
import { dashboardAuth } from "./middleware/dashboard-auth.js";
|
| 11 |
import { createAuthRoutes } from "./routes/auth.js";
|
| 12 |
import { createAccountRoutes } from "./routes/accounts.js";
|
| 13 |
import { createChatRoutes } from "./routes/chat.js";
|
|
|
|
| 27 |
import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
|
| 28 |
import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
|
| 29 |
import { UsageStatsStore } from "./auth/usage-stats.js";
|
| 30 |
+
import { startSessionCleanup, stopSessionCleanup } from "./auth/dashboard-session.js";
|
| 31 |
+
import { createDashboardAuthRoutes } from "./routes/dashboard-login.js";
|
| 32 |
|
| 33 |
export interface ServerHandle {
|
| 34 |
close: () => Promise<void>;
|
|
|
|
| 72 |
app.use("*", requestId);
|
| 73 |
app.use("*", logger);
|
| 74 |
app.use("*", errorHandler);
|
| 75 |
+
app.use("*", dashboardAuth);
|
| 76 |
|
| 77 |
// Mount routes
|
| 78 |
const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
|
|
|
|
| 85 |
const usageStats = new UsageStatsStore();
|
| 86 |
const webRoutes = createWebRoutes(accountPool, usageStats);
|
| 87 |
|
| 88 |
+
app.route("/", createDashboardAuthRoutes());
|
| 89 |
app.route("/", authRoutes);
|
| 90 |
app.route("/", accountRoutes);
|
| 91 |
app.route("/", chatRoutes);
|
|
|
|
| 124 |
}
|
| 125 |
console.log();
|
| 126 |
|
| 127 |
+
// Start dashboard session cleanup
|
| 128 |
+
startSessionCleanup();
|
| 129 |
+
|
| 130 |
// Start background update checkers
|
| 131 |
// (Electron has its own native auto-updater — skip proxy update checker)
|
| 132 |
startUpdateChecker();
|
|
|
|
| 160 |
stopProxyUpdateChecker();
|
| 161 |
stopModelRefresh();
|
| 162 |
stopQuotaRefresh();
|
| 163 |
+
stopSessionCleanup();
|
| 164 |
refreshScheduler.destroy();
|
| 165 |
proxyPool.destroy();
|
| 166 |
cookieJar.destroy();
|
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 2 |
+
import { Hono } from "hono";
|
| 3 |
+
|
| 4 |
+
const mockConfig = {
|
| 5 |
+
server: { proxy_api_key: "test-key" as string | null },
|
| 6 |
+
session: { ttl_minutes: 60, cleanup_interval_minutes: 5 },
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
vi.mock("../../config.js", () => ({
|
| 10 |
+
getConfig: vi.fn(() => mockConfig),
|
| 11 |
+
}));
|
| 12 |
+
|
| 13 |
+
const mockGetConnInfo = vi.fn(() => ({ remote: { address: "192.168.1.100" } }));
|
| 14 |
+
vi.mock("@hono/node-server/conninfo", () => ({
|
| 15 |
+
getConnInfo: (...args: unknown[]) => mockGetConnInfo(...args),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("../../auth/dashboard-session.js", async () => {
|
| 19 |
+
const validSessions = new Set<string>();
|
| 20 |
+
return {
|
| 21 |
+
validateSession: vi.fn((id: string) => validSessions.has(id)),
|
| 22 |
+
_addTestSession: (id: string) => validSessions.add(id),
|
| 23 |
+
_clearTestSessions: () => validSessions.clear(),
|
| 24 |
+
};
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
import { dashboardAuth } from "../dashboard-auth.js";
|
| 28 |
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
| 29 |
+
const sessionMod = await import("../../auth/dashboard-session.js") as {
|
| 30 |
+
validateSession: ReturnType<typeof vi.fn>;
|
| 31 |
+
_addTestSession: (id: string) => void;
|
| 32 |
+
_clearTestSessions: () => void;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
function createApp(): Hono {
|
| 36 |
+
const app = new Hono();
|
| 37 |
+
app.use("*", dashboardAuth);
|
| 38 |
+
// Catch-all handler to confirm middleware passed through
|
| 39 |
+
app.all("*", (c) => c.json({ ok: true }));
|
| 40 |
+
return app;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
describe("dashboard-auth middleware", () => {
|
| 44 |
+
beforeEach(() => {
|
| 45 |
+
vi.clearAllMocks();
|
| 46 |
+
mockConfig.server.proxy_api_key = "test-key";
|
| 47 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "192.168.1.100" } });
|
| 48 |
+
sessionMod._clearTestSessions();
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("passes through when proxy_api_key is not set", async () => {
|
| 52 |
+
mockConfig.server.proxy_api_key = null;
|
| 53 |
+
const app = createApp();
|
| 54 |
+
const res = await app.request("/auth/accounts");
|
| 55 |
+
expect(res.status).toBe(200);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("passes through for localhost requests", async () => {
|
| 59 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
|
| 60 |
+
const app = createApp();
|
| 61 |
+
const res = await app.request("/auth/accounts");
|
| 62 |
+
expect(res.status).toBe(200);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("passes through for ::1 localhost", async () => {
|
| 66 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "::1" } });
|
| 67 |
+
const app = createApp();
|
| 68 |
+
const res = await app.request("/admin/rotation-settings");
|
| 69 |
+
expect(res.status).toBe(200);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("passes through for GET / (HTML shell)", async () => {
|
| 73 |
+
const app = createApp();
|
| 74 |
+
const res = await app.request("/");
|
| 75 |
+
expect(res.status).toBe(200);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it("passes through for GET /desktop", async () => {
|
| 79 |
+
const app = createApp();
|
| 80 |
+
const res = await app.request("/desktop");
|
| 81 |
+
expect(res.status).toBe(200);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it("passes through for /assets/* (static files)", async () => {
|
| 85 |
+
const app = createApp();
|
| 86 |
+
const res = await app.request("/assets/index-abc123.js");
|
| 87 |
+
expect(res.status).toBe(200);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("passes through for /health", async () => {
|
| 91 |
+
const app = createApp();
|
| 92 |
+
const res = await app.request("/health");
|
| 93 |
+
expect(res.status).toBe(200);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it("passes through for /v1/* API routes", async () => {
|
| 97 |
+
const app = createApp();
|
| 98 |
+
const res = await app.request("/v1/chat/completions");
|
| 99 |
+
expect(res.status).toBe(200);
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it("passes through for /v1beta/* API routes", async () => {
|
| 103 |
+
const app = createApp();
|
| 104 |
+
const res = await app.request("/v1beta/models");
|
| 105 |
+
expect(res.status).toBe(200);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it("passes through for dashboard auth endpoints", async () => {
|
| 109 |
+
const app = createApp();
|
| 110 |
+
for (const path of ["/auth/dashboard-login", "/auth/dashboard-logout", "/auth/dashboard-status"]) {
|
| 111 |
+
const res = await app.request(path);
|
| 112 |
+
expect(res.status).toBe(200);
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it("returns 401 for /auth/accounts without session", async () => {
|
| 117 |
+
const app = createApp();
|
| 118 |
+
const res = await app.request("/auth/accounts");
|
| 119 |
+
expect(res.status).toBe(401);
|
| 120 |
+
const body = await res.json();
|
| 121 |
+
expect(body.error).toBeTruthy();
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
it("returns 401 for /admin/* without session", async () => {
|
| 125 |
+
const app = createApp();
|
| 126 |
+
const res = await app.request("/admin/rotation-settings");
|
| 127 |
+
expect(res.status).toBe(401);
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("returns 401 for /auth/status without session", async () => {
|
| 131 |
+
const app = createApp();
|
| 132 |
+
const res = await app.request("/auth/status");
|
| 133 |
+
expect(res.status).toBe(401);
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
it("passes through with valid session cookie", async () => {
|
| 137 |
+
sessionMod._addTestSession("valid-session-id");
|
| 138 |
+
const app = createApp();
|
| 139 |
+
const res = await app.request("/auth/accounts", {
|
| 140 |
+
headers: { Cookie: "_codex_session=valid-session-id" },
|
| 141 |
+
});
|
| 142 |
+
expect(res.status).toBe(200);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("returns 401 with invalid session cookie", async () => {
|
| 146 |
+
const app = createApp();
|
| 147 |
+
const res = await app.request("/auth/accounts", {
|
| 148 |
+
headers: { Cookie: "_codex_session=invalid-id" },
|
| 149 |
+
});
|
| 150 |
+
expect(res.status).toBe(401);
|
| 151 |
+
});
|
| 152 |
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dashboard Auth Middleware — cookie-based login gate for the web dashboard.
|
| 3 |
+
*
|
| 4 |
+
* When proxy_api_key is configured and the request originates from a non-localhost
|
| 5 |
+
* address, require a valid _codex_session cookie. Protects dashboard data endpoints
|
| 6 |
+
* while allowing: static assets, health, API routes, login endpoints, and HTML shell.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import type { Context, Next } from "hono";
|
| 10 |
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
| 11 |
+
import { getConfig } from "../config.js";
|
| 12 |
+
import { isLocalhostRequest } from "../utils/is-localhost.js";
|
| 13 |
+
import { validateSession } from "../auth/dashboard-session.js";
|
| 14 |
+
import { parseSessionCookie } from "../utils/parse-cookie.js";
|
| 15 |
+
|
| 16 |
+
/** Paths that are always allowed through without dashboard session. */
|
| 17 |
+
const ALLOWED_PREFIXES = ["/assets/", "/v1/", "/v1beta/"];
|
| 18 |
+
const ALLOWED_EXACT = new Set([
|
| 19 |
+
"/health",
|
| 20 |
+
"/auth/dashboard-login",
|
| 21 |
+
"/auth/dashboard-logout",
|
| 22 |
+
"/auth/dashboard-status",
|
| 23 |
+
]);
|
| 24 |
+
/** GET-only paths allowed (HTML shell must load to render login form). */
|
| 25 |
+
const ALLOWED_GET_EXACT = new Set(["/", "/desktop"]);
|
| 26 |
+
|
| 27 |
+
export async function dashboardAuth(c: Context, next: Next): Promise<Response | void> {
|
| 28 |
+
const config = getConfig();
|
| 29 |
+
|
| 30 |
+
// No key configured → no gate
|
| 31 |
+
if (!config.server.proxy_api_key) return next();
|
| 32 |
+
|
| 33 |
+
// Localhost → bypass (Electron + local dev)
|
| 34 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 35 |
+
if (isLocalhostRequest(remoteAddr)) return next();
|
| 36 |
+
|
| 37 |
+
// Always-allowed paths
|
| 38 |
+
const path = c.req.path;
|
| 39 |
+
if (ALLOWED_EXACT.has(path)) return next();
|
| 40 |
+
if (ALLOWED_PREFIXES.some((p) => path.startsWith(p))) return next();
|
| 41 |
+
if (c.req.method === "GET" && ALLOWED_GET_EXACT.has(path)) return next();
|
| 42 |
+
|
| 43 |
+
// Check session cookie
|
| 44 |
+
const sessionId = parseSessionCookie(c.req.header("cookie"));
|
| 45 |
+
if (sessionId && validateSession(sessionId)) return next();
|
| 46 |
+
|
| 47 |
+
// Not authenticated — reject
|
| 48 |
+
c.status(401);
|
| 49 |
+
return c.json({ error: "Dashboard login required" });
|
| 50 |
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 2 |
+
import { Hono } from "hono";
|
| 3 |
+
|
| 4 |
+
const mockConfig = {
|
| 5 |
+
server: { proxy_api_key: "secret-key" as string | null },
|
| 6 |
+
session: { ttl_minutes: 60, cleanup_interval_minutes: 5 },
|
| 7 |
+
auth: { rotation_strategy: "least_used" as string },
|
| 8 |
+
quota: {
|
| 9 |
+
refresh_interval_minutes: 5,
|
| 10 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 11 |
+
skip_exhausted: true,
|
| 12 |
+
},
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
vi.mock("../../config.js", () => ({
|
| 16 |
+
getConfig: vi.fn(() => mockConfig),
|
| 17 |
+
reloadAllConfigs: vi.fn(),
|
| 18 |
+
ROTATION_STRATEGIES: ["least_used", "round_robin", "sticky"],
|
| 19 |
+
}));
|
| 20 |
+
|
| 21 |
+
const mockGetConnInfo = vi.fn(() => ({ remote: { address: "192.168.1.100" } }));
|
| 22 |
+
vi.mock("@hono/node-server/conninfo", () => ({
|
| 23 |
+
getConnInfo: (...args: unknown[]) => mockGetConnInfo(...args),
|
| 24 |
+
}));
|
| 25 |
+
|
| 26 |
+
vi.mock("../../auth/dashboard-session.js", async (importOriginal) => {
|
| 27 |
+
const actual = await importOriginal<typeof import("../../auth/dashboard-session.js")>();
|
| 28 |
+
return actual;
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
vi.mock("../../paths.js", () => ({
|
| 32 |
+
getConfigDir: vi.fn(() => "/tmp/test-config"),
|
| 33 |
+
getPublicDir: vi.fn(() => "/tmp/test-public"),
|
| 34 |
+
getDesktopPublicDir: vi.fn(() => "/tmp/test-desktop"),
|
| 35 |
+
getDataDir: vi.fn(() => "/tmp/test-data"),
|
| 36 |
+
getBinDir: vi.fn(() => "/tmp/test-bin"),
|
| 37 |
+
isEmbedded: vi.fn(() => false),
|
| 38 |
+
}));
|
| 39 |
+
|
| 40 |
+
vi.mock("../../utils/yaml-mutate.js", () => ({
|
| 41 |
+
mutateYaml: vi.fn(),
|
| 42 |
+
}));
|
| 43 |
+
|
| 44 |
+
vi.mock("../../tls/transport.js", () => ({
|
| 45 |
+
getTransport: vi.fn(),
|
| 46 |
+
getTransportInfo: vi.fn(() => ({})),
|
| 47 |
+
}));
|
| 48 |
+
|
| 49 |
+
vi.mock("../../tls/curl-binary.js", () => ({
|
| 50 |
+
getCurlDiagnostics: vi.fn(() => ({})),
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
vi.mock("../../fingerprint/manager.js", () => ({
|
| 54 |
+
buildHeaders: vi.fn(() => ({})),
|
| 55 |
+
}));
|
| 56 |
+
|
| 57 |
+
vi.mock("../../update-checker.js", () => ({
|
| 58 |
+
getUpdateState: vi.fn(() => ({})),
|
| 59 |
+
checkForUpdate: vi.fn(),
|
| 60 |
+
isUpdateInProgress: vi.fn(() => false),
|
| 61 |
+
}));
|
| 62 |
+
|
| 63 |
+
vi.mock("../../self-update.js", () => ({
|
| 64 |
+
getProxyInfo: vi.fn(() => ({})),
|
| 65 |
+
canSelfUpdate: vi.fn(() => false),
|
| 66 |
+
checkProxySelfUpdate: vi.fn(),
|
| 67 |
+
applyProxySelfUpdate: vi.fn(),
|
| 68 |
+
isProxyUpdateInProgress: vi.fn(() => false),
|
| 69 |
+
getCachedProxyUpdateResult: vi.fn(() => null),
|
| 70 |
+
getDeployMode: vi.fn(() => "git"),
|
| 71 |
+
}));
|
| 72 |
+
|
| 73 |
+
vi.mock("@hono/node-server/serve-static", () => ({
|
| 74 |
+
serveStatic: vi.fn(() => vi.fn()),
|
| 75 |
+
}));
|
| 76 |
+
|
| 77 |
+
import {
|
| 78 |
+
createDashboardAuthRoutes,
|
| 79 |
+
_resetRateLimitForTest,
|
| 80 |
+
} from "../dashboard-login.js";
|
| 81 |
+
import { createSettingsRoutes } from "../admin/settings.js";
|
| 82 |
+
import { _resetForTest } from "../../auth/dashboard-session.js";
|
| 83 |
+
|
| 84 |
+
function createApp(): Hono {
|
| 85 |
+
const app = new Hono();
|
| 86 |
+
app.route("/", createDashboardAuthRoutes());
|
| 87 |
+
app.route("/", createSettingsRoutes());
|
| 88 |
+
return app;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
describe("dashboard auth endpoints", () => {
|
| 92 |
+
beforeEach(() => {
|
| 93 |
+
vi.clearAllMocks();
|
| 94 |
+
mockConfig.server.proxy_api_key = "secret-key";
|
| 95 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "192.168.1.100" } });
|
| 96 |
+
_resetForTest();
|
| 97 |
+
_resetRateLimitForTest();
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
describe("POST /auth/dashboard-login", () => {
|
| 101 |
+
it("returns 200 and sets cookie with correct password", async () => {
|
| 102 |
+
const app = createApp();
|
| 103 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 104 |
+
method: "POST",
|
| 105 |
+
headers: { "Content-Type": "application/json" },
|
| 106 |
+
body: JSON.stringify({ password: "secret-key" }),
|
| 107 |
+
});
|
| 108 |
+
expect(res.status).toBe(200);
|
| 109 |
+
const body = await res.json();
|
| 110 |
+
expect(body.success).toBe(true);
|
| 111 |
+
|
| 112 |
+
const cookie = res.headers.get("set-cookie");
|
| 113 |
+
expect(cookie).toContain("_codex_session=");
|
| 114 |
+
expect(cookie).toContain("HttpOnly");
|
| 115 |
+
expect(cookie).toContain("SameSite=Strict");
|
| 116 |
+
expect(cookie).toContain("Path=/");
|
| 117 |
+
expect(cookie).toContain("Max-Age=");
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("returns 401 with wrong password", async () => {
|
| 121 |
+
const app = createApp();
|
| 122 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 123 |
+
method: "POST",
|
| 124 |
+
headers: { "Content-Type": "application/json" },
|
| 125 |
+
body: JSON.stringify({ password: "wrong" }),
|
| 126 |
+
});
|
| 127 |
+
expect(res.status).toBe(401);
|
| 128 |
+
const body = await res.json();
|
| 129 |
+
expect(body.error).toBeTruthy();
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
it("returns 400 with missing body", async () => {
|
| 133 |
+
const app = createApp();
|
| 134 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 135 |
+
method: "POST",
|
| 136 |
+
headers: { "Content-Type": "application/json" },
|
| 137 |
+
body: JSON.stringify({}),
|
| 138 |
+
});
|
| 139 |
+
expect(res.status).toBe(400);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
it("sets Secure flag when behind HTTPS reverse proxy", async () => {
|
| 143 |
+
const app = createApp();
|
| 144 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 145 |
+
method: "POST",
|
| 146 |
+
headers: {
|
| 147 |
+
"Content-Type": "application/json",
|
| 148 |
+
"X-Forwarded-Proto": "https",
|
| 149 |
+
},
|
| 150 |
+
body: JSON.stringify({ password: "secret-key" }),
|
| 151 |
+
});
|
| 152 |
+
expect(res.status).toBe(200);
|
| 153 |
+
const cookie = res.headers.get("set-cookie");
|
| 154 |
+
expect(cookie).toContain("Secure");
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
it("omits Secure flag for HTTP", async () => {
|
| 158 |
+
const app = createApp();
|
| 159 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 160 |
+
method: "POST",
|
| 161 |
+
headers: { "Content-Type": "application/json" },
|
| 162 |
+
body: JSON.stringify({ password: "secret-key" }),
|
| 163 |
+
});
|
| 164 |
+
expect(res.status).toBe(200);
|
| 165 |
+
const cookie = res.headers.get("set-cookie");
|
| 166 |
+
expect(cookie).not.toContain("Secure");
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
it("returns 429 after 5 failed attempts", async () => {
|
| 170 |
+
const app = createApp();
|
| 171 |
+
for (let i = 0; i < 5; i++) {
|
| 172 |
+
await app.request("/auth/dashboard-login", {
|
| 173 |
+
method: "POST",
|
| 174 |
+
headers: { "Content-Type": "application/json" },
|
| 175 |
+
body: JSON.stringify({ password: "wrong" }),
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
const res = await app.request("/auth/dashboard-login", {
|
| 179 |
+
method: "POST",
|
| 180 |
+
headers: { "Content-Type": "application/json" },
|
| 181 |
+
body: JSON.stringify({ password: "wrong" }),
|
| 182 |
+
});
|
| 183 |
+
expect(res.status).toBe(429);
|
| 184 |
+
});
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
describe("POST /auth/dashboard-logout", () => {
|
| 188 |
+
it("clears session and cookie", async () => {
|
| 189 |
+
const app = createApp();
|
| 190 |
+
// Login first
|
| 191 |
+
const loginRes = await app.request("/auth/dashboard-login", {
|
| 192 |
+
method: "POST",
|
| 193 |
+
headers: { "Content-Type": "application/json" },
|
| 194 |
+
body: JSON.stringify({ password: "secret-key" }),
|
| 195 |
+
});
|
| 196 |
+
const cookie = loginRes.headers.get("set-cookie")!;
|
| 197 |
+
const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
|
| 198 |
+
|
| 199 |
+
// Logout
|
| 200 |
+
const logoutRes = await app.request("/auth/dashboard-logout", {
|
| 201 |
+
method: "POST",
|
| 202 |
+
headers: { Cookie: `_codex_session=${sessionId}` },
|
| 203 |
+
});
|
| 204 |
+
expect(logoutRes.status).toBe(200);
|
| 205 |
+
const body = await logoutRes.json();
|
| 206 |
+
expect(body.success).toBe(true);
|
| 207 |
+
|
| 208 |
+
const clearCookie = logoutRes.headers.get("set-cookie");
|
| 209 |
+
expect(clearCookie).toContain("Max-Age=0");
|
| 210 |
+
});
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
describe("GET /auth/dashboard-status", () => {
|
| 214 |
+
it("returns required=false when no key configured", async () => {
|
| 215 |
+
mockConfig.server.proxy_api_key = null;
|
| 216 |
+
const app = createApp();
|
| 217 |
+
const res = await app.request("/auth/dashboard-status");
|
| 218 |
+
const body = await res.json();
|
| 219 |
+
expect(body.required).toBe(false);
|
| 220 |
+
expect(body.authenticated).toBe(true);
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
it("returns required=false for localhost", async () => {
|
| 224 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
|
| 225 |
+
const app = createApp();
|
| 226 |
+
const res = await app.request("/auth/dashboard-status");
|
| 227 |
+
const body = await res.json();
|
| 228 |
+
expect(body.required).toBe(false);
|
| 229 |
+
expect(body.authenticated).toBe(true);
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
it("returns required=true, authenticated=false for remote without session", async () => {
|
| 233 |
+
const app = createApp();
|
| 234 |
+
const res = await app.request("/auth/dashboard-status");
|
| 235 |
+
const body = await res.json();
|
| 236 |
+
expect(body.required).toBe(true);
|
| 237 |
+
expect(body.authenticated).toBe(false);
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
it("returns required=true, authenticated=true for remote with valid session", async () => {
|
| 241 |
+
const app = createApp();
|
| 242 |
+
// Login first
|
| 243 |
+
const loginRes = await app.request("/auth/dashboard-login", {
|
| 244 |
+
method: "POST",
|
| 245 |
+
headers: { "Content-Type": "application/json" },
|
| 246 |
+
body: JSON.stringify({ password: "secret-key" }),
|
| 247 |
+
});
|
| 248 |
+
const cookie = loginRes.headers.get("set-cookie")!;
|
| 249 |
+
const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
|
| 250 |
+
|
| 251 |
+
const statusRes = await app.request("/auth/dashboard-status", {
|
| 252 |
+
headers: { Cookie: `_codex_session=${sessionId}` },
|
| 253 |
+
});
|
| 254 |
+
const body = await statusRes.json();
|
| 255 |
+
expect(body.required).toBe(true);
|
| 256 |
+
expect(body.authenticated).toBe(true);
|
| 257 |
+
});
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
describe("POST /admin/settings — remote clear protection", () => {
|
| 261 |
+
it("blocks remote session from clearing proxy_api_key", async () => {
|
| 262 |
+
const app = createApp();
|
| 263 |
+
const res = await app.request("/admin/settings", {
|
| 264 |
+
method: "POST",
|
| 265 |
+
headers: {
|
| 266 |
+
"Content-Type": "application/json",
|
| 267 |
+
Authorization: "Bearer secret-key",
|
| 268 |
+
},
|
| 269 |
+
body: JSON.stringify({ proxy_api_key: null }),
|
| 270 |
+
});
|
| 271 |
+
expect(res.status).toBe(403);
|
| 272 |
+
const body = await res.json();
|
| 273 |
+
expect(body.error).toContain("Cannot clear");
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
it("allows localhost to clear proxy_api_key", async () => {
|
| 277 |
+
mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
|
| 278 |
+
const app = createApp();
|
| 279 |
+
const res = await app.request("/admin/settings", {
|
| 280 |
+
method: "POST",
|
| 281 |
+
headers: {
|
| 282 |
+
"Content-Type": "application/json",
|
| 283 |
+
Authorization: "Bearer secret-key",
|
| 284 |
+
},
|
| 285 |
+
body: JSON.stringify({ proxy_api_key: null }),
|
| 286 |
+
});
|
| 287 |
+
expect(res.status).toBe(200);
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
it("allows remote session to change (not clear) proxy_api_key", async () => {
|
| 291 |
+
const app = createApp();
|
| 292 |
+
const res = await app.request("/admin/settings", {
|
| 293 |
+
method: "POST",
|
| 294 |
+
headers: {
|
| 295 |
+
"Content-Type": "application/json",
|
| 296 |
+
Authorization: "Bearer secret-key",
|
| 297 |
+
},
|
| 298 |
+
body: JSON.stringify({ proxy_api_key: "new-key" }),
|
| 299 |
+
});
|
| 300 |
+
expect(res.status).toBe(200);
|
| 301 |
+
});
|
| 302 |
+
});
|
| 303 |
+
});
|
|
@@ -7,6 +7,7 @@ import { getConfig, getFingerprint } from "../../config.js";
|
|
| 7 |
import { getConfigDir, getDataDir, getBinDir, isEmbedded } from "../../paths.js";
|
| 8 |
import { getTransportInfo } from "../../tls/transport.js";
|
| 9 |
import { getCurlDiagnostics } from "../../tls/curl-binary.js";
|
|
|
|
| 10 |
|
| 11 |
export function createHealthRoutes(accountPool: AccountPool): Hono {
|
| 12 |
const app = new Hono();
|
|
@@ -25,7 +26,7 @@ export function createHealthRoutes(accountPool: AccountPool): Hono {
|
|
| 25 |
app.get("/debug/fingerprint", (c) => {
|
| 26 |
const isProduction = process.env.NODE_ENV === "production";
|
| 27 |
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 28 |
-
const isLocalhost = remoteAddr
|
| 29 |
if (isProduction && !isLocalhost) {
|
| 30 |
c.status(404);
|
| 31 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
@@ -86,7 +87,7 @@ export function createHealthRoutes(accountPool: AccountPool): Hono {
|
|
| 86 |
|
| 87 |
app.get("/debug/diagnostics", (c) => {
|
| 88 |
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 89 |
-
const isLocalhost = remoteAddr
|
| 90 |
if (process.env.NODE_ENV === "production" && !isLocalhost) {
|
| 91 |
c.status(404);
|
| 92 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
|
|
| 7 |
import { getConfigDir, getDataDir, getBinDir, isEmbedded } from "../../paths.js";
|
| 8 |
import { getTransportInfo } from "../../tls/transport.js";
|
| 9 |
import { getCurlDiagnostics } from "../../tls/curl-binary.js";
|
| 10 |
+
import { isLocalhostRequest } from "../../utils/is-localhost.js";
|
| 11 |
|
| 12 |
export function createHealthRoutes(accountPool: AccountPool): Hono {
|
| 13 |
const app = new Hono();
|
|
|
|
| 26 |
app.get("/debug/fingerprint", (c) => {
|
| 27 |
const isProduction = process.env.NODE_ENV === "production";
|
| 28 |
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 29 |
+
const isLocalhost = isLocalhostRequest(remoteAddr);
|
| 30 |
if (isProduction && !isLocalhost) {
|
| 31 |
c.status(404);
|
| 32 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
|
|
| 87 |
|
| 88 |
app.get("/debug/diagnostics", (c) => {
|
| 89 |
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 90 |
+
const isLocalhost = isLocalhostRequest(remoteAddr);
|
| 91 |
if (process.env.NODE_ENV === "production" && !isLocalhost) {
|
| 92 |
c.status(404);
|
| 93 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
@@ -1,8 +1,10 @@
|
|
| 1 |
import { Hono } from "hono";
|
| 2 |
import { resolve } from "path";
|
|
|
|
| 3 |
import { getConfig, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js";
|
| 4 |
import { getConfigDir } from "../../paths.js";
|
| 5 |
import { mutateYaml } from "../../utils/yaml-mutate.js";
|
|
|
|
| 6 |
|
| 7 |
export function createSettingsRoutes(): Hono {
|
| 8 |
const app = new Hono();
|
|
@@ -73,6 +75,15 @@ export function createSettingsRoutes(): Hono {
|
|
| 73 |
const body = await c.req.json() as { proxy_api_key?: string | null };
|
| 74 |
const newKey = body.proxy_api_key === undefined ? currentKey : (body.proxy_api_key || null);
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
const configPath = resolve(getConfigDir(), "default.yaml");
|
| 77 |
mutateYaml(configPath, (data) => {
|
| 78 |
const server = data.server as Record<string, unknown>;
|
|
|
|
| 1 |
import { Hono } from "hono";
|
| 2 |
import { resolve } from "path";
|
| 3 |
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
| 4 |
import { getConfig, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js";
|
| 5 |
import { getConfigDir } from "../../paths.js";
|
| 6 |
import { mutateYaml } from "../../utils/yaml-mutate.js";
|
| 7 |
+
import { isLocalhostRequest } from "../../utils/is-localhost.js";
|
| 8 |
|
| 9 |
export function createSettingsRoutes(): Hono {
|
| 10 |
const app = new Hono();
|
|
|
|
| 75 |
const body = await c.req.json() as { proxy_api_key?: string | null };
|
| 76 |
const newKey = body.proxy_api_key === undefined ? currentKey : (body.proxy_api_key || null);
|
| 77 |
|
| 78 |
+
// Prevent remote sessions from clearing the key (would disable login gate)
|
| 79 |
+
if (currentKey && !newKey) {
|
| 80 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 81 |
+
if (!isLocalhostRequest(remoteAddr)) {
|
| 82 |
+
c.status(403);
|
| 83 |
+
return c.json({ error: "Cannot clear API key from remote session — this would disable the login gate" });
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
const configPath = resolve(getConfigDir(), "default.yaml");
|
| 88 |
mutateYaml(configPath, (data) => {
|
| 89 |
const server = data.server as Record<string, unknown>;
|
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dashboard Login Routes — cookie-based authentication for the web dashboard.
|
| 3 |
+
*
|
| 4 |
+
* Provides login/logout/status endpoints that work with the dashboard-auth middleware.
|
| 5 |
+
* Uses the existing proxy_api_key as the dashboard password.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { timingSafeEqual } from "crypto";
|
| 9 |
+
import { Hono } from "hono";
|
| 10 |
+
import type { Context } from "hono";
|
| 11 |
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
| 12 |
+
import { getConfig } from "../config.js";
|
| 13 |
+
import { isLocalhostRequest } from "../utils/is-localhost.js";
|
| 14 |
+
import { parseSessionCookie } from "../utils/parse-cookie.js";
|
| 15 |
+
import {
|
| 16 |
+
createSession,
|
| 17 |
+
validateSession,
|
| 18 |
+
deleteSession,
|
| 19 |
+
} from "../auth/dashboard-session.js";
|
| 20 |
+
|
| 21 |
+
/** Per-IP brute-force tracking: IP → { count, resetAt } */
|
| 22 |
+
const failedAttempts = new Map<string, { count: number; resetAt: number }>();
|
| 23 |
+
const MAX_ATTEMPTS = 5;
|
| 24 |
+
const WINDOW_MS = 60_000;
|
| 25 |
+
|
| 26 |
+
function checkRateLimit(ip: string): boolean {
|
| 27 |
+
const now = Date.now();
|
| 28 |
+
const entry = failedAttempts.get(ip);
|
| 29 |
+
if (!entry || now > entry.resetAt) {
|
| 30 |
+
if (entry) failedAttempts.delete(ip); // cleanup expired
|
| 31 |
+
return true;
|
| 32 |
+
}
|
| 33 |
+
return entry.count < MAX_ATTEMPTS;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function recordFailure(ip: string): void {
|
| 37 |
+
const now = Date.now();
|
| 38 |
+
const entry = failedAttempts.get(ip);
|
| 39 |
+
if (!entry || now > entry.resetAt) {
|
| 40 |
+
failedAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
| 41 |
+
} else {
|
| 42 |
+
entry.count++;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** Detect HTTPS from X-Forwarded-Proto (reverse proxy) or protocol. */
|
| 47 |
+
function isHttps(c: Context): boolean {
|
| 48 |
+
const proto = c.req.header("x-forwarded-proto");
|
| 49 |
+
if (proto) return proto.toLowerCase() === "https";
|
| 50 |
+
const url = new URL(c.req.url);
|
| 51 |
+
return url.protocol === "https:";
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function buildCookieString(name: string, value: string, maxAge: number, secure: boolean): string {
|
| 55 |
+
let cookie = `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
| 56 |
+
if (secure) cookie += "; Secure";
|
| 57 |
+
return cookie;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Reset rate-limit state — for tests only. */
|
| 61 |
+
export function _resetRateLimitForTest(): void {
|
| 62 |
+
failedAttempts.clear();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function createDashboardAuthRoutes(): Hono {
|
| 66 |
+
const app = new Hono();
|
| 67 |
+
|
| 68 |
+
// POST /auth/dashboard-login — validate proxy_api_key and set session cookie
|
| 69 |
+
app.post("/auth/dashboard-login", async (c) => {
|
| 70 |
+
const config = getConfig();
|
| 71 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "unknown";
|
| 72 |
+
|
| 73 |
+
// Rate limit check
|
| 74 |
+
if (!checkRateLimit(remoteAddr)) {
|
| 75 |
+
c.status(429);
|
| 76 |
+
return c.json({ error: "Too many login attempts. Try again later." });
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
let body: { password?: string };
|
| 80 |
+
try {
|
| 81 |
+
body = await c.req.json();
|
| 82 |
+
} catch {
|
| 83 |
+
c.status(400);
|
| 84 |
+
return c.json({ error: "Invalid JSON body" });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const password = body.password;
|
| 88 |
+
if (!password || typeof password !== "string") {
|
| 89 |
+
c.status(400);
|
| 90 |
+
return c.json({ error: "Password is required" });
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const key = config.server.proxy_api_key ?? "";
|
| 94 |
+
const a = Buffer.from(password);
|
| 95 |
+
const b = Buffer.from(key);
|
| 96 |
+
const match = a.length === b.length && timingSafeEqual(a, b);
|
| 97 |
+
if (!match) {
|
| 98 |
+
recordFailure(remoteAddr);
|
| 99 |
+
c.status(401);
|
| 100 |
+
return c.json({ error: "Invalid password" });
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const session = createSession();
|
| 104 |
+
const maxAge = config.session.ttl_minutes * 60;
|
| 105 |
+
const secure = isHttps(c);
|
| 106 |
+
c.header("Set-Cookie", buildCookieString("_codex_session", session.id, maxAge, secure));
|
| 107 |
+
return c.json({ success: true });
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
// POST /auth/dashboard-logout — clear session and cookie
|
| 111 |
+
app.post("/auth/dashboard-logout", (c) => {
|
| 112 |
+
const sessionId = parseSessionCookie(c.req.header("cookie"));
|
| 113 |
+
if (sessionId) {
|
| 114 |
+
deleteSession(sessionId);
|
| 115 |
+
}
|
| 116 |
+
const secure = isHttps(c);
|
| 117 |
+
c.header("Set-Cookie", buildCookieString("_codex_session", "", 0, secure));
|
| 118 |
+
return c.json({ success: true });
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
// GET /auth/dashboard-status — check if login is required and current auth state
|
| 122 |
+
app.get("/auth/dashboard-status", (c) => {
|
| 123 |
+
const config = getConfig();
|
| 124 |
+
|
| 125 |
+
// No key → no gate required
|
| 126 |
+
if (!config.server.proxy_api_key) {
|
| 127 |
+
return c.json({ required: false, authenticated: true });
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Localhost → no gate required
|
| 131 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 132 |
+
if (isLocalhostRequest(remoteAddr)) {
|
| 133 |
+
return c.json({ required: false, authenticated: true });
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Check session
|
| 137 |
+
const sessionId = parseSessionCookie(c.req.header("cookie"));
|
| 138 |
+
const authenticated = !!sessionId && validateSession(sessionId);
|
| 139 |
+
|
| 140 |
+
return c.json({ required: true, authenticated });
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
return app;
|
| 144 |
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { isLocalhostRequest } from "../is-localhost.js";
|
| 3 |
+
|
| 4 |
+
describe("isLocalhostRequest", () => {
|
| 5 |
+
it.each([
|
| 6 |
+
["", true],
|
| 7 |
+
["127.0.0.1", true],
|
| 8 |
+
["::1", true],
|
| 9 |
+
["::ffff:127.0.0.1", true],
|
| 10 |
+
])("returns true for %s", (addr, expected) => {
|
| 11 |
+
expect(isLocalhostRequest(addr)).toBe(expected);
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
it.each([
|
| 15 |
+
["192.168.1.1", false],
|
| 16 |
+
["10.0.0.5", false],
|
| 17 |
+
["172.16.0.1", false],
|
| 18 |
+
["8.8.8.8", false],
|
| 19 |
+
["::ffff:192.168.1.1", false],
|
| 20 |
+
["2001:db8::1", false],
|
| 21 |
+
])("returns false for %s", (addr, expected) => {
|
| 22 |
+
expect(isLocalhostRequest(addr)).toBe(expected);
|
| 23 |
+
});
|
| 24 |
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const LOCALHOST_ADDRS = new Set(["", "127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
| 2 |
+
|
| 3 |
+
/** Returns true if the remote address represents a localhost connection. */
|
| 4 |
+
export function isLocalhostRequest(remoteAddr: string): boolean {
|
| 5 |
+
return LOCALHOST_ADDRS.has(remoteAddr);
|
| 6 |
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const COOKIE_REGEX = /(?:^|;\s*)_codex_session=([^;]*)/;
|
| 2 |
+
|
| 3 |
+
/** Extract the _codex_session cookie value from a Cookie header. */
|
| 4 |
+
export function parseSessionCookie(cookieHeader: string | undefined): string | undefined {
|
| 5 |
+
if (!cookieHeader) return undefined;
|
| 6 |
+
const match = COOKIE_REGEX.exec(cookieHeader);
|
| 7 |
+
return match ? match[1] : undefined;
|
| 8 |
+
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
import { useState, useEffect, useRef } from "preact/hooks";
|
|
|
|
|
|
|
| 2 |
import { I18nProvider } from "../../shared/i18n/context";
|
| 3 |
import { ThemeProvider } from "../../shared/theme/context";
|
| 4 |
import { Header } from "./components/Header";
|
|
@@ -22,6 +24,11 @@ import { useProxies } from "../../shared/hooks/use-proxies";
|
|
| 22 |
import { useStatus } from "../../shared/hooks/use-status";
|
| 23 |
import { useUpdateStatus } from "../../shared/hooks/use-update-status";
|
| 24 |
import { useI18n } from "../../shared/i18n/context";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
function useUpdateMessage() {
|
| 27 |
const { t } = useI18n();
|
|
@@ -75,6 +82,7 @@ function Dashboard() {
|
|
| 75 |
const proxies = useProxies();
|
| 76 |
const status = useStatus(accounts.list.length);
|
| 77 |
const update = useUpdateMessage();
|
|
|
|
| 78 |
const [showModal, setShowModal] = useState(false);
|
| 79 |
const prevUpdateAvailable = useRef(false);
|
| 80 |
|
|
@@ -104,6 +112,7 @@ function Dashboard() {
|
|
| 104 |
version={update.status?.proxy.version ?? null}
|
| 105 |
commit={update.status?.proxy.commit ?? null}
|
| 106 |
hasUpdate={update.hasUpdate}
|
|
|
|
| 107 |
/>
|
| 108 |
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 109 |
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
|
@@ -195,13 +204,79 @@ function PageRouter({ hash }: { hash: string }) {
|
|
| 195 |
}
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
export function App() {
|
| 199 |
const hash = useHash();
|
| 200 |
|
| 201 |
return (
|
| 202 |
<I18nProvider>
|
| 203 |
<ThemeProvider>
|
| 204 |
-
<
|
|
|
|
|
|
|
| 205 |
</ThemeProvider>
|
| 206 |
</I18nProvider>
|
| 207 |
);
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useContext } from "preact/hooks";
|
| 2 |
+
import { createContext } from "preact";
|
| 3 |
+
import type { ComponentChildren } from "preact";
|
| 4 |
import { I18nProvider } from "../../shared/i18n/context";
|
| 5 |
import { ThemeProvider } from "../../shared/theme/context";
|
| 6 |
import { Header } from "./components/Header";
|
|
|
|
| 24 |
import { useStatus } from "../../shared/hooks/use-status";
|
| 25 |
import { useUpdateStatus } from "../../shared/hooks/use-update-status";
|
| 26 |
import { useI18n } from "../../shared/i18n/context";
|
| 27 |
+
import { useDashboardAuth } from "../../shared/hooks/use-dashboard-auth";
|
| 28 |
+
|
| 29 |
+
/** Context for dashboard session state (logout button, remote session indicator). */
|
| 30 |
+
const DashboardAuthCtx = createContext<{ onLogout?: () => void }>({});
|
| 31 |
+
function useDashboardAuthCtx() { return useContext(DashboardAuthCtx); }
|
| 32 |
|
| 33 |
function useUpdateMessage() {
|
| 34 |
const { t } = useI18n();
|
|
|
|
| 82 |
const proxies = useProxies();
|
| 83 |
const status = useStatus(accounts.list.length);
|
| 84 |
const update = useUpdateMessage();
|
| 85 |
+
const { onLogout } = useDashboardAuthCtx();
|
| 86 |
const [showModal, setShowModal] = useState(false);
|
| 87 |
const prevUpdateAvailable = useRef(false);
|
| 88 |
|
|
|
|
| 112 |
version={update.status?.proxy.version ?? null}
|
| 113 |
commit={update.status?.proxy.commit ?? null}
|
| 114 |
hasUpdate={update.hasUpdate}
|
| 115 |
+
onLogout={onLogout}
|
| 116 |
/>
|
| 117 |
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 118 |
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
|
|
|
| 204 |
}
|
| 205 |
}
|
| 206 |
|
| 207 |
+
function LoginGate({ children }: { children: ComponentChildren }) {
|
| 208 |
+
const { t } = useI18n();
|
| 209 |
+
const auth = useDashboardAuth();
|
| 210 |
+
const [password, setPassword] = useState("");
|
| 211 |
+
|
| 212 |
+
if (auth.status === "loading") {
|
| 213 |
+
return (
|
| 214 |
+
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark">
|
| 215 |
+
<div class="animate-pulse text-slate-400 dark:text-text-dim text-sm">Loading...</div>
|
| 216 |
+
</div>
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
if (auth.status === "login") {
|
| 221 |
+
const handleSubmit = (e: Event) => {
|
| 222 |
+
e.preventDefault();
|
| 223 |
+
if (password.trim()) auth.login(password.trim());
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark px-4">
|
| 228 |
+
<div class="w-full max-w-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-2xl shadow-lg p-8">
|
| 229 |
+
<div class="flex flex-col items-center gap-2 mb-6">
|
| 230 |
+
<div class="flex items-center justify-center size-12 rounded-full bg-primary/10 text-primary border border-primary/20">
|
| 231 |
+
<svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 232 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
| 233 |
+
</svg>
|
| 234 |
+
</div>
|
| 235 |
+
<h1 class="text-lg font-bold text-slate-800 dark:text-text-main">{t("dashboardLogin")}</h1>
|
| 236 |
+
<p class="text-xs text-slate-500 dark:text-text-dim text-center">{t("dashboardLoginRequired")}</p>
|
| 237 |
+
</div>
|
| 238 |
+
<form onSubmit={handleSubmit} class="flex flex-col gap-4">
|
| 239 |
+
<div>
|
| 240 |
+
<label class="block text-xs font-medium text-slate-600 dark:text-text-dim mb-1.5">{t("dashboardPassword")}</label>
|
| 241 |
+
<input
|
| 242 |
+
type="password"
|
| 243 |
+
value={password}
|
| 244 |
+
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
|
| 245 |
+
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-sm text-slate-800 dark:text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition-colors"
|
| 246 |
+
placeholder="proxy_api_key"
|
| 247 |
+
autofocus
|
| 248 |
+
/>
|
| 249 |
+
</div>
|
| 250 |
+
{auth.error && (
|
| 251 |
+
<p class="text-xs text-red-500 font-medium">
|
| 252 |
+
{auth.error.includes("Too many") ? t("dashboardTooManyAttempts") : t("dashboardLoginError")}
|
| 253 |
+
</p>
|
| 254 |
+
)}
|
| 255 |
+
<button
|
| 256 |
+
type="submit"
|
| 257 |
+
class="w-full py-2.5 bg-primary hover:bg-primary-hover text-white text-sm font-semibold rounded-lg transition-colors shadow-sm active:scale-[0.98]"
|
| 258 |
+
>
|
| 259 |
+
{t("dashboardLoginBtn")}
|
| 260 |
+
</button>
|
| 261 |
+
</form>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
const ctxValue = auth.isRemoteSession ? { onLogout: auth.logout } : {};
|
| 268 |
+
return <DashboardAuthCtx.Provider value={ctxValue}>{children}</DashboardAuthCtx.Provider>;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
export function App() {
|
| 272 |
const hash = useHash();
|
| 273 |
|
| 274 |
return (
|
| 275 |
<I18nProvider>
|
| 276 |
<ThemeProvider>
|
| 277 |
+
<LoginGate>
|
| 278 |
+
<PageRouter hash={hash} />
|
| 279 |
+
</LoginGate>
|
| 280 |
</ThemeProvider>
|
| 281 |
</I18nProvider>
|
| 282 |
);
|
|
@@ -39,9 +39,10 @@ interface HeaderProps {
|
|
| 39 |
commit?: string | null;
|
| 40 |
isProxySettings?: boolean;
|
| 41 |
hasUpdate?: boolean;
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
-
export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) {
|
| 45 |
const { lang, toggleLang, t } = useI18n();
|
| 46 |
const { isDark, toggle: toggleTheme } = useTheme();
|
| 47 |
|
|
@@ -113,6 +114,18 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 113 |
{updateStatusMsg}
|
| 114 |
</button>
|
| 115 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
{/* Language Toggle */}
|
| 117 |
<button
|
| 118 |
onClick={toggleLang}
|
|
|
|
| 39 |
commit?: string | null;
|
| 40 |
isProxySettings?: boolean;
|
| 41 |
hasUpdate?: boolean;
|
| 42 |
+
onLogout?: () => void;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate, onLogout }: HeaderProps) {
|
| 46 |
const { lang, toggleLang, t } = useI18n();
|
| 47 |
const { isDark, toggle: toggleTheme } = useTheme();
|
| 48 |
|
|
|
|
| 114 |
{updateStatusMsg}
|
| 115 |
</button>
|
| 116 |
)}
|
| 117 |
+
{/* Logout (remote sessions only) */}
|
| 118 |
+
{onLogout && (
|
| 119 |
+
<button
|
| 120 |
+
onClick={onLogout}
|
| 121 |
+
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 122 |
+
>
|
| 123 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 124 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
| 125 |
+
</svg>
|
| 126 |
+
<span class="hidden sm:inline"><StableText tKey="dashboardLogout" class="text-xs font-semibold">{t("dashboardLogout")}</StableText></span>
|
| 127 |
+
</button>
|
| 128 |
+
)}
|
| 129 |
{/* Language Toggle */}
|
| 130 |
<button
|
| 131 |
onClick={toggleLang}
|