From bd76a89ba14813cfeff416a591902f8da77ddc92 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Mon, 13 Apr 2026 19:07:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=96=B0=E5=A2=9E=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现管理员登录和验证码验证功能 - 添加管理员登录流程,支持通过 Telegram 验证 - 实现登录挑战和会话管理,确保安全性 - 更新前端状态管理,处理登录和登出逻辑 - 增加系统配置管理,支持 Telegram 安全设置 - 新增数据库表以存储登录挑战和会话信息 --- frontend/src/App.tsx | 9 + frontend/src/api/client.ts | 69 +++- frontend/src/components/layout/AppLayout.tsx | 185 +++++++-- frontend/src/components/layout/Sidebar.tsx | 6 +- frontend/src/hooks/use-system-config.ts | 19 + frontend/src/lib/admin-auth.ts | 70 ++++ frontend/src/lib/local-cache.ts | 5 +- frontend/src/lib/system-config.ts | 62 +++ frontend/src/pages/SystemSettings.tsx | 295 ++++++++++++++ frontend/src/store/auth.ts | 77 +++- frontend/src/types/index.ts | 23 ++ src/admin/auth_api.ts | 235 +++++++++++ src/admin/auth_shared.ts | 403 +++++++++++++++++++ src/admin/billing_api.ts | 20 +- src/admin/index.ts | 66 ++- src/admin/system_api.ts | 155 +++++++ src/constants.ts | 1 + src/db/index.ts | 26 ++ src/index.ts | 16 + src/system-config.ts | 121 ++++++ type.d.ts | 35 ++ 21 files changed, 1820 insertions(+), 78 deletions(-) create mode 100644 frontend/src/hooks/use-system-config.ts create mode 100644 frontend/src/lib/admin-auth.ts create mode 100644 frontend/src/lib/system-config.ts create mode 100644 frontend/src/pages/SystemSettings.tsx create mode 100644 src/admin/auth_api.ts create mode 100644 src/admin/auth_shared.ts create mode 100644 src/admin/system_api.ts create mode 100644 src/system-config.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 082463b..d9bb1b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { Tokens } from "./pages/Tokens"; import { Pricing } from "./pages/Pricing"; import { ApiTest } from "./pages/ApiTest"; import { NotFound } from "./pages/NotFound"; +import { SystemSettings } from "./pages/SystemSettings"; const queryClient = new QueryClient({ defaultOptions: { @@ -144,6 +145,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f00e065..e042009 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,9 +9,12 @@ import { AnalyticsBreakdownDimension, AnalyticsRange, BillingConfig, + SystemConfig, + AdminLoginResponse, UsageLogFilters, UsageLogSearchData, } from '@/types' +import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth' const BASE_URL = import.meta.env.VITE_API_BASE_URL || '' @@ -30,6 +33,10 @@ export class ApiError extends Error { let onUnauthorized: (() => void) | null = null let onError: ((error: ApiError) => void) | null = null +type RequestBehavior = { + skipUnauthorizedHandler?: boolean +} + export function setErrorHandlers(handlers: { onUnauthorized?: () => void onError?: (error: ApiError) => void @@ -48,16 +55,16 @@ async function requestInterceptor(config: RequestInit): Promise { } // 添加 admin token - const adminToken = localStorage.getItem('adminToken') - if (adminToken) { - headers.set('x-admin-token', adminToken) + const credential = getStoredAdminCredential() + if (credential) { + headers.set(credential.headerName, credential.token) } return { ...config, headers } } // 响应拦截器 -async function responseInterceptor(response: Response): Promise { +async function responseInterceptor(response: Response, behavior: RequestBehavior = {}): Promise { if (!response.ok) { let errorMessage: string @@ -84,10 +91,11 @@ async function responseInterceptor(response: Response): Promise { // 处理特定状态码 switch (response.status) { case 401: - // 未授权 - 清除 token 并触发回调 - localStorage.removeItem('adminToken') - onUnauthorized?.() - error.message = '认证已过期,请重新登录' + if (!behavior.skipUnauthorizedHandler) { + clearAdminCredentials() + onUnauthorized?.() + error.message = '认证已过期,请重新登录' + } break case 403: error.message = '没有权限执行此操作' @@ -148,13 +156,17 @@ function buildQueryString(params: Record): } // 统一请求方法 -async function request(endpoint: string, options: RequestInit = {}): Promise { +async function request( + endpoint: string, + options: RequestInit = {}, + behavior: RequestBehavior = {}, +): Promise { const url = `${BASE_URL}${endpoint}` try { const config = await requestInterceptor(options) const response = await fetch(url, config) - return responseInterceptor(response) + return responseInterceptor(response, behavior) } catch (error) { // 处理网络错误 if (error instanceof TypeError && error.message.includes('fetch')) { @@ -228,6 +240,20 @@ export const apiClient = { body: JSON.stringify(config), }), + getSystemConfig: () => request>('/api/admin/system/config', { method: 'GET' }), + + saveSystemConfig: (config: SystemConfig) => + request>('/api/admin/system/config', { + method: 'POST', + body: JSON.stringify(config), + }), + + sendTelegramTestMessage: (payload: Pick) => + request>('/api/admin/system/telegram/test', { + method: 'POST', + body: JSON.stringify(payload), + }), + // Analytics APIs getAnalyticsOverview: (range: AnalyticsRange) => request>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }), @@ -292,4 +318,27 @@ export const apiClient = { // Auth check checkAuth: () => request('/api/admin/channel', { method: 'GET' }), + + startAdminLogin: (token: string) => + request>('/api/admin/auth/login', { + method: 'POST', + body: JSON.stringify({ token }), + }, { + skipUnauthorizedHandler: true, + }), + + verifyAdminLogin: (challengeId: string, code: string) => + request>('/api/admin/auth/verify', { + method: 'POST', + body: JSON.stringify({ challengeId, code }), + }, { + skipUnauthorizedHandler: true, + }), + + logoutAdmin: () => + request>('/api/admin/auth/logout', { + method: 'POST', + }, { + skipUnauthorizedHandler: true, + }), } diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index cd6a605..9ca8868 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -15,7 +15,8 @@ import { Button } from '@/components/ui/button' import { useAuthStore } from '@/store/auth' import { useToast } from '@/components/ui/use-toast' import { Alert, AlertDescription } from '@/components/ui/alert' -import { Menu, Zap } from 'lucide-react' +import { Menu, RotateCcw, Zap } from 'lucide-react' +import { parseUtcTimestamp } from '@/lib/utils' interface AppLayoutProps { children: ReactNode @@ -24,31 +25,117 @@ interface AppLayoutProps { export function AppLayout({ children }: AppLayoutProps) { const navigate = useNavigate() const [adminToken, setAdminToken] = useState('') + const [verificationCode, setVerificationCode] = useState('') + const [challengeId, setChallengeId] = useState(null) + const [challengeExpiresAt, setChallengeExpiresAt] = useState(null) const [authError, setAuthError] = useState('') - const [isLoading, setIsLoading] = useState(false) + const [authStep, setAuthStep] = useState<'token' | 'verification'>('token') + const [isSubmitting, setIsSubmitting] = useState(false) const [isMobileNavOpen, setIsMobileNavOpen] = useState(false) const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) - const { login, showAuthModal, closeAuthModal, isAuthenticated, isLoading: isAuthLoading } = useAuthStore() + const { + startLogin, + verifyLogin, + showAuthModal, + closeAuthModal, + isAuthenticated, + isLoading: isAuthLoading, + } = useAuthStore() const { addToast } = useToast() + const resetAuthDialog = () => { + setAdminToken('') + setVerificationCode('') + setChallengeId(null) + setChallengeExpiresAt(null) + setAuthError('') + setAuthStep('token') + setIsSubmitting(false) + } + + const handleCloseAuthModal = () => { + resetAuthDialog() + closeAuthModal() + } + + const finalizeLogin = () => { + resetAuthDialog() + setIsMobileNavOpen(false) + addToast('登录成功', 'success') + navigate('/dashboard', { replace: true }) + } + const handleAuthSubmit = async (e: React.FormEvent) => { e.preventDefault() setAuthError('') - setIsLoading(true) + setIsSubmitting(true) try { - await login(adminToken) - setAdminToken('') - setIsMobileNavOpen(false) - addToast('登录成功', 'success') - navigate('/dashboard', { replace: true }) + if (authStep === 'token') { + const loginResult = await startLogin(adminToken) + + if (loginResult.requiresVerification) { + setAuthStep('verification') + setChallengeId(loginResult.challengeId) + setChallengeExpiresAt(loginResult.challengeExpiresAt) + setVerificationCode('') + addToast('验证码已发送到 Telegram', 'success') + return + } + + finalizeLogin() + return + } + + if (!challengeId) { + throw new Error('验证码会话不存在,请重新获取') + } + + await verifyLogin(challengeId, verificationCode) + finalizeLogin() } catch (error) { setAuthError(error instanceof Error ? error.message : '管理员令牌无效') } finally { - setIsLoading(false) + setIsSubmitting(false) } } + const handleResendVerificationCode = async () => { + if (!adminToken.trim()) { + setAuthError('请先重新输入管理员令牌') + setAuthStep('token') + return + } + + setAuthError('') + setIsSubmitting(true) + + try { + const loginResult = await startLogin(adminToken) + if (!loginResult.requiresVerification) { + finalizeLogin() + return + } + + setAuthStep('verification') + setChallengeId(loginResult.challengeId) + setChallengeExpiresAt(loginResult.challengeExpiresAt) + setVerificationCode('') + addToast('验证码已重新发送到 Telegram', 'success') + } catch (error) { + setAuthError(error instanceof Error ? error.message : '验证码发送失败') + } finally { + setIsSubmitting(false) + } + } + + const challengeExpiresText = (() => { + const date = challengeExpiresAt ? parseUtcTimestamp(challengeExpiresAt) : null + return date + ? date.toLocaleString('zh-CN', { hour12: false }) + : '' + })() + return (
{isAuthenticated && !isAuthLoading && ( @@ -108,28 +195,58 @@ export function AppLayout({ children }: AppLayoutProps) { )} {/* Auth Dialog */} - !open && closeAuthModal()}> + !open && handleCloseAuthModal()}> 管理员身份验证 - 请输入管理员令牌以访问管理功能 + {authStep === 'token' + ? '请输入管理员令牌以访问管理功能' + : '验证码已发送到 Telegram,请输入 6 位数字验证码完成登录'}
-
- - setAdminToken(e.target.value)} - required - /> -
+ {authStep === 'token' ? ( +
+ + setAdminToken(e.target.value)} + required + /> +
+ ) : ( +
+ + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + /> +
+ {challengeExpiresText ? `验证码有效期至 ${challengeExpiresText}` : '验证码 5 分钟内有效'} + +
+
+ )} {authError && ( @@ -142,13 +259,27 @@ export function AppLayout({ children }: AppLayoutProps) { - diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2f8a983..608b598 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -14,6 +14,7 @@ import { X, PanelLeftClose, PanelLeftOpen, + SlidersHorizontal, Zap, } from "lucide-react"; import { useAuthStore } from "@/store/auth"; @@ -31,6 +32,7 @@ const navItems: NavItem[] = [ { title: "渠道管理", href: "/channels", icon: LinkIcon }, { title: "令牌管理", href: "/tokens", icon: Key }, { title: "定价管理", href: "/pricing", icon: DollarSign }, + { title: "系统设置", href: "/settings", icon: SlidersHorizontal }, { title: "API 测试", href: "/api-test", icon: TestTube2 }, ]; @@ -77,8 +79,8 @@ export function Sidebar({ } }; - const handleLogout = () => { - logout(); + const handleLogout = async () => { + await logout(); navigate("/", { replace: true }); onNavigate?.(); }; diff --git a/frontend/src/hooks/use-system-config.ts b/frontend/src/hooks/use-system-config.ts new file mode 100644 index 0000000..20b4a19 --- /dev/null +++ b/frontend/src/hooks/use-system-config.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiClient } from "@/api/client"; +import { SystemConfig } from "@/types"; +import { DEFAULT_SYSTEM_CONFIG, normalizeSystemConfig } from "@/lib/system-config"; + +export const SYSTEM_CONFIG_QUERY_KEY = ["system-config"] as const; + +export function useSystemConfig() { + return useQuery({ + queryKey: SYSTEM_CONFIG_QUERY_KEY, + queryFn: async () => { + const response = await apiClient.getSystemConfig(); + return normalizeSystemConfig(response.data as SystemConfig | undefined); + }, + staleTime: 60_000, + placeholderData: DEFAULT_SYSTEM_CONFIG, + }); +} diff --git a/frontend/src/lib/admin-auth.ts b/frontend/src/lib/admin-auth.ts new file mode 100644 index 0000000..28716f3 --- /dev/null +++ b/frontend/src/lib/admin-auth.ts @@ -0,0 +1,70 @@ +export const ADMIN_SESSION_TOKEN_KEY = "adminSessionToken"; +export const LEGACY_ADMIN_TOKEN_KEY = "adminToken"; +export const ADMIN_SESSION_HEADER = "x-admin-session"; +export const LEGACY_ADMIN_TOKEN_HEADER = "x-admin-token"; + +const canUseLocalStorage = (): boolean => { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +}; + +export const getStoredAdminSessionToken = (): string | null => { + if (!canUseLocalStorage()) { + return null; + } + + const value = window.localStorage.getItem(ADMIN_SESSION_TOKEN_KEY); + return value && value.trim().length > 0 ? value.trim() : null; +}; + +export const getStoredLegacyAdminToken = (): string | null => { + if (!canUseLocalStorage()) { + return null; + } + + const value = window.localStorage.getItem(LEGACY_ADMIN_TOKEN_KEY); + return value && value.trim().length > 0 ? value.trim() : null; +}; + +export const getStoredAdminCredential = (): { headerName: string; token: string } | null => { + const sessionToken = getStoredAdminSessionToken(); + if (sessionToken) { + return { + headerName: ADMIN_SESSION_HEADER, + token: sessionToken, + }; + } + + const legacyToken = getStoredLegacyAdminToken(); + if (legacyToken) { + return { + headerName: LEGACY_ADMIN_TOKEN_HEADER, + token: legacyToken, + }; + } + + return null; +}; + +export const storeAdminSessionToken = (token: string): void => { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY); + window.localStorage.setItem(ADMIN_SESSION_TOKEN_KEY, token); +}; + +export const clearAdminCredentials = (): void => { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(ADMIN_SESSION_TOKEN_KEY); + window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY); +}; + +export const getAdminCredentialScopeToken = (): string => { + return getStoredAdminSessionToken() + || getStoredLegacyAdminToken() + || "anonymous"; +}; diff --git a/frontend/src/lib/local-cache.ts b/frontend/src/lib/local-cache.ts index 3f9ec0a..ed9f7dd 100644 --- a/frontend/src/lib/local-cache.ts +++ b/frontend/src/lib/local-cache.ts @@ -1,6 +1,7 @@ +import { getAdminCredentialScopeToken } from "@/lib/admin-auth"; + const CACHE_PREFIX = "one-api-cache"; const CACHE_VERSION = 1; -const ADMIN_TOKEN_KEY = "adminToken"; export interface LocalCacheEntry { version: number; @@ -15,7 +16,7 @@ const getAdminTokenScope = (): string => { return "server"; } - const adminToken = window.localStorage.getItem(ADMIN_TOKEN_KEY) || "anonymous"; + const adminToken = getAdminCredentialScopeToken(); let hash = 2166136261; for (let index = 0; index < adminToken.length; index += 1) { diff --git a/frontend/src/lib/system-config.ts b/frontend/src/lib/system-config.ts new file mode 100644 index 0000000..851d814 --- /dev/null +++ b/frontend/src/lib/system-config.ts @@ -0,0 +1,62 @@ +import { type AdminSecurityConfig, type ApiDocsConfig, type SystemConfig } from "@/types"; +import { + DEFAULT_BILLING_DISPLAY_DECIMALS, + normalizeBillingDisplayDecimals, +} from "@/lib/billing"; + +export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = { + enabled: false, + telegramBotToken: "", + telegramChatId: "", +}; + +export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { + enabled: true, +}; + +export const DEFAULT_SYSTEM_CONFIG: SystemConfig = { + displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS, + adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG, + apiDocs: DEFAULT_API_DOCS_CONFIG, +}; + +export const PRECISION_OPTIONS = [ + { label: "低", value: 2 }, + { label: "中", value: 4 }, + { label: "高", value: 6 }, + { label: "超高", value: 9 }, +] as const; + +const normalizeString = (value: unknown): string => { + return typeof value === "string" ? value.trim() : ""; +}; + +export const normalizeAdminSecurityConfig = (value?: Partial | null): AdminSecurityConfig => { + return { + enabled: value?.enabled === true, + telegramBotToken: normalizeString(value?.telegramBotToken), + telegramChatId: normalizeString(value?.telegramChatId), + }; +}; + +export const normalizeApiDocsConfig = (value?: Partial | null): ApiDocsConfig => { + return { + enabled: value?.enabled ?? DEFAULT_API_DOCS_CONFIG.enabled, + }; +}; + +export const normalizeSystemConfig = (value?: Partial | null): SystemConfig => { + return { + displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals), + adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity), + apiDocs: normalizeApiDocsConfig(value?.apiDocs), + }; +}; + +export const isTelegramSecurityEnabled = (config?: Partial | null): boolean => { + return config?.enabled === true + && typeof config.telegramBotToken === "string" + && config.telegramBotToken.trim().length > 0 + && typeof config.telegramChatId === "string" + && config.telegramChatId.trim().length > 0; +}; diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx new file mode 100644 index 0000000..ce67b39 --- /dev/null +++ b/frontend/src/pages/SystemSettings.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Bot, Check, FileText, Globe, Shield, SlidersHorizontal } from "lucide-react"; + +import { apiClient } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { BILLING_CONFIG_QUERY_KEY } from "@/hooks/use-billing-config"; +import { SYSTEM_CONFIG_QUERY_KEY, useSystemConfig } from "@/hooks/use-system-config"; +import { DEFAULT_SYSTEM_CONFIG, normalizeSystemConfig, PRECISION_OPTIONS } from "@/lib/system-config"; +import { PageContainer } from "@/components/ui/page-container"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; + +const buildAbsoluteUrl = (pathname: string): string => { + if (typeof window === "undefined") { + return pathname; + } + + const baseUrl = API_BASE_URL ? new URL(API_BASE_URL, window.location.origin) : new URL(window.location.origin); + + return new URL(pathname, baseUrl).toString(); +}; + +export function SystemSettings() { + const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG); + + const { addToast } = useToast(); + const queryClient = useQueryClient(); + const systemConfigQuery = useSystemConfig(); + + useEffect(() => { + if (systemConfigQuery.data) { + setSystemConfig(normalizeSystemConfig(systemConfigQuery.data)); + } + }, [systemConfigQuery.data]); + + const saveSystemConfigMutation = useMutation({ + mutationFn: async () => apiClient.saveSystemConfig(normalizeSystemConfig(systemConfig)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SYSTEM_CONFIG_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: BILLING_CONFIG_QUERY_KEY }); + addToast("系统设置已保存", "success"); + }, + onError: (error: Error) => { + addToast(`保存失败:${error.message}`, "error"); + }, + }); + + const telegramTestMutation = useMutation({ + mutationFn: async () => + apiClient.sendTelegramTestMessage({ + telegramBotToken: systemConfig.adminSecurity.telegramBotToken, + telegramChatId: systemConfig.adminSecurity.telegramChatId, + }), + onSuccess: () => { + addToast("测试消息已发送,请到 Telegram 查收", "success"); + }, + onError: (error: Error) => { + addToast(`测试失败:${error.message}`, "error"); + }, + }); + + const docsLinks = useMemo( + () => [ + { label: "Swagger UI", href: buildAbsoluteUrl("/api/docs") }, + { label: "ReDoc", href: buildAbsoluteUrl("/api/redocs") }, + { label: "OpenAPI JSON", href: buildAbsoluteUrl("/api/openapi.json") }, + ], + [], + ); + + const currentPrecisionOption = PRECISION_OPTIONS.find((option) => option.value === systemConfig.displayDecimals); + const telegramConfigComplete = + systemConfig.adminSecurity.telegramBotToken.trim().length > 0 && + systemConfig.adminSecurity.telegramChatId.trim().length > 0; + const securityEnabled = systemConfig.adminSecurity.enabled && telegramConfigComplete; + + return ( + saveSystemConfigMutation.mutate()} + disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading} + > + + 保存 + + } + > + +
+
+
+ +
+
+
+
+

