Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,16 @@ SMTP_USE_TLS=true
# 用于生成邮件中的链接
# 生产环境示例:https://tradingagents.com
# 开发环境示例:http://localhost:3000
APP_BASE_URL=http://localhost:3000
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
81 changes: 31 additions & 50 deletions web/backend/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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次
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
18 changes: 7 additions & 11 deletions web/backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -72,7 +70,7 @@ class Config:
class UserInDB(User):
hashed_password: str

# Captcha
# Captcha(已废弃:图形验证码已替换为 Cloudflare Turnstile,保留仅为向后兼容引用)
class CaptchaResponse(BaseModel):
captcha_id: str
seed: str
Expand All @@ -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"""
Expand All @@ -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):
Expand Down
48 changes: 48 additions & 0 deletions web/backend/turnstile.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +7 to +18

_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
18 changes: 15 additions & 3 deletions web/frontend/public/lib/font-awesome/css/icons.subset.css
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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"}
Expand All @@ -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"}
Expand All @@ -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"}
Expand Down
Loading