diff --git a/.env.example b/.env.example index cf36e57..a0e5ee7 100644 --- a/.env.example +++ b/.env.example @@ -110,4 +110,16 @@ SMTP_USE_TLS=true # 用于生成邮件中的链接 # 生产环境示例:https://tradingagents.com # 开发环境示例:http://localhost:3000 -APP_BASE_URL=http://localhost:3000 \ No newline at end of file +APP_BASE_URL=http://localhost:3000 + +# ============================================ +# Cloudflare Turnstile 人机验证(登录/注册/邮箱验证码下发) +# ============================================ +# 默认使用 Cloudflare 官方测试密钥(始终通过),仅供联调; +# 生产环境请前往 https://dash.cloudflare.com/?to=/:/turnstile 创建真实站点并覆盖以下两项。 +# 测试 sitekey(始终通过):1x00000000000000000000AA +# 测试 secret key(始终通过):1x0000000000000000000000000000000AA +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +# 前端读取的 sitekey(Next.js 仅注入 NEXT_PUBLIC_ 前缀变量) +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA \ No newline at end of file diff --git a/web/backend/auth_routes.py b/web/backend/auth_routes.py index e6fe59b..663155f 100644 --- a/web/backend/auth_routes.py +++ b/web/backend/auth_routes.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from web.backend.database import get_db from web.backend.schemas import ( - UserCreate, UserLogin, AuthResponse, User as UserSchema, Token, CaptchaResponse, + UserCreate, UserLogin, AuthResponse, User as UserSchema, Token, EmailCodeSendRequest, EmailCodeSendResponse, EmailCodeLoginRequest, PasswordSetRequest ) from web.backend.auth import ( @@ -29,12 +29,9 @@ from typing import Deque, Dict import time -_CAPTCHA_REQUESTS: Dict[str, Deque[float]] = defaultdict(deque) # 每IP的验证码请求时间戳 _FAILED_ATTEMPTS: Dict[str, Deque[float]] = defaultdict(deque) # 每IP的失败时间戳 _EMAIL_CODE_REQUESTS: Dict[str, Deque[float]] = defaultdict(deque) # 每邮箱的验证码请求时间戳 -CAPTCHA_RATE_LIMIT = 20 # 每分钟最多获取20次验证码/每IP -CAPTCHA_RATE_WINDOW = 60 # 秒 FAIL_WINDOW = 600 # 10分钟 FAIL_LIMIT = 5 # 10分钟内最多失败5次 EMAIL_CODE_RATE_LIMIT = 1 # 每邮箱每60秒最多请求1次 @@ -45,16 +42,6 @@ def _prune(dq: Deque[float], window: int): while dq and now - dq[0] > window: dq.popleft() -def _check_rate(ip: str) -> bool: - dq = _CAPTCHA_REQUESTS[ip] - _prune(dq, CAPTCHA_RATE_WINDOW) - return len(dq) < CAPTCHA_RATE_LIMIT - -def _note_captcha_request(ip: str): - dq = _CAPTCHA_REQUESTS[ip] - _prune(dq, CAPTCHA_RATE_WINDOW) - dq.append(time.time()) - def _note_fail(ip: str): dq = _FAILED_ATTEMPTS[ip] _prune(dq, FAIL_WINDOW) @@ -75,21 +62,15 @@ def _note_email_code_request(email: str): _prune(dq, EMAIL_CODE_RATE_WINDOW) dq.append(time.time()) -@router.post("/captcha/new", response_model=CaptchaResponse) -async def new_captcha(request: Request): +@router.get("/turnstile/sitekey") +async def get_turnstile_sitekey(): """ - Create new captcha challenge and return seed and id (frontend draws image via Canvas using seed) + Return the Cloudflare Turnstile site key (public, safe to expose). + Frontend uses it to render the human-verification widget. + Defaults to Cloudflare's always-pass test key for debugging. """ - client_ip = request.client.host if request.client else "unknown" - if not _check_rate(client_ip): - raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码请求过于频繁,请稍后再试") - try: - from web.backend.captcha import create_captcha - cid, seed = create_captcha() - _note_captcha_request(client_ip) - return {"captcha_id": cid, "seed": seed} - except Exception as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"生成验证码失败: {str(e)}") + from web.backend.turnstile import TURNSTILE_SITE_KEY + return {"sitekey": TURNSTILE_SITE_KEY} # Security scheme security = HTTPBearer() @@ -130,16 +111,16 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db), re Register a new user (requires captcha and email verification code) """ try: - # 验证服务端验证码(防止绕过前端) - from web.backend.captcha import verify_captcha + # 校验 Cloudflare Turnstile 人机验证 token + from web.backend.turnstile import verify_turnstile_token from web.backend.services.verification_code_service import get_verification_code_service client_ip = request.client.host if request and request.client else "unknown" if _too_many_fails(client_ip): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="尝试次数过多,请稍后再试") - if not user_data.captcha_id or not user_data.captcha_answer or not verify_captcha(user_data.captcha_id, user_data.captcha_answer): + if not await verify_turnstile_token(user_data.turnstile_token, client_ip): _note_fail(client_ip) - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图形验证码无效或已过期") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="人机验证失败,请刷新后重试") # Verify email code if not user_data.email_code: @@ -199,14 +180,14 @@ async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db), reques """ Login user and return access token (requires captcha) """ - # 验证服务端验证码(防止绕过前端) - from web.backend.captcha import verify_captcha + # 校验 Cloudflare Turnstile 人机验证 token + from web.backend.turnstile import verify_turnstile_token client_ip = request.client.host if request and request.client else "unknown" if _too_many_fails(client_ip): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="尝试次数过多,请稍后再试") - if not user_data.captcha_id or not user_data.captcha_answer or not verify_captcha(user_data.captcha_id, user_data.captcha_answer): + if not await verify_turnstile_token(user_data.turnstile_token, client_ip): _note_fail(client_ip) - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="验证码无效或已过期") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="人机验证失败,请刷新后重试") # Authenticate user user = await authenticate_user(db, user_data.username, user_data.password) @@ -314,9 +295,9 @@ async def send_email_code( ): """ Send verification code to user's email - Requires CAPTCHA validation and rate limiting + Requires Cloudflare Turnstile validation and rate limiting """ - from web.backend.captcha import verify_captcha + from web.backend.turnstile import verify_turnstile_token from web.backend.services.verification_code_service import get_verification_code_service from sqlalchemy import select @@ -329,12 +310,12 @@ async def send_email_code( detail="尝试次数过多,请稍后再试" ) - # Validate CAPTCHA - if not verify_captcha(request_data.captcha_id, request_data.captcha_answer): + # Validate Turnstile token + if not await verify_turnstile_token(request_data.turnstile_token, client_ip): _note_fail(client_ip) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="验证码无效或已过期" + detail="人机验证失败,请刷新后重试" ) # Check email rate limiting @@ -386,10 +367,10 @@ async def send_email_code_for_register( ): """ Send verification code for registration - Requires CAPTCHA validation and rate limiting + Requires Cloudflare Turnstile validation and rate limiting Checks that email is NOT already registered """ - from web.backend.captcha import verify_captcha + from web.backend.turnstile import verify_turnstile_token from web.backend.services.verification_code_service import VerificationCodeService from sqlalchemy import select @@ -402,12 +383,12 @@ async def send_email_code_for_register( detail="尝试次数过多,请稍后再试" ) - # Validate CAPTCHA - if not verify_captcha(request_data.captcha_id, request_data.captcha_answer): + # Validate Turnstile token + if not await verify_turnstile_token(request_data.turnstile_token, client_ip): _note_fail(client_ip) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="验证码无效或已过期" + detail="人机验证失败,请刷新后重试" ) # Check email rate limiting @@ -489,9 +470,9 @@ async def login_with_email_code( ): """ Login user with email and verification code - Requires CAPTCHA validation + Requires Cloudflare Turnstile validation """ - from web.backend.captcha import verify_captcha + from web.backend.turnstile import verify_turnstile_token from web.backend.services.verification_code_service import get_verification_code_service from sqlalchemy import select @@ -504,12 +485,12 @@ async def login_with_email_code( detail="尝试次数过多,请稍后再试" ) - # Validate CAPTCHA - if not verify_captcha(request_data.captcha_id, request_data.captcha_answer): + # Validate Turnstile token + if not await verify_turnstile_token(request_data.turnstile_token, client_ip): _note_fail(client_ip) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="验证码无效或已过期" + detail="人机验证失败,请刷新后重试" ) # Verify email code diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 44ff7ec..f1ea8a3 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -15,9 +15,8 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: Optional[str] = None # Password is now optional - # 服务端验证码(防绕过前端) - captcha_id: Optional[str] = None - captcha_answer: Optional[str] = None + # Cloudflare Turnstile 人机验证 token(前端 widget 回传) + turnstile_token: Optional[str] = None # 邮箱验证码 email_code: Optional[str] = None @@ -55,9 +54,8 @@ def validate_password(cls, v): class UserLogin(BaseModel): username: str password: str - # 服务端验证码(防绕过前端) - captcha_id: Optional[str] = None - captcha_answer: Optional[str] = None + # Cloudflare Turnstile 人机验证 token(前端 widget 回传) + turnstile_token: Optional[str] = None class User(UserBase): id: int @@ -72,7 +70,7 @@ class Config: class UserInDB(User): hashed_password: str -# Captcha +# Captcha(已废弃:图形验证码已替换为 Cloudflare Turnstile,保留仅为向后兼容引用) class CaptchaResponse(BaseModel): captcha_id: str seed: str @@ -81,8 +79,7 @@ class CaptchaResponse(BaseModel): class EmailCodeSendRequest(BaseModel): """Request schema for sending verification code""" email: EmailStr - captcha_id: str - captcha_answer: str + turnstile_token: Optional[str] = None class EmailCodeSendResponse(BaseModel): """Response schema for send verification code""" @@ -93,8 +90,7 @@ class EmailCodeLoginRequest(BaseModel): """Request schema for email code login""" email: EmailStr code: str - captcha_id: str - captcha_answer: str + turnstile_token: Optional[str] = None @validator('code') def validate_code(cls, v): diff --git a/web/backend/turnstile.py b/web/backend/turnstile.py new file mode 100644 index 0000000..ebdf2f0 --- /dev/null +++ b/web/backend/turnstile.py @@ -0,0 +1,48 @@ +"""Cloudflare Turnstile 人机验证集成。 + +后端用 siteverify 校验前端下发的 turnstile_token。 +默认使用 Cloudflare 官方测试密钥(始终通过),方便联调; +生产环境请通过环境变量 TURNSTILE_SECRET_KEY 覆盖为真实密钥。 +""" +import os +from typing import Optional + +import httpx + +# Cloudflare 官方测试密钥(始终通过 / always passes) +# 详见 https://developers.cloudflare.com/turnstile/troubleshooting/testing/ +_TEST_SECRET = "1x0000000000000000000000000000000AA" +_TEST_SITE_KEY = "1x00000000000000000000AA" + +TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", _TEST_SECRET) +TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", _TEST_SITE_KEY) + +_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + +async def verify_turnstile_token(token: Optional[str], remote_ip: Optional[str] = None) -> bool: + """校验 Turnstile token。 + + - token 为空或 None:直接判失败。 + - 调用 Cloudflare siteverify;测试密钥始终返回 success=true。 + - 任何网络/解析异常都判失败(fail-closed),不抛异常以免打断登录流程。 + """ + if not token: + return False + + # 测试密钥短路:Cloudflare 不会真正校验测试 token,直接放行以方便联调。 + if TURNSTILE_SECRET_KEY == _TEST_SECRET: + return True + + try: + async with httpx.AsyncClient(timeout=8.0) as client: + data = {"secret": TURNSTILE_SECRET_KEY, "response": token} + if remote_ip: + data["remoteip"] = remote_ip + resp = await client.post(_SITEVERIFY_URL, data=data) + resp.raise_for_status() + payload = resp.json() + return bool(payload.get("success", False)) + except Exception: + # fail-closed:异常时拒绝,避免放行机器人 + return False diff --git a/web/frontend/public/lib/font-awesome/css/icons.subset.css b/web/frontend/public/lib/font-awesome/css/icons.subset.css index 7977c18..b29a1d1 100644 --- a/web/frontend/public/lib/font-awesome/css/icons.subset.css +++ b/web/frontend/public/lib/font-awesome/css/icons.subset.css @@ -73,6 +73,9 @@ .fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"} .fa-comments:before{content:"\f086"} .fa-user-check:before{content:"\f4fc"} +.fa-circle-notch:before{content:"\f1ce"} +.fa-gavel:before,.fa-legal:before{content:"\f0e3"} +.fa-compass:before{content:"\f14e"} .fa-bars:before,.fa-navicon:before{content:"\f0c9"} .fa-lightbulb:before{content:"\f0eb"} .fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"} @@ -91,7 +94,7 @@ .fa-server:before{content:"\f233"} .fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"} .fa-crown:before{content:"\f521"} -.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"} +.fa-folder-open:before{content:"\f07c"} .fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"} .fa-image:before{content:"\f03e"} .fa-circle-play:before,.fa-play-circle:before{content:"\f144"} @@ -101,12 +104,14 @@ .fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"} .fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"} .fa-layer-group:before{content:"\f5fd"} +.fa-newspaper:before{content:"\f1ea"} .fa-question:before{content:"\3f"} +.fa-magnifying-glass-chart:before{content:"\e522"} .fa-chart-line:before,.fa-line-chart:before{content:"\f201"} -.fa-arrow-right:before{content:"\f061"} .fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"} .fa-cube:before{content:"\f1b2"} .fa-circle:before{content:"\f111"} +.fa-users-rays:before{content:"\e593"} .fa-terminal:before{content:"\f120"} .fa-eye:before{content:"\f06e"} .fa-pen:before{content:"\f304"} @@ -130,27 +135,34 @@ .fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"} .fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"} .fa-calendar-week:before{content:"\f784"} +.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"} .fa-stop:before{content:"\f04d"} .fa-bolt:before,.fa-zap:before{content:"\f0e7"} .fa-bug:before{content:"\f188"} .fa-arrow-down:before{content:"\f063"} .fa-play:before{content:"\f04b"} .fa-magnifying-glass:before,.fa-search:before{content:"\f002"} +.fa-receipt:before{content:"\f543"} .fa-chevron-down:before{content:"\f078"} .fa-arrow-up:before{content:"\f062"} .fa-list-check:before,.fa-tasks:before{content:"\f0ae"} .fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"} .fa-add:before,.fa-plus:before{content:"\2b"} .fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"} +.fa-rocket:before{content:"\f135"} +.fa-arrow-trend-up:before{content:"\e098"} .fa-chevron-left:before{content:"\f053"} .fa-chevron-right:before{content:"\f054"} .fa-spinner:before{content:"\f110"} .fa-robot:before{content:"\f544"} -.fa-building:before{content:"\f1ad"} .fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"} .fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"} +.fa-shield-blank:before,.fa-shield:before{content:"\f132"} .fa-calendar:before{content:"\f133"} .fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"} +.fa-arrow-trend-down:before{content:"\e097"} +.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"} +.fa-table-list:before,.fa-th-list:before{content:"\f00b"} .fa-user-plus:before{content:"\f234"} .fa-check:before{content:"\f00c"} .fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"} diff --git a/web/frontend/src/app/admin/llm-config/page.tsx b/web/frontend/src/app/admin/llm-config/page.tsx index 10306f9..ffe0839 100644 --- a/web/frontend/src/app/admin/llm-config/page.tsx +++ b/web/frontend/src/app/admin/llm-config/page.tsx @@ -6,8 +6,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth'; import { buildApiUrl } from '@/utils/api'; import { useToast, Toast } from '@/components/ui/Toast'; -import { AppNavbar } from '@/components/common/AppNavbar'; -import { Footer } from '@/components/common/Footer'; +import { SiteLayout } from '@/components/site/SiteLayout'; import { ProviderList } from '@/components/admin/llm-config/ProviderList'; import { ModelList } from '@/components/admin/llm-config/ModelList'; import { ProviderForm } from '@/components/admin/llm-config/ProviderForm'; @@ -16,7 +15,7 @@ import { ConfirmDialog } from '@/components/admin/llm-config/ConfirmDialog'; import { RouteDataState } from '@/components/ui/RouteDataState'; export default function LLMConfigPage() { - const { user, logout, isLoading: authLoading } = useAuth(); + const { user, isLoading: authLoading } = useAuth(); const router = useRouter(); const { toast, showToast, hideToast } = useToast(); const queryClient = useQueryClient(); @@ -176,17 +175,18 @@ export default function LLMConfigPage() { } return ( -
- - -
+ {/* 页面标题 */} -
-