安全设置

+

+ 绑定 Telegram 后自动开启登录验证和通知功能。 +

+
+ + setSystemConfig((current) => ({ + ...current, + adminSecurity: { + ...current.adminSecurity, + enabled: checked, + }, + })) + } + /> +
+
+
+ +
+
+ + + setSystemConfig((current) => ({ + ...current, + adminSecurity: { + ...current.adminSecurity, + telegramBotToken: event.target.value, + }, + })) + } + /> +
+ +
+ + + setSystemConfig((current) => ({ + ...current, + adminSecurity: { + ...current.adminSecurity, + telegramChatId: event.target.value, + }, + })) + } + /> +
+
+ +
+
+
+
+ + 发送测试消息 +
+

+ {!telegramConfigComplete + ? "请确保 Telegram Bot Token 和 Chat ID 已正确填写,测试通过后将启用登录验证和通知功能。" + : securityEnabled + ? "当前已开启 Telegram 登录验证,管理员必须输入验证码才能登录。" + : "Telegram 已配置但尚未启用。可以先发送测试消息确认,再决定是否开启登录验证。"} +

+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+

显示精度设置

+

+ 页面显示金额保留 {systemConfig.displayDecimals} 位小数, 示例:$ + {(0).toFixed(systemConfig.displayDecimals)} + {currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""} +

+
+
+
+
+ {PRECISION_OPTIONS.map((option) => { + const isActive = systemConfig.displayDecimals === option.value; + return ( + + ); + })} +
+
+ +
+ +
+
+
+ +
+
+
+
+

开启接口文档

+

+ 开启后自动暴露 Swagger、ReDoc 与 OpenAPI JSON;关闭后对应路由会直接返回 404。 +

+
+ + setSystemConfig((current) => ({ + ...current, + apiDocs: { + ...current.apiDocs, + enabled: checked, + }, + })) + } + /> +
+
+
+ + {systemConfig.apiDocs.enabled ? ( +
+ {docsLinks.map((item) => ( +
+
+
{item.label}
+
{item.href}
+
+ +
+ ))} +
+ ) : ( + "" + )} +
+ + + ); +} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 6a281ef..8313919 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,14 +1,21 @@ import { create } from 'zustand' import { apiClient } from '@/api/client' import { clearScopedCacheByPrefix } from '@/lib/local-cache' +import { type AdminLoginResponse } from '@/types' +import { + clearAdminCredentials, + getStoredAdminCredential, + storeAdminSessionToken, +} from '@/lib/admin-auth' interface AuthState { isAuthenticated: boolean isLoading: boolean error: string | null showAuthModal: boolean - login: (token: string) => Promise - logout: () => void + startLogin: (token: string) => Promise + verifyLogin: (challengeId: string, code: string) => Promise + logout: () => Promise checkAuth: () => Promise openAuthModal: () => void closeAuthModal: () => void @@ -20,18 +27,31 @@ export const useAuthStore = create((set) => ({ error: null, showAuthModal: false, - login: async (token: string) => { + startLogin: async (token: string) => { set({ isLoading: true, error: null }) + try { - // Store token - localStorage.setItem('adminToken', token) + const response = await apiClient.startAdminLogin(token) + const loginResult = response.data as AdminLoginResponse - // Verify token by making a test request - await apiClient.checkAuth() + if (!loginResult) { + throw new Error('登录响应无效') + } - set({ isAuthenticated: true, isLoading: false, showAuthModal: false }) + if (loginResult.sessionToken) { + storeAdminSessionToken(loginResult.sessionToken) + set({ isAuthenticated: true, isLoading: false, showAuthModal: false }) + return loginResult + } + + if (!loginResult.requiresVerification || !loginResult.challengeId) { + throw new Error('登录响应缺少验证码挑战信息') + } + + set({ isAuthenticated: false, isLoading: false }) + return loginResult } catch (error) { - localStorage.removeItem('adminToken') + clearAdminCredentials() set({ isAuthenticated: false, isLoading: false, @@ -41,16 +61,45 @@ export const useAuthStore = create((set) => ({ } }, - logout: () => { + verifyLogin: async (challengeId: string, code: string) => { + set({ isLoading: true, error: null }) + + try { + const response = await apiClient.verifyAdminLogin(challengeId, code) + const loginResult = response.data as AdminLoginResponse + + if (!loginResult.sessionToken) { + throw new Error('登录响应缺少会话令牌') + } + + storeAdminSessionToken(loginResult.sessionToken) + set({ isAuthenticated: true, isLoading: false, showAuthModal: false }) + } catch (error) { + clearAdminCredentials() + set({ + isAuthenticated: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Verification failed', + }) + throw error + } + }, + + logout: async () => { + try { + await apiClient.logoutAdmin() + } catch { + // ignore logout errors and clear local state anyway + } + clearScopedCacheByPrefix('analytics:') clearScopedCacheByPrefix('usage-logs:') - localStorage.removeItem('adminToken') + clearAdminCredentials() set({ isAuthenticated: false, error: null }) }, checkAuth: async () => { - const token = localStorage.getItem('adminToken') - if (!token) { + if (!getStoredAdminCredential()) { set({ isAuthenticated: false, isLoading: false }) return } @@ -60,7 +109,7 @@ export const useAuthStore = create((set) => ({ await apiClient.checkAuth() set({ isAuthenticated: true, isLoading: false }) } catch (error) { - localStorage.removeItem('adminToken') + clearAdminCredentials() set({ isAuthenticated: false, isLoading: false }) } }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 44e853f..a950a7e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -42,6 +42,29 @@ export interface BillingConfig { displayDecimals: number } +export interface AdminSecurityConfig { + enabled: boolean + telegramBotToken: string + telegramChatId: string +} + +export interface ApiDocsConfig { + enabled: boolean +} + +export interface SystemConfig extends BillingConfig { + adminSecurity: AdminSecurityConfig + apiDocs: ApiDocsConfig +} + +export interface AdminLoginResponse { + requiresVerification: boolean + challengeId: string | null + challengeExpiresAt: string | null + sessionToken: string | null + sessionExpiresAt: string | null +} + export interface PricingModel { input?: number output?: number diff --git a/src/admin/auth_api.ts b/src/admin/auth_api.ts new file mode 100644 index 0000000..00400fd --- /dev/null +++ b/src/admin/auth_api.ts @@ -0,0 +1,235 @@ +import { Context } from "hono"; +import { OpenAPIRoute } from "chanfana"; +import { z } from "zod"; + +import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; +import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"; +import { + createAdminLoginChallenge, + createAdminSession, + deleteAdminLoginChallenge, + getAdminSessionHeaderName, + invalidateAdminSession, + sendAdminLoginCodeNotification, + sendAdminLoginResultNotification, + verifyAdminLoginChallenge, +} from "./auth_shared"; + +const loginStartRequestSchema = z.object({ + token: z.string().trim().min(1, "管理员令牌不能为空"), +}); + +const loginStartResponseSchema = z.object({ + requiresVerification: z.boolean(), + challengeId: z.nullable(z.string()), + challengeExpiresAt: z.nullable(z.string()), + sessionToken: z.nullable(z.string()), + sessionExpiresAt: z.nullable(z.string()), +}); + +const loginVerifyRequestSchema = z.object({ + challengeId: z.string().trim().min(1, "challengeId 不能为空"), + code: z.string().trim().regex(/^\d{6}$/, "验证码必须为 6 位数字"), +}); + +const buildDirectLoginResponse = async ( + c: Context +) => { + const session = await createAdminSession(c); + + return { + success: true, + data: { + requiresVerification: false, + challengeId: null, + challengeExpiresAt: null, + sessionToken: session.sessionToken, + sessionExpiresAt: session.expiresAt, + }, + message: "Login successful", + } as CommonResponse; +}; + +export class AdminLoginStartEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Start admin login", + request: { + body: { + content: { + "application/json": { + schema: loginStartRequestSchema, + }, + }, + }, + }, + responses: { + ...CommonSuccessfulResponse(loginStartResponseSchema), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + const parsedBody = loginStartRequestSchema.safeParse( + await c.req.json<{ token: string }>() + ); + if (!parsedBody.success) { + return c.text(parsedBody.error.issues[0]?.message || "管理员令牌不能为空", 400); + } + + const body = parsedBody.data; + const systemConfig = await getSystemConfig(c); + const securityConfig = systemConfig.adminSecurity; + const securityEnabled = isTelegramSecurityEnabled(securityConfig); + + if (!c.env.ADMIN_TOKEN || body.token !== c.env.ADMIN_TOKEN) { + if (securityEnabled) { + await sendAdminLoginResultNotification( + securityConfig, + c, + "failure", + "管理员令牌错误" + ).catch((error) => { + console.error("Failed to send Telegram login failure notification:", error); + }); + } + + return c.text("管理员令牌无效", 401); + } + + if (!securityEnabled) { + return buildDirectLoginResponse(c); + } + + const challenge = await createAdminLoginChallenge(c); + + try { + await sendAdminLoginCodeNotification( + securityConfig, + c, + challenge.code, + challenge.expiresAt + ); + } catch (error) { + await deleteAdminLoginChallenge(c, challenge.challengeId); + + return c.text( + `Telegram 发送验证码失败:${error instanceof Error ? error.message : "unknown error"}`, + 500 + ); + } + + return { + success: true, + data: { + requiresVerification: true, + challengeId: challenge.challengeId, + challengeExpiresAt: challenge.expiresAt, + sessionToken: null, + sessionExpiresAt: null, + }, + message: "Verification code sent", + } as CommonResponse; + } +} + +export class AdminLoginVerifyEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Verify admin login code", + request: { + body: { + content: { + "application/json": { + schema: loginVerifyRequestSchema, + }, + }, + }, + }, + responses: { + ...CommonSuccessfulResponse(loginStartResponseSchema), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + const parsedBody = loginVerifyRequestSchema.safeParse( + await c.req.json<{ challengeId: string; code: string }>() + ); + if (!parsedBody.success) { + return c.text(parsedBody.error.issues[0]?.message || "验证码格式无效", 400); + } + + const body = parsedBody.data; + const systemConfig = await getSystemConfig(c); + const securityConfig = systemConfig.adminSecurity; + + if (!isTelegramSecurityEnabled(securityConfig)) { + return c.text("当前未开启 Telegram 登录验证", 400); + } + + const result = await verifyAdminLoginChallenge( + c, + body.challengeId, + body.code + ); + + if (!result.ok) { + await sendAdminLoginResultNotification( + securityConfig, + c, + "failure", + result.reason + ).catch((error) => { + console.error("Failed to send Telegram login failure notification:", error); + }); + + return c.text(result.reason || "验证码验证失败", 401); + } + + const session = await createAdminSession(c); + + await sendAdminLoginResultNotification( + securityConfig, + c, + "success" + ).catch((error) => { + console.error("Failed to send Telegram login success notification:", error); + }); + + return { + success: true, + data: { + requiresVerification: false, + challengeId: null, + challengeExpiresAt: null, + sessionToken: session.sessionToken, + sessionExpiresAt: session.expiresAt, + }, + message: "Login verified successfully", + } as CommonResponse; + } +} + +export class AdminLogoutEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Logout admin session", + responses: { + ...CommonSuccessfulResponse(z.boolean()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + const sessionToken = c.req.header(getAdminSessionHeaderName()); + + await invalidateAdminSession(c, sessionToken); + + return { + success: true, + data: true, + message: "Logout successful", + } as CommonResponse; + } +} diff --git a/src/admin/auth_shared.ts b/src/admin/auth_shared.ts new file mode 100644 index 0000000..2977178 --- /dev/null +++ b/src/admin/auth_shared.ts @@ -0,0 +1,403 @@ +import { Context } from "hono"; + +const ADMIN_SESSION_HEADER = "x-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"; + +type LoginRequestMetadata = { + clientIp: string; + country: string; + region: string; + city: string; + colo: string; + timezone: string; +}; + +const firstNonEmpty = (...values: Array): string => { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + + return ""; +}; + +const formatTimestampInTimezone = (date: Date, timezone: string): string => { + const formatter = new Intl.DateTimeFormat("zh-CN", { + timeZone: timezone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const parts = formatter.formatToParts(date); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + + return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second} (${timezone})`; +}; + +const getRequestMetadata = (c: Context): LoginRequestMetadata => { + const requestCf = (c.req.raw.cf || {}) as Partial>; + + return { + clientIp: firstNonEmpty( + c.req.header("cf-connecting-ip"), + c.req.header("x-real-ip"), + c.req.header("x-forwarded-for")?.split(",")[0] + ), + country: firstNonEmpty(requestCf.country), + region: firstNonEmpty(requestCf.region, requestCf.regionCode), + city: firstNonEmpty(requestCf.city), + colo: firstNonEmpty(requestCf.colo), + timezone: firstNonEmpty(requestCf.timezone), + }; +}; + +const getLocationText = (metadata: LoginRequestMetadata): string => { + const location = [metadata.country, metadata.region, metadata.city] + .filter(Boolean) + .join(" / "); + + return location || "未知位置"; +}; + +const createTelegramMessage = ( + lines: Array +): string => { + return lines.filter(Boolean).join("\n"); +}; + +const generateNumericCode = (): string => { + return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); +}; + +const generateSessionToken = (): string => { + return `${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`; +}; + +const toSha256Hex = async (value: string): Promise => { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; + +const cleanupExpiredArtifacts = async (c: Context) => { + const now = new Date().toISOString(); + + await Promise.all([ + c.env.DB.prepare( + `DELETE FROM admin_login_challenge WHERE expires_at <= ?` + ).bind(now).run(), + c.env.DB.prepare( + `DELETE FROM admin_session WHERE expires_at <= ?` + ).bind(now).run(), + ]); +}; + +const sendTelegramMessage = async ( + securityConfig: AdminSecurityConfig, + text: string +): Promise => { + const response = await fetch( + `https://api.telegram.org/bot${securityConfig.telegramBotToken}/sendMessage`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: securityConfig.telegramChatId, + text, + }), + } + ); + + const payload = await response.json() + .catch(() => ({})) as { ok?: boolean; description?: string }; + + if (!response.ok || payload.ok !== true) { + throw new Error(payload.description || `HTTP ${response.status}`); + } +}; + +const buildBaseMessageLines = ( + metadata: LoginRequestMetadata, + occurredAt: Date +): Array => { + const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE; + + return [ + `时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`, + `时间(系统):${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`, + `IP:${metadata.clientIp || "未知 IP"}`, + `位置:${getLocationText(metadata)}`, + `节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`, + ]; +}; + +export const getAdminSessionHeaderName = (): string => { + return ADMIN_SESSION_HEADER; +}; + +export const createAdminSession = async ( + c: Context +): Promise<{ sessionToken: string; expiresAt: string }> => { + await cleanupExpiredArtifacts(c); + + const sessionToken = generateSessionToken(); + const tokenHash = await toSha256Hex(sessionToken); + const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_MS).toISOString(); + + await c.env.DB.prepare( + `INSERT INTO admin_session (token_hash, expires_at) + VALUES (?, ?)` + ).bind(tokenHash, expiresAt).run(); + + return { + sessionToken, + expiresAt, + }; +}; + +export const invalidateAdminSession = async ( + c: Context, + sessionToken: string | null | undefined +): Promise => { + if (!sessionToken) { + return; + } + + const tokenHash = await toSha256Hex(sessionToken); + + await c.env.DB.prepare( + `DELETE FROM admin_session WHERE token_hash = ?` + ).bind(tokenHash).run(); +}; + +export const validateAdminSession = async ( + c: Context, + sessionToken: string | null | undefined +): Promise => { + if (!sessionToken) { + return false; + } + + await cleanupExpiredArtifacts(c); + + const tokenHash = await toSha256Hex(sessionToken); + const session = await c.env.DB.prepare( + `SELECT token_hash, expires_at FROM admin_session WHERE token_hash = ?` + ).bind(tokenHash).first>(); + + if (!session?.token_hash) { + return false; + } + + if (Date.parse(session.expires_at) <= Date.now()) { + await c.env.DB.prepare( + `DELETE FROM admin_session WHERE token_hash = ?` + ).bind(tokenHash).run(); + return false; + } + + await c.env.DB.prepare( + `UPDATE admin_session + SET last_used_at = datetime('now'), + updated_at = datetime('now') + WHERE token_hash = ?` + ).bind(tokenHash).run(); + + return true; +}; + +export const createAdminLoginChallenge = async ( + c: Context +): Promise<{ challengeId: string; code: string; expiresAt: string }> => { + await cleanupExpiredArtifacts(c); + + const challengeId = crypto.randomUUID(); + const code = generateNumericCode(); + const codeHash = await toSha256Hex(code); + const expiresAt = new Date(Date.now() + ADMIN_LOGIN_CHALLENGE_TTL_MS).toISOString(); + const metadata = getRequestMetadata(c); + + await c.env.DB.prepare( + `INSERT INTO admin_login_challenge ( + id, + code_hash, + expires_at, + attempts, + max_attempts, + request_ip, + request_country, + request_region, + request_city, + request_colo, + request_timezone + ) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + challengeId, + codeHash, + expiresAt, + ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS, + metadata.clientIp, + metadata.country, + metadata.region, + metadata.city, + metadata.colo, + metadata.timezone + ).run(); + + return { + challengeId, + code, + expiresAt, + }; +}; + +export const deleteAdminLoginChallenge = async ( + c: Context, + challengeId: string +): Promise => { + await c.env.DB.prepare( + `DELETE FROM admin_login_challenge WHERE id = ?` + ).bind(challengeId).run(); +}; + +export const verifyAdminLoginChallenge = async ( + c: Context, + challengeId: string, + code: string +): Promise<{ + ok: boolean; + reason?: string; +}> => { + await cleanupExpiredArtifacts(c); + + const challenge = await c.env.DB.prepare( + `SELECT * FROM admin_login_challenge WHERE id = ?` + ).bind(challengeId).first(); + + if (!challenge?.id) { + return { + ok: false, + reason: "验证码已失效,请重新获取", + }; + } + + if (Date.parse(challenge.expires_at) <= Date.now()) { + await deleteAdminLoginChallenge(c, challengeId); + return { + ok: false, + reason: "验证码已过期,请重新获取", + }; + } + + if ((challenge.attempts || 0) >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) { + await deleteAdminLoginChallenge(c, challengeId); + return { + ok: false, + reason: "验证码尝试次数过多,请重新获取", + }; + } + + const codeHash = await toSha256Hex(code); + + if (codeHash !== challenge.code_hash) { + const nextAttempts = (challenge.attempts || 0) + 1; + + if (nextAttempts >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) { + await deleteAdminLoginChallenge(c, challengeId); + return { + ok: false, + reason: "验证码错误,已达到最大尝试次数", + }; + } + + await c.env.DB.prepare( + `UPDATE admin_login_challenge + SET attempts = ?, updated_at = datetime('now') + WHERE id = ?` + ).bind(nextAttempts, challengeId).run(); + + return { + ok: false, + reason: "验证码错误", + }; + } + + await deleteAdminLoginChallenge(c, challengeId); + + return { + ok: true, + }; +}; + +export const sendAdminLoginCodeNotification = async ( + securityConfig: AdminSecurityConfig, + c: Context, + code: string, + expiresAt: string +): Promise => { + const metadata = getRequestMetadata(c); + const occurredAt = new Date(); + const expiresDate = new Date(expiresAt); + const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE; + + await sendTelegramMessage( + securityConfig, + createTelegramMessage([ + "【One API 管理后台】", + `登录验证码:${code}`, + `过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`, + ...buildBaseMessageLines(metadata, occurredAt), + ]) + ); +}; + +export const sendAdminLoginResultNotification = async ( + securityConfig: AdminSecurityConfig, + c: Context, + status: "success" | "failure", + reason?: string +): Promise => { + const metadata = getRequestMetadata(c); + const occurredAt = new Date(); + + await sendTelegramMessage( + securityConfig, + createTelegramMessage([ + "【One API 管理后台】", + `登录${status === "success" ? "成功" : "失败"}`, + reason ? `原因:${reason}` : "", + ...buildBaseMessageLines(metadata, occurredAt), + ]) + ); +}; + +export const sendTelegramTestNotification = async ( + securityConfig: AdminSecurityConfig, + c: Context +): Promise => { + const metadata = getRequestMetadata(c); + const occurredAt = new Date(); + + await sendTelegramMessage( + securityConfig, + createTelegramMessage([ + "【One API 管理后台】", + "Telegram 绑定测试成功", + ...buildBaseMessageLines(metadata, occurredAt), + ]) + ); +}; diff --git a/src/admin/billing_api.ts b/src/admin/billing_api.ts index 5f8573f..c4b85e9 100644 --- a/src/admin/billing_api.ts +++ b/src/admin/billing_api.ts @@ -2,10 +2,9 @@ import { Context } from "hono"; import { OpenAPIRoute } from "chanfana"; import { z } from "zod"; -import { CONSTANTS } from "../constants"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; import { BillingConfig, normalizeBillingConfig } from "../billing"; -import { getJsonSetting, saveSetting } from "../utils"; +import { getSystemConfig, saveSystemConfig } from "../system-config"; const billingConfigSchema = z.object({ displayDecimals: z.number().int().min(0).max(9), @@ -22,14 +21,9 @@ export class BillingConfigGetEndpoint extends OpenAPIRoute { }; async handle(c: Context) { - const billingConfig = await getJsonSetting( - c, - CONSTANTS.BILLING_CONFIG_KEY - ); - return { success: true, - data: normalizeBillingConfig(billingConfig), + data: normalizeBillingConfig(await getSystemConfig(c)), } as CommonResponse; } } @@ -55,13 +49,13 @@ export class BillingConfigUpdateEndpoint extends OpenAPIRoute { async handle(c: Context) { const body = await c.req.json(); + const currentSystemConfig = await getSystemConfig(c); const config = normalizeBillingConfig(body); - await saveSetting( - c, - CONSTANTS.BILLING_CONFIG_KEY, - JSON.stringify(config) - ); + await saveSystemConfig(c, { + ...currentSystemConfig, + displayDecimals: config.displayDecimals, + }); return { success: true, diff --git a/src/admin/index.ts b/src/admin/index.ts index 4476a24..33606df 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -14,6 +14,16 @@ import { import { BillingConfigGetEndpoint, BillingConfigUpdateEndpoint } from "./billing_api" +import { + SystemConfigGetEndpoint, + SystemConfigUpdateEndpoint, + TelegramTestMessageEndpoint, +} from "./system_api" +import { + AdminLoginStartEndpoint, + AdminLoginVerifyEndpoint, + AdminLogoutEndpoint, +} from "./auth_api" import { AnalyticsOverviewEndpoint, AnalyticsTrendEndpoint, @@ -21,28 +31,61 @@ import { AnalyticsEventsEndpoint, UsageLogSearchEndpoint, } from "./analytics_api" +import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config" +import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared" const app = new Hono() export const api = fromHono(app) -// Authentication Middleware - using environment variable -app.use('/api/admin/*', async (c, next) => { - const token = c.req.header('x-admin-token'); - const adminToken = c.env.ADMIN_TOKEN; - - if (!token || !adminToken || token !== adminToken) { - return c.text("Unauthorized", 401); - } - await next(); -}); +const PUBLIC_AUTH_ROUTES = new Set([ + "/api/admin/auth/login", + "/api/admin/auth/verify", +]); app.use('/api/admin/*', async (c, next) => { await db.ensureReady(c); await next(); }); +// Authentication Middleware - using environment variable or admin session +app.use('/api/admin/*', async (c, next) => { + if (PUBLIC_AUTH_ROUTES.has(c.req.path)) { + await next(); + return; + } + + const sessionToken = c.req.header(getAdminSessionHeaderName()); + + if (await validateAdminSession(c, sessionToken)) { + await next(); + return; + } + + const systemConfig = await getSystemConfig(c); + const securityEnabled = isTelegramSecurityEnabled(systemConfig.adminSecurity); + const token = c.req.header('x-admin-token'); + const adminToken = c.env.ADMIN_TOKEN; + + if (!securityEnabled && token && adminToken && token === adminToken) { + await next(); + return; + } + + return c.text( + securityEnabled + ? "Telegram 登录验证已开启,请先完成验证码登录" + : "Unauthorized", + 401 + ); +}); + api.post("/api/admin/db_initialize", DBInitializeEndpoint) +// Authentication routes +api.post("/api/admin/auth/login", AdminLoginStartEndpoint) +api.post("/api/admin/auth/verify", AdminLoginVerifyEndpoint) +api.post("/api/admin/auth/logout", AdminLogoutEndpoint) + api.get("/api/admin/channel", ChannelGetEndpoint) api.post("/api/admin/channel/:key", ChannelUpsertEndpoint) api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint) @@ -59,6 +102,9 @@ api.get("/api/admin/pricing", PricingGetEndpoint) api.post("/api/admin/pricing", PricingUpdateEndpoint) api.get("/api/admin/billing/config", BillingConfigGetEndpoint) api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint) +api.get("/api/admin/system/config", SystemConfigGetEndpoint) +api.post("/api/admin/system/config", SystemConfigUpdateEndpoint) +api.post("/api/admin/system/telegram/test", TelegramTestMessageEndpoint) // Analytics management routes api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint) diff --git a/src/admin/system_api.ts b/src/admin/system_api.ts new file mode 100644 index 0000000..410c4a5 --- /dev/null +++ b/src/admin/system_api.ts @@ -0,0 +1,155 @@ +import { Context } from "hono"; +import { OpenAPIRoute } from "chanfana"; +import { z } from "zod"; + +import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; +import { + getSystemConfig, + isTelegramSecurityEnabled, + normalizeAdminSecurityConfig, + normalizeSystemConfig, + saveSystemConfig, +} from "../system-config"; +import { sendTelegramTestNotification } from "./auth_shared"; + +const adminSecurityConfigSchema = z.object({ + enabled: z.boolean(), + telegramBotToken: z.string(), + telegramChatId: z.string(), +}); + +const apiDocsConfigSchema = z.object({ + enabled: z.boolean(), +}); + +const systemConfigSchema = z.object({ + displayDecimals: z.number().int().min(0).max(9), + adminSecurity: adminSecurityConfigSchema, + apiDocs: apiDocsConfigSchema, +}); + +const telegramTestRequestSchema = z.object({ + telegramBotToken: z.string().trim().min(1, "Bot Token 不能为空"), + telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"), +}); + +const ensureSystemConfigValid = (config: SystemConfig) => { + if ( + config.adminSecurity.enabled + && !isTelegramSecurityEnabled(config.adminSecurity) + ) { + throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID"); + } +}; + +export class SystemConfigGetEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Get system configuration", + responses: { + ...CommonSuccessfulResponse(systemConfigSchema), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + return { + success: true, + data: await getSystemConfig(c), + } as CommonResponse; + } +} + +export class SystemConfigUpdateEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Update system configuration", + request: { + body: { + content: { + "application/json": { + schema: systemConfigSchema, + }, + }, + }, + }, + responses: { + ...CommonSuccessfulResponse(systemConfigSchema), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + const body = await c.req.json(); + const config = normalizeSystemConfig(body); + + try { + ensureSystemConfigValid(config); + } catch (error) { + return c.text( + error instanceof Error ? error.message : "系统设置无效", + 400 + ); + } + + return { + success: true, + data: await saveSystemConfig(c, config), + message: "System config updated successfully", + } as CommonResponse; + } +} + +export class TelegramTestMessageEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Send Telegram test notification", + request: { + body: { + content: { + "application/json": { + schema: telegramTestRequestSchema, + }, + }, + }, + }, + responses: { + ...CommonSuccessfulResponse(z.boolean()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + const parsedBody = telegramTestRequestSchema.safeParse( + await c.req.json>() + ); + if (!parsedBody.success) { + return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400); + } + + const body = parsedBody.data; + const securityConfig = { + ...normalizeAdminSecurityConfig(body), + enabled: true, + } satisfies AdminSecurityConfig; + + if (!isTelegramSecurityEnabled(securityConfig)) { + return c.text("请先填写有效的 Bot Token 和 Chat ID", 400); + } + + try { + await sendTelegramTestNotification(securityConfig, c); + } catch (error) { + return c.text( + `Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`, + 500 + ); + } + + return { + success: true, + data: true, + message: "Telegram test message sent successfully", + } as CommonResponse; + } +} diff --git a/src/constants.ts b/src/constants.ts index 7f9ccfb..883f0f8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,4 +4,5 @@ export const CONSTANTS = { DB_VERSION: "v0.0.6", MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG", BILLING_CONFIG_KEY: "BILLING_CONFIG", + SYSTEM_CONFIG_KEY: "SYSTEM_CONFIG", } diff --git a/src/db/index.ts b/src/db/index.ts index 12377bc..9e9b5ff 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -24,6 +24,32 @@ CREATE TABLE IF NOT EXISTS settings ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS admin_login_challenge ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 5, + request_ip TEXT DEFAULT '', + request_country TEXT DEFAULT '', + request_region TEXT DEFAULT '', + request_city TEXT DEFAULT '', + request_colo TEXT DEFAULT '', + request_timezone TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS admin_session ( + token_hash TEXT PRIMARY KEY, + expires_at TEXT NOT NULL, + last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_admin_login_challenge_expires_at + ON admin_login_challenge (expires_at); +CREATE INDEX IF NOT EXISTS idx_admin_session_expires_at + ON admin_session (expires_at); ` let dbReadyPromise: Promise | null = null; diff --git a/src/index.ts b/src/index.ts index ec4e039..541e09f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { cors } from 'hono/cors'; import { api as providerApi } from './providers' import { api as adminApi } from './admin' import { fromHono } from 'chanfana'; +import db from './db'; +import { getSystemConfig } from './system-config'; const FRONTEND_ENTRY = '/' const FRONTEND_STATIC_PATHS = new Set([ @@ -22,6 +24,11 @@ const FRONTEND_STATIC_PREFIXES = [ '/src/', ] const LOCAL_DEV_HOSTNAMES = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost']) +const API_DOC_ROUTE_PATHS = new Set([ + '/api/docs', + '/api/redocs', + '/api/openapi.json', +]) type AppContext = Context function isApiRequest(pathname: string): boolean { @@ -96,6 +103,15 @@ openapi.use('/*', cors()); app.use('*', async (c, next) => { const requestUrl = new URL(c.req.url) + if (API_DOC_ROUTE_PATHS.has(requestUrl.pathname)) { + await db.ensureReady(c); + + const systemConfig = await getSystemConfig(c); + if (!systemConfig.apiDocs.enabled) { + return c.notFound(); + } + } + if (isApiRequest(requestUrl.pathname)) { await next() return diff --git a/src/system-config.ts b/src/system-config.ts new file mode 100644 index 0000000..fbe9a93 --- /dev/null +++ b/src/system-config.ts @@ -0,0 +1,121 @@ +import { Context } from "hono"; + +import { + DEFAULT_BILLING_DISPLAY_DECIMALS, + normalizeBillingDisplayDecimals, +} from "./billing"; +import { CONSTANTS } from "./constants"; +import { getJsonSetting, saveSetting } from "./utils"; + +export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = { + enabled: false, + telegramBotToken: "", + telegramChatId: "", +}; + +export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { + enabled: true, +}; + +export const DEFAULT_SYSTEM_CONFIG: SystemConfig = { + displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS, + adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG, + apiDocs: DEFAULT_API_DOCS_CONFIG, +}; + +const normalizeBoolean = (value: unknown, fallback = false): boolean => { + return typeof value === "boolean" ? value : fallback; +}; + +const normalizeString = (value: unknown): string => { + return typeof value === "string" ? value.trim() : ""; +}; + +export const normalizeAdminSecurityConfig = ( + value: Partial | null | undefined +): AdminSecurityConfig => { + return { + enabled: normalizeBoolean(value?.enabled), + telegramBotToken: normalizeString(value?.telegramBotToken), + telegramChatId: normalizeString(value?.telegramChatId), + }; +}; + +export const normalizeApiDocsConfig = ( + value: Partial | null | undefined +): ApiDocsConfig => { + return { + enabled: normalizeBoolean(value?.enabled, DEFAULT_API_DOCS_CONFIG.enabled), + }; +}; + +export const normalizeSystemConfig = ( + value: Partial | null | undefined +): SystemConfig => { + return { + displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals), + adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity), + apiDocs: normalizeApiDocsConfig(value?.apiDocs), + }; +}; + +export const isTelegramSecurityEnabled = ( + value: AdminSecurityConfig | SystemConfig | null | undefined +): boolean => { + const security = value && "adminSecurity" in value + ? value.adminSecurity + : value; + + if (!security) { + return false; + } + + return security.enabled + && security.telegramBotToken.length > 0 + && security.telegramChatId.length > 0; +}; + +export const getSystemConfig = async ( + c: Context +): Promise => { + const systemConfig = await getJsonSetting( + c, + CONSTANTS.SYSTEM_CONFIG_KEY + ); + + if (systemConfig) { + return normalizeSystemConfig(systemConfig); + } + + const legacyBillingConfig = await getJsonSetting( + c, + CONSTANTS.BILLING_CONFIG_KEY + ); + + return normalizeSystemConfig({ + displayDecimals: legacyBillingConfig?.displayDecimals, + }); +}; + +export const saveSystemConfig = async ( + c: Context, + value: Partial | null | undefined +): Promise => { + const config = normalizeSystemConfig(value); + + await saveSetting( + c, + CONSTANTS.SYSTEM_CONFIG_KEY, + JSON.stringify(config) + ); + + await saveSetting( + c, + CONSTANTS.BILLING_CONFIG_KEY, + JSON.stringify({ + displayDecimals: config.displayDecimals, + } satisfies BillingConfig) + ); + + return config; +}; diff --git a/type.d.ts b/type.d.ts index b5ff084..bf78fe4 100644 --- a/type.d.ts +++ b/type.d.ts @@ -120,6 +120,21 @@ type BillingConfig = { displayDecimals: number; } +type AdminSecurityConfig = { + enabled: boolean; + telegramBotToken: string; + telegramChatId: string; +} + +type ApiDocsConfig = { + enabled: boolean; +} + +type SystemConfig = BillingConfig & { + adminSecurity: AdminSecurityConfig; + apiDocs: ApiDocsConfig; +} + type ApiTokenData = { name: string; channel_keys: string[]; @@ -131,3 +146,23 @@ type RequestTrackingState = { upstreamStatus?: number; errorSummary?: string; } + +type AdminLoginChallengeRow = BaseDbRow & { + id: string; + code_hash: string; + expires_at: string; + attempts: number; + max_attempts: number; + request_ip: string; + request_country: string; + request_region: string; + request_city: string; + request_colo: string; + request_timezone: string; +} + +type AdminSessionRow = BaseDbRow & { + token_hash: string; + expires_at: string; + last_used_at: string; +}