diff --git a/.agents/skills/daoyou-backend-api-security/SKILL.md b/.agents/skills/daoyou-backend-api-security/SKILL.md index caad011b..93d83ee2 100644 --- a/.agents/skills/daoyou-backend-api-security/SKILL.md +++ b/.agents/skills/daoyou-backend-api-security/SKILL.md @@ -1,6 +1,6 @@ --- name: daoyou-backend-api-security -description: Daoyou Hono API、认证、授权、Better Auth、Turnstile、admin、internal cron、LLM header 安全和服务端输入校验指南。Use when adding or modifying src/server routes, middleware, auth, admin endpoints, cron/internal endpoints, shared contracts, LLM provider handling, API validation, or security-relevant frontend auth/admin loaders in this repo. For ordinary admin pages, navigation, and UI state, use daoyou-frontend-routing-state. +description: Daoyou Hono API、认证、授权、Better Auth、ALTCHA、admin、internal cron、LLM header 安全和服务端输入校验指南。Use when adding or modifying src/server routes, middleware, auth, admin endpoints, cron/internal endpoints, shared contracts, LLM provider handling, API validation, or security-relevant frontend auth/admin loaders in this repo. For ordinary admin pages, navigation, and UI state, use daoyou-frontend-routing-state. --- # Daoyou Backend API Security @@ -62,7 +62,7 @@ description: Daoyou Hono API、认证、授权、Better Auth、Turnstile、admin ## Do Not - Do not rely on React loaders for authorization. -- Do not bypass `src/server/lib/auth/hono.ts` for login, signup, reset, OTP, or Turnstile-protected flows. +- Do not bypass `src/server/lib/auth/hono.ts` for login, signup, reset, OTP, or ALTCHA-protected flows. - Do not add admin files under `/api/admin` without explicit admin authorization. - Do not add provider or Base URL request headers; use the validated DeepSeek configuration from the Hono context. - Do not make public list/ranking/community endpoints private without checking frontend/product usage. diff --git a/.agents/skills/daoyou-dev-runtime/SKILL.md b/.agents/skills/daoyou-dev-runtime/SKILL.md index 3b1cbb14..d7a233fd 100644 --- a/.agents/skills/daoyou-dev-runtime/SKILL.md +++ b/.agents/skills/daoyou-dev-runtime/SKILL.md @@ -29,7 +29,7 @@ description: Daoyou 本地开发、环境变量、构建、Docker、compose、cr - The SPA is built with `build:client` for independent static deployment; backend Docker builds never run it. - The Hono API uses `@hono/vite-build/bun` with entry `src/index.ts`; unmatched routes redirect to the public client rather than serving SPA files. - The battle-server uses `vite.battle.config.ts` and outputs `dist-battle/battle-server.js` for Node LTS. -- `VITE_TURNSTILE_SITE_KEY` is a frontend-only build-time value. +- ALTCHA uses the server-side `ALTCHA_HMAC_SECRET` and does not require a frontend site key. - Health check is `/api/health-check`; Redis down returns 503, missing Redis returns `redis: disabled`. - Production Bun cron jobs currently include `auction-expire`, `bet-battle-expire`, `rank-rewards`, and `market-refresh`. - React SPA is independently deployed and is never copied into backend images. diff --git a/.env.example b/.env.example index e6c8b0a9..634f887b 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,7 @@ GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= # Captcha -TURNSTILE_SECRET_KEY= +ALTCHA_HMAC_SECRET=replace-with-a-random-secret # Admin ADMIN_EMAILS= @@ -74,4 +74,3 @@ SPONSORKIT_AFDIAN_EXCHANGE_RATE=6.5 # Configure these in GitHub Actions Environment/Repository vars, not in the # server runtime env file passed to `docker run --env-file`. # VITE_API_BASE_URL=https://api.daoyou.example.com -# VITE_TURNSTILE_SITE_KEY=your-public-turnstile-site-key diff --git a/AGENTS.md b/AGENTS.md index e815eafe..d2ee4256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ bun run auth:migrate - `bun run build` is two-stage: `tsc -b && vite build --mode client && vite build`. - Vitest uses node environment and discovers tests only under `src/shared`. -- Docker runtime contains only `dist`; `VITE_TURNSTILE_SITE_KEY` is a build-time variable. +- Docker runtime contains only `dist`; ALTCHA uses the server-side `ALTCHA_HMAC_SECRET` and does not require a frontend site key. - GitHub Actions currently builds and pushes Docker image on `master`; it is not a lint/test quality gate. ## Skills To Use @@ -88,7 +88,7 @@ bun run auth:migrate ## High-Risk Areas - Build pipeline and SPA fallback in `vite.config.ts` / `src/index.ts`. -- Auth, Turnstile, Better Auth schema, admin allowlist, and session cookie passthrough. +- Auth, ALTCHA, Better Auth schema, admin allowlist, and session cookie passthrough. - LLM provider headers, prompt schemas, resource/reward/cost parsing, and metrics. - Drizzle migrations, legacy tables, JSONB model shape, and transaction boundaries. - `GameViewportLayout`, bottom dock/HUD/world-chat offset, and scene metadata. diff --git a/README.md b/README.md index a0ef97ba..9eeec79f 100644 --- a/README.md +++ b/README.md @@ -165,20 +165,24 @@ cp .env.example .env.local ### 登录 / 注册相关 -当前鉴权中,以下接口会强制要求 Turnstile token: +当前鉴权中,以下接口会强制要求 ALTCHA PoW payload: - `/api/auth/sign-in/email` - `/api/auth/sign-up/email` - `/api/auth/request-password-reset` - `/api/auth/email-otp/send-verification-otp` -因此前端若不配置 Turnstile,相关表单无法正常工作。 +前端通过 `/api/captcha/challenge` 获取带场景和过期时间的 challenge,完成 PoW +后把 payload 发送给认证接口。服务端会验证签名、场景和有效期,并通过 Redis +原子记录 challenge 的单次消费状态,防止重放。 | 变量 | 说明 | | --- | --- | | `VITE_API_BASE_URL` | 前端构建时注入的后端 API 基地址,如 `https://api.example.com` | -| `VITE_TURNSTILE_SITE_KEY` | 前端构建时注入;没有它,登录/注册/找回密码页不会渲染验证码组件 | -| `TURNSTILE_SECRET_KEY` 或 `TURNSTILE_SECRET` | 服务端校验 Turnstile 的密钥;未配置时服务端仍要求 token,但不会调用 Cloudflare 做真正校验 | +| `ALTCHA_HMAC_SECRET` | 服务端签发和验证 ALTCHA challenge 的独立 HMAC 密钥;生产环境必须配置 | + +ALTCHA 不需要前端 site key。认证 CAPTCHA 启用时 Redis 也是强依赖;Redis 不可用时, +受保护的认证请求会失败关闭,避免 challenge 被重复使用。 ### 邮件能力 @@ -293,7 +297,7 @@ docker run --rm -p 3000:3000 \ 注意: -- `VITE_API_BASE_URL` 和 `VITE_TURNSTILE_SITE_KEY` 是前端 Pages 构建期变量,不进入后端 Docker 镜像 +- `VITE_API_BASE_URL` 是前端 Pages 构建期变量,不进入后端 Docker 镜像 - 服务运行时环境变量通过 shell、容器环境或 `--env-file` 注入 ## 仓库内现成部署脚本 diff --git a/bun.lock b/bun.lock index cf9d82f7..4c94bc73 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,11 @@ "@ai-sdk/deepseek": "^3.0.24", "@better-auth/drizzle-adapter": "^1.6.26", "@better-auth/i18n": "^1.6.26", - "@marsidev/react-turnstile": "^1.5.5", "@microfleet/ioredis-lock": "^6.0.1", "@tailwindcss/vite": "^4.3.3", "ai": "^7.0.56", + "altcha": "^3.2.1", + "altcha-lib": "^2.3.2", "better-auth": "^1.6.26", "boardgame.io": "^0.50.2", "canvas-confetti": "^1.9.4", @@ -288,8 +289,6 @@ "@koa/router": ["@koa/router@10.1.1", "", { "dependencies": { "debug": "^4.1.1", "http-errors": "^1.7.3", "koa-compose": "^4.1.0", "methods": "^1.1.2", "path-to-regexp": "^6.1.0" } }, "sha512-ORNjq5z4EmQPriKbR0ER3k4Gh7YGNhWDL7JBW+8wXDrHLbWYKYSJaOJ9aN06npF5tbTxe2JBOsurpJDAvjiXKw=="], - "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.5", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-+EtXbmsJwlRuIUruLb3UBAakpW0wFGeA17QQp0uG8Mv+Zh8MXktZT68P5HS2g1ssfeRd2+BZGupOE/F4KGajFQ=="], - "@microfleet/ioredis-lock": ["@microfleet/ioredis-lock@6.0.1", "https://registry.npmmirror.com/@microfleet/ioredis-lock/-/ioredis-lock-6.0.1.tgz", { "peerDependencies": { "ioredis": "~4.x.x || ~5.x.x" } }, "sha512-nd4pKn4GMH3N+DrCm9R/AW7nzm4oLxtmX94HvkR94ReJdpDrP54aD0bA5rjVLZarCzTpHMxGDiH8sfsvDtsjTg=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ=="], @@ -498,6 +497,10 @@ "ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "altcha": ["altcha@3.2.1", "", { "dependencies": { "hash-wasm": "^4.12.0" } }, "sha512-MXsnNmgg3BGJja0DXbflv9Bp29uyGv6wUImaaT1yLxCKOj+x9QBMpi/Vdh6XbP4Uf0PykaeQwl8ivJAvIhoo0A=="], + + "altcha-lib": ["altcha-lib@2.3.2", "", { "peerDependencies": { "@fastify/cookie": "^11.0.0", "@fastify/cors": "^11.0.0", "@fastify/formbody": "^8.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", "@nestjs/platform-express": "^10.0.0 || ^11.0.0", "@sveltejs/kit": "^2.0.0", "cookie-parser": "^1.0.0", "cors": "^2.0.0", "express": "^4.0.0 || ^5.0.0", "fastify": "^5.0.0", "h3": "^2.0.0", "hono": "^4.0.0" }, "optionalPeers": ["@fastify/cookie", "@fastify/cors", "@fastify/formbody", "@nestjs/common", "@nestjs/core", "@nestjs/platform-express", "@sveltejs/kit", "cookie-parser", "cors", "express", "fastify", "h3", "hono"], "bin": { "altcha-lib": "bin/cli.mjs" } }, "sha512-puJp4aPn/kK2t5Z3dQkbFTmPusaYPkdlrXB4feWZzdNIiC3sPndKMlcjQFUU3a+sVlUt6DJhfWK2+HiwDBbW1Q=="], + "ansis": ["ansis@4.3.1", "https://registry.npmmirror.com/ansis/-/ansis-4.3.1.tgz", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -730,6 +733,8 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], diff --git a/package.json b/package.json index b4ad6785..919ebbab 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,11 @@ "@ai-sdk/deepseek": "^3.0.24", "@better-auth/drizzle-adapter": "^1.6.26", "@better-auth/i18n": "^1.6.26", - "@marsidev/react-turnstile": "^1.5.5", "@microfleet/ioredis-lock": "^6.0.1", "@tailwindcss/vite": "^4.3.3", "ai": "^7.0.56", + "altcha": "^3.2.1", + "altcha-lib": "^2.3.2", "better-auth": "^1.6.26", "boardgame.io": "^0.50.2", "canvas-confetti": "^1.9.4", diff --git a/src/react-app/components/auth/AltchaCaptcha.tsx b/src/react-app/components/auth/AltchaCaptcha.tsx new file mode 100644 index 00000000..dfc08a91 --- /dev/null +++ b/src/react-app/components/auth/AltchaCaptcha.tsx @@ -0,0 +1,92 @@ +import { resolveApiUrl } from '@app/lib/api/url'; +import 'altcha'; +import 'altcha/i18n/zh-cn'; +import 'altcha/types/react'; +import type { AltchaWidgetElement } from 'altcha'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type HTMLAttributes, +} from 'react'; + +export type AltchaAction = + | 'sign-in' + | 'sign-up' + | 'password-reset' + | 'email-otp'; + +export interface AltchaCaptchaHandle { + reset: () => void; +} + +interface AltchaCaptchaProps extends HTMLAttributes { + action: AltchaAction; + onPayloadChange: (payload: string | null) => void; +} + +type AltchaStateChangeDetail = { + payload?: string; + state?: string; +}; + +const AltchaCaptcha = forwardRef( + ({ action, onPayloadChange, className, ...rest }, ref) => { + const widgetRef = useRef(null); + const challengeUrl = useMemo( + () => + resolveApiUrl( + `/api/captcha/challenge?action=${encodeURIComponent(action)}`, + ), + [action], + ); + + useImperativeHandle( + ref, + () => ({ + reset: () => { + widgetRef.current?.reset(); + onPayloadChange(null); + }, + }), + [onPayloadChange], + ); + + useEffect(() => { + const widget = widgetRef.current; + if (!widget) { + return; + } + + const handleStateChange = (event: Event) => { + const { payload, state } = ( + event as CustomEvent + ).detail; + onPayloadChange(state === 'verified' && payload ? payload : null); + }; + + widget.addEventListener('statechange', handleStateChange); + return () => { + widget.removeEventListener('statechange', handleStateChange); + }; + }, [onPayloadChange]); + + return ( +
+ +
+ ); + }, +); + +AltchaCaptcha.displayName = 'AltchaCaptcha'; + +export default AltchaCaptcha; diff --git a/src/react-app/components/auth/AuthCaptchaField.tsx b/src/react-app/components/auth/AuthCaptchaField.tsx new file mode 100644 index 00000000..55778d07 --- /dev/null +++ b/src/react-app/components/auth/AuthCaptchaField.tsx @@ -0,0 +1,30 @@ +import AltchaCaptcha, { + type AltchaAction, + type AltchaCaptchaHandle, +} from '@app/components/auth/AltchaCaptcha'; +import type { RefObject } from 'react'; + +interface AuthCaptchaFieldProps { + action: AltchaAction; + error?: string; + captchaRef: RefObject; + onPayloadChange: (payload: string | null) => void; +} + +export function AuthCaptchaField({ + action, + error, + captchaRef, + onPayloadChange, +}: AuthCaptchaFieldProps) { + return ( +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/src/react-app/components/auth/AuthTurnstileField.tsx b/src/react-app/components/auth/AuthTurnstileField.tsx deleted file mode 100644 index ca77f252..00000000 --- a/src/react-app/components/auth/AuthTurnstileField.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import TurnstileCaptcha, { - type TurnstileCaptchaHandle, -} from '@app/components/auth/TurnstileCaptcha'; -import type { RefObject } from 'react'; - -interface AuthTurnstileFieldProps { - enabled: boolean; - error?: string; - turnstileRef: RefObject; - onTokenChange: (token: string | null) => void; -} - -export function AuthTurnstileField({ - enabled, - error, - turnstileRef, - onTokenChange, -}: AuthTurnstileFieldProps) { - if (!enabled) { - return null; - } - - return ( -
- - {error ?

{error}

: null} -
- ); -} diff --git a/src/react-app/components/auth/TurnstileCaptcha.tsx b/src/react-app/components/auth/TurnstileCaptcha.tsx deleted file mode 100644 index b01f8dc1..00000000 --- a/src/react-app/components/auth/TurnstileCaptcha.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { clientEnv } from '@app/lib/env'; -import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'; -import { - forwardRef, - useImperativeHandle, - useRef, - type HTMLAttributes, -} from 'react'; - -export interface TurnstileCaptchaHandle { - reset: () => void; -} - -interface TurnstileCaptchaProps extends HTMLAttributes { - onTokenChange: (token: string | null) => void; -} - -const siteKey = clientEnv.turnstileSiteKey; - -const TurnstileCaptcha = forwardRef< - TurnstileCaptchaHandle, - TurnstileCaptchaProps ->(({ onTokenChange, className, ...rest }, ref) => { - const turnstileRef = useRef(undefined); - - useImperativeHandle( - ref, - () => ({ - reset: () => { - turnstileRef.current?.reset(); - onTokenChange(null); - }, - }), - [onTokenChange], - ); - - if (!siteKey) return null; - - return ( -
- { - onTokenChange(token); - }} - onExpire={() => { - onTokenChange(null); - }} - onError={() => { - onTokenChange(null); - }} - options={{ - theme: 'light', - language: 'zh-CN', - size: 'flexible', - }} - /> -
- ); -}); - -TurnstileCaptcha.displayName = 'TurnstileCaptcha'; - -export default TurnstileCaptcha; diff --git a/src/react-app/components/auth/index.ts b/src/react-app/components/auth/index.ts index 03e7c3cc..f73c2cc6 100644 --- a/src/react-app/components/auth/index.ts +++ b/src/react-app/components/auth/index.ts @@ -1,8 +1,8 @@ export { AuthChoiceCard } from './AuthChoiceCard'; +export { AuthCaptchaField } from './AuthCaptchaField'; export { AuthPageShell } from './AuthPageShell'; -export { AuthTurnstileField } from './AuthTurnstileField'; export { useAuthFeedback } from './useAuthFeedback'; -export { useTurnstileField } from './useTurnstileField'; +export { useCaptchaField } from './useCaptchaField'; export { buildEmailOtpTarget, getEmailOtpVerifyFieldErrors, diff --git a/src/react-app/components/auth/useCaptchaField.ts b/src/react-app/components/auth/useCaptchaField.ts new file mode 100644 index 00000000..6b3f9097 --- /dev/null +++ b/src/react-app/components/auth/useCaptchaField.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef, useState } from 'react'; +import type { AltchaCaptchaHandle } from './AltchaCaptcha'; + +export function useCaptchaField() { + const captchaRef = useRef(null); + const [captchaPayload, setCaptchaPayload] = useState(null); + const [captchaError, setCaptchaError] = useState(''); + + const updateCaptchaPayload = useCallback((payload: string | null) => { + setCaptchaPayload(payload); + if (payload) { + setCaptchaError(''); + } + }, []); + + const ensureCaptcha = () => { + if (!captchaPayload) { + setCaptchaError('请先完成人机验证'); + return null; + } + + setCaptchaError(''); + return captchaPayload; + }; + + const resetCaptcha = () => { + captchaRef.current?.reset(); + setCaptchaError(''); + }; + + return { + captchaRef, + captchaError, + ensureCaptcha, + resetCaptcha, + setCaptchaError, + setCaptchaPayload: updateCaptchaPayload, + }; +} diff --git a/src/react-app/components/auth/useTurnstileField.ts b/src/react-app/components/auth/useTurnstileField.ts deleted file mode 100644 index 5d65b86b..00000000 --- a/src/react-app/components/auth/useTurnstileField.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { clientEnv } from '@app/lib/env'; -import { useRef, useState } from 'react'; -import type { TurnstileCaptchaHandle } from './TurnstileCaptcha'; - -export function useTurnstileField() { - const turnstileEnabled = Boolean(clientEnv.turnstileSiteKey); - const turnstileRef = useRef(null); - const [captchaToken, setCaptchaToken] = useState(null); - const [captchaError, setCaptchaError] = useState(''); - - const updateCaptchaToken = (token: string | null) => { - setCaptchaToken(token); - if (token) { - setCaptchaError(''); - } - }; - - const ensureCaptcha = () => { - if (!turnstileEnabled) { - return ''; - } - - if (!captchaToken) { - setCaptchaError('请先完成人机验证'); - return null; - } - - setCaptchaError(''); - return captchaToken; - }; - - const resetCaptcha = () => { - turnstileRef.current?.reset(); - setCaptchaError(''); - }; - - return { - turnstileEnabled, - turnstileRef, - captchaError, - ensureCaptcha, - resetCaptcha, - setCaptchaError, - setCaptchaToken: updateCaptchaToken, - }; -} diff --git a/src/react-app/lib/auth/AuthProvider.tsx b/src/react-app/lib/auth/AuthProvider.tsx index d2d743aa..672b8db4 100644 --- a/src/react-app/lib/auth/AuthProvider.tsx +++ b/src/react-app/lib/auth/AuthProvider.tsx @@ -23,14 +23,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { name, email, password, - captchaToken, + captchaPayload, ) => { const { error } = await authClient.signUp.email({ name: name.trim(), email: normalizeEmail(email), password, callbackURL: getDefaultGameRedirectUrl(), - fetchOptions: getCaptchaFetchOptions(captchaToken), + fetchOptions: getCaptchaFetchOptions(captchaPayload), }); if (!error) { @@ -45,13 +45,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signInWithPassword: AuthContextType['signInWithPassword'] = async ( email, password, - captchaToken, + captchaPayload, ) => { const { error } = await authClient.signIn.email({ email: normalizeEmail(email), password, callbackURL: getDefaultGameRedirectUrl(), - fetchOptions: getCaptchaFetchOptions(captchaToken), + fetchOptions: getCaptchaFetchOptions(captchaPayload), }); if (!error) { @@ -65,12 +65,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signInWithEmailOtp: AuthContextType['signInWithEmailOtp'] = async ( email, - captchaToken, + captchaPayload, ) => { const { error } = await authClient.emailOtp.sendVerificationOtp({ email: normalizeEmail(email), type: 'sign-in', - fetchOptions: getCaptchaFetchOptions(captchaToken), + fetchOptions: getCaptchaFetchOptions(captchaPayload), }); return { @@ -114,13 +114,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const requestPasswordReset: AuthContextType['requestPasswordReset'] = async ( email, - captchaToken, + captchaPayload, redirectTo, ) => { const { error } = await authClient.requestPasswordReset({ email: normalizeEmail(email), redirectTo: redirectTo || getDefaultResetRedirectUrl(), - fetchOptions: getCaptchaFetchOptions(captchaToken), + fetchOptions: getCaptchaFetchOptions(captchaPayload), }); return { diff --git a/src/react-app/lib/auth/authState.ts b/src/react-app/lib/auth/authState.ts index f4b337de..362cb366 100644 --- a/src/react-app/lib/auth/authState.ts +++ b/src/react-app/lib/auth/authState.ts @@ -22,16 +22,16 @@ export interface AuthContextType { name: string, email: string, password: string, - captchaToken?: string, + captchaPayload?: string, ) => Promise<{ error: AuthActionError | null }>; signInWithPassword: ( email: string, password: string, - captchaToken?: string, + captchaPayload?: string, ) => Promise<{ error: AuthActionError | null }>; signInWithEmailOtp: ( email: string, - captchaToken?: string, + captchaPayload?: string, ) => Promise<{ error: AuthActionError | null }>; verifyEmailOtp: ( email: string, @@ -43,7 +43,7 @@ export interface AuthContextType { ) => Promise<{ error: AuthActionError | null }>; requestPasswordReset: ( email: string, - captchaToken?: string, + captchaPayload?: string, redirectTo?: string, ) => Promise<{ error: AuthActionError | null }>; resetPassword: ( @@ -77,14 +77,14 @@ export function getDefaultGameRedirectUrl() { return new URL('/game', window.location.origin).toString(); } -export function getCaptchaFetchOptions(captchaToken?: string) { - if (!captchaToken) { +export function getCaptchaFetchOptions(captchaPayload?: string) { + if (!captchaPayload) { return undefined; } return { headers: { - 'x-turnstile-token': captchaToken, + 'x-altcha-payload': captchaPayload, }, }; } diff --git a/src/react-app/lib/env.ts b/src/react-app/lib/env.ts index c72e7534..e9d969b8 100644 --- a/src/react-app/lib/env.ts +++ b/src/react-app/lib/env.ts @@ -13,5 +13,4 @@ function getOptionalEnv(...names: string[]): string | undefined { export const clientEnv = { apiBaseUrl: getOptionalEnv('VITE_API_BASE_URL')?.replace(/\/+$/, ''), - turnstileSiteKey: getOptionalEnv('VITE_TURNSTILE_SITE_KEY'), }; diff --git a/src/react-app/routes/forgot-password/route.tsx b/src/react-app/routes/forgot-password/route.tsx index ea3cb4b3..5ea77181 100644 --- a/src/react-app/routes/forgot-password/route.tsx +++ b/src/react-app/routes/forgot-password/route.tsx @@ -1,10 +1,10 @@ import { AuthPageShell, - AuthTurnstileField, + AuthCaptchaField, buildEmailOtpTarget, toErrorMessage, useAuthFeedback, - useTurnstileField, + useCaptchaField, validateEmailField, } from '@app/components/auth'; import { InkButton } from '@app/components/ui/InkButton'; @@ -19,13 +19,12 @@ export default function ForgotPasswordRoute() { const { requestPasswordReset } = useAuth(); const { showDialog, showErrorDialog } = useAuthFeedback(); const { - turnstileEnabled, - turnstileRef, + captchaRef, captchaError, ensureCaptcha, resetCaptcha, - setCaptchaToken, - } = useTurnstileField(); + setCaptchaPayload, + } = useCaptchaField(); const [email, setEmail] = useState(searchParams.get('email') ?? ''); const [loading, setLoading] = useState(false); @@ -112,11 +111,11 @@ export default function ForgotPasswordRoute() { error={errors.email} disabled={loading} /> - - - - | null = null; + +function getAltchaSecret(): string | null { + return process.env.ALTCHA_HMAC_SECRET?.trim() || null; +} + +async function getAltchaKeySignatureSecret(secret: string): Promise { + if (derivedSecretSource !== secret || !derivedSecretPromise) { + derivedSecretSource = secret; + derivedSecretPromise = deriveHmacKeySecret(secret); + } + + return derivedSecretPromise; +} + +function parsePayload(payload: string): Payload | null { + if (!payload || payload.length > ALTCHA_MAX_PAYLOAD_LENGTH) { + return null; + } + + try { + const parsed = JSON.parse( + Buffer.from(payload, 'base64').toString('utf8'), + ) as Payload; + + if ( + !parsed || + typeof parsed !== 'object' || + !parsed.challenge || + !parsed.solution + ) { + return null; + } + + return parsed; + } catch { + return null; + } +} + +export function isAltchaAction(value: string): value is AltchaAction { + return ALTCHA_ACTIONS.some((action) => action === value); +} + +export function isAltchaServerEnabled(): boolean { + return Boolean(getAltchaSecret()); +} + +export async function createAltchaChallenge(action: AltchaAction) { + const secret = getAltchaSecret(); + if (!secret) { + throw new Error('ALTCHA_HMAC_SECRET is required'); + } + + return createChallenge({ + algorithm: 'PBKDF2/SHA-256', + cost: ALTCHA_PBKDF2_COST, + counter: randomInt(ALTCHA_COUNTER_MAX, ALTCHA_COUNTER_MIN), + data: { action }, + deriveKey, + expiresAt: new Date(Date.now() + ALTCHA_CHALLENGE_TTL_MS), + hmacSignatureSecret: secret, + hmacKeySignatureSecret: await getAltchaKeySignatureSecret(secret), + }); +} + +export async function verifyAltchaPayload( + payloadValue: string, + expectedAction: AltchaAction, +): Promise { + const secret = getAltchaSecret(); + if (!secret) { + return 'unavailable'; + } + + const payload = parsePayload(payloadValue); + if (!payload) { + return 'invalid'; + } + + const { challenge, solution } = payload; + const { parameters } = challenge; + if ( + parameters.data?.action !== expectedAction || + typeof parameters.expiresAt !== 'number' || + typeof parameters.nonce !== 'string' + ) { + return 'invalid'; + } + + try { + const verification = await verifySolution({ + challenge, + deriveKey, + hmacSignatureSecret: secret, + hmacKeySignatureSecret: await getAltchaKeySignatureSecret(secret), + solution, + }); + + if (!verification.verified) { + return 'invalid'; + } + + const ttlSeconds = Math.floor(parameters.expiresAt - Date.now() / 1_000); + if (ttlSeconds <= 0) { + return 'invalid'; + } + + const consumed = await redis.set( + `${ALTCHA_REPLAY_KEY_PREFIX}${parameters.nonce}`, + '1', + 'EX', + ttlSeconds, + 'NX', + ); + + return consumed === 'OK' ? 'verified' : 'invalid'; + } catch (error) { + console.error('[altcha] verification unavailable', error); + return 'unavailable'; + } +} diff --git a/src/server/lib/auth/hono.ts b/src/server/lib/auth/hono.ts index 23c4db73..0ed7e1fa 100644 --- a/src/server/lib/auth/hono.ts +++ b/src/server/lib/auth/hono.ts @@ -1,19 +1,19 @@ import { auth } from '@server/lib/auth/auth'; import { authUsers } from '@server/lib/auth/schema'; import { - isTurnstileServerEnabled, - verifyTurnstileToken, -} from '@server/lib/auth/turnstile'; + isAltchaServerEnabled, + verifyAltchaPayload, + type AltchaAction, +} from '@server/lib/auth/altcha'; import { db } from '@server/lib/drizzle/db'; -import { getRequestIp } from '@server/lib/http/requestIp'; import { eq } from 'drizzle-orm'; import type { Context } from 'hono'; -const CAPTCHA_PROTECTED_PATHS = new Set([ - '/api/auth/sign-in/email', - '/api/auth/sign-up/email', - '/api/auth/request-password-reset', - '/api/auth/email-otp/send-verification-otp', +const CAPTCHA_ACTION_BY_PATH = new Map([ + ['/api/auth/sign-in/email', 'sign-in'], + ['/api/auth/sign-up/email', 'sign-up'], + ['/api/auth/request-password-reset', 'password-reset'], + ['/api/auth/email-otp/send-verification-otp', 'email-otp'], ]); const ADMIN_AUTH_PATH = '/api/auth/admin'; @@ -47,27 +47,36 @@ function authError(message: string, status = 400) { } async function validateCaptcha(context: Context): Promise { - if (!CAPTCHA_PROTECTED_PATHS.has(context.req.path)) { + const action = CAPTCHA_ACTION_BY_PATH.get(context.req.path); + if (!action) { return null; } - if (!isTurnstileServerEnabled()) { - return null; + if (!isAltchaServerEnabled()) { + return authError('人机验证服务未配置', 503); } const body = await readRequestBody(context.req.raw); - const captchaTokenHeader = context.req.header('x-turnstile-token'); - const captchaTokenBody = - typeof body.captchaToken === 'string' ? body.captchaToken : ''; - const captchaToken = captchaTokenHeader || captchaTokenBody; + const captchaPayloadHeader = context.req.header('x-altcha-payload'); + const captchaPayloadBody = + typeof body.altcha === 'string' + ? body.altcha + : typeof body.captchaPayload === 'string' + ? body.captchaPayload + : ''; + const captchaPayload = captchaPayloadHeader || captchaPayloadBody; - if (!captchaToken) { + if (!captchaPayload) { return authError('请先完成人机验证'); } - const verified = await verifyTurnstileToken(captchaToken, getRequestIp(context)); + const verification = await verifyAltchaPayload(captchaPayload, action); - if (!verified) { + if (verification === 'unavailable') { + return authError('人机验证服务暂不可用,请稍后重试', 503); + } + + if (verification !== 'verified') { return authError('人机验证失败,请重试'); } diff --git a/src/server/lib/auth/turnstile.ts b/src/server/lib/auth/turnstile.ts deleted file mode 100644 index 8e0c5ff9..00000000 --- a/src/server/lib/auth/turnstile.ts +++ /dev/null @@ -1,48 +0,0 @@ -const TURNSTILE_VERIFY_URL = - 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - -function getTurnstileSecret(): string | null { - return ( - process.env.TURNSTILE_SECRET_KEY ?? - process.env.TURNSTILE_SECRET ?? - null - ); -} - -export function isTurnstileServerEnabled(): boolean { - return Boolean(getTurnstileSecret()); -} - -export async function verifyTurnstileToken( - token: string, - remoteIp?: string, -): Promise { - const secret = getTurnstileSecret(); - - if (!secret) { - return true; - } - - const formData = new FormData(); - formData.set('secret', secret); - formData.set('response', token); - - if (remoteIp) { - formData.set('remoteip', remoteIp); - } - - const response = await fetch(TURNSTILE_VERIFY_URL, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - return false; - } - - const result = (await response.json()) as { - success?: boolean; - }; - - return result.success === true; -} diff --git a/src/server/lib/http/cors.ts b/src/server/lib/http/cors.ts index 30676833..b2f4d0e9 100644 --- a/src/server/lib/http/cors.ts +++ b/src/server/lib/http/cors.ts @@ -6,7 +6,7 @@ export const apiCorsOptions = { 'Content-Type', 'Authorization', 'Idempotency-Key', - 'x-turnstile-token', + 'x-altcha-payload', 'x-llm-api-key', 'x-llm-model', ], diff --git a/src/server/routes/api/captcha.router.ts b/src/server/routes/api/captcha.router.ts new file mode 100644 index 00000000..58246600 --- /dev/null +++ b/src/server/routes/api/captcha.router.ts @@ -0,0 +1,31 @@ +import { + createAltchaChallenge, + isAltchaAction, + isAltchaServerEnabled, +} from '@server/lib/auth/altcha'; +import type { AppEnv } from '@server/lib/hono/types'; +import { Hono } from 'hono'; + +const router = new Hono(); + +router.get('/challenge', async (c) => { + const action = c.req.query('action') ?? ''; + if (!isAltchaAction(action)) { + return c.json({ success: false, error: '无效的人机验证场景' }, 400); + } + + if (!isAltchaServerEnabled()) { + return c.json({ success: false, error: '人机验证服务未配置' }, 503); + } + + try { + const challenge = await createAltchaChallenge(action); + c.header('Cache-Control', 'no-store'); + return c.json(challenge); + } catch (error) { + console.error('[altcha] challenge creation failed', error); + return c.json({ success: false, error: '人机验证服务暂不可用' }, 503); + } +}); + +export default router; diff --git a/src/server/routes/api/index.ts b/src/server/routes/api/index.ts index c2138c0a..00847507 100644 --- a/src/server/routes/api/index.ts +++ b/src/server/routes/api/index.ts @@ -11,6 +11,7 @@ import battleRecordsRouter from '@server/routes/api/battle-records.router'; import battleMatchesRouter from '@server/routes/api/battle-matches.router'; import betBattlesRouter from '@server/routes/api/bet-battles.router'; import blackMarketRouter from '@server/routes/api/black-market.router'; +import captchaRouter from '@server/routes/api/captcha.router'; import communityRouter from '@server/routes/api/community.router'; import craftRouter from '@server/routes/api/craft.router'; import cultivatorRouter from '@server/routes/api/cultivator.router'; @@ -83,6 +84,7 @@ apiRouter.route('/battle-records', battleRecordsRouter); apiRouter.route('/battle-matches', battleMatchesRouter); apiRouter.route('/bet-battles', betBattlesRouter); apiRouter.route('/black-market', blackMarketRouter); +apiRouter.route('/captcha', captchaRouter); apiRouter.route('/community', communityRouter); apiRouter.route('/craft', craftRouter); apiRouter.route('/cultivator', cultivatorRouter);