- - LLM 配置管理 -

-

管理 LLM 供应商和模型配置

+
+
+
管理控制台
+

+ + LLM 配置管理 +

+

管理 LLM 供应商和模型配置

+
+ 管理员
{/* 标签页 */} @@ -273,9 +273,6 @@ export default function LLMConfigPage() {
)} -
- -
+ ); } diff --git a/web/frontend/src/app/admin/system-default-provider/page.tsx b/web/frontend/src/app/admin/system-default-provider/page.tsx index 02ba3cd..bff33d6 100644 --- a/web/frontend/src/app/admin/system-default-provider/page.tsx +++ b/web/frontend/src/app/admin/system-default-provider/page.tsx @@ -4,13 +4,12 @@ import React from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth'; -import { AppNavbar } from '@/components/common/AppNavbar'; -import { Footer } from '@/components/common/Footer'; +import { SiteLayout } from '@/components/site/SiteLayout'; import { SystemDefaultForm } from '@/components/admin/system-default-provider/SystemDefaultForm'; import { PageLoading } from '@/components/ui/PageLoading'; export default function SystemDefaultProviderPage() { - const { user, logout, isLoading: authLoading } = useAuth(); + const { user, isLoading: authLoading } = useAuth(); const router = useRouter(); // 权限检查:仅管理员可访问,普通用户无写权限,重定向回首页 @@ -25,25 +24,23 @@ export default function SystemDefaultProviderPage() { } return ( -
- - -
- {/* 页面标题 */} -
-

-