diff --git a/config.example.yaml b/config.example.yaml index 118b90f..b7625d1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -31,6 +31,13 @@ projectApp: - interval_seconds: 60 max_count: 20 +# captcha (hCaptcha) - 领取接口的人机验证由后端校验 +# 本地开发可用 hCaptcha 测试密钥(配合测试 sitekey 10000000-ffff-ffff-ffff-000000000001): +# secret_key: "0x0000000000000000000000000000000000000000" +captcha: + secret_key: "" + verify_url: "https://api.hcaptcha.com/siteverify" + # OAuth2 oauth2: client_id: "" diff --git a/docs/docs.go b/docs/docs.go index 91af347..1bdb91e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -648,6 +648,47 @@ const docTemplate = `{ } } }, + "/api/v1/projects/{id}/receive/token": { + "get": { + "description": "项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交", + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "获取领取凭证", + "parameters": [ + { + "type": "string", + "description": "项目ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/project.ProjectResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/project.ReceiveTokenResponseData" + } + } + } + ] + } + } + } + } + }, "/api/v1/projects/{id}/receivers": { "get": { "consumes": [ @@ -1422,6 +1463,17 @@ const docTemplate = `{ } } }, + "project.ReceiveTokenResponseData": { + "type": "object", + "properties": { + "expires_in": { + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, "project.ReportProjectRequestBody": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 8ff4ed2..5abc044 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -639,6 +639,47 @@ } } }, + "/api/v1/projects/{id}/receive/token": { + "get": { + "description": "项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交", + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "获取领取凭证", + "parameters": [ + { + "type": "string", + "description": "项目ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/project.ProjectResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/project.ReceiveTokenResponseData" + } + } + } + ] + } + } + } + } + }, "/api/v1/projects/{id}/receivers": { "get": { "consumes": [ @@ -1413,6 +1454,17 @@ } } }, + "project.ReceiveTokenResponseData": { + "type": "object", + "properties": { + "expires_in": { + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, "project.ReportProjectRequestBody": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e31a265..6f7aa40 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -428,6 +428,13 @@ definitions: label: type: string type: object + project.ReceiveTokenResponseData: + properties: + expires_in: + type: integer + token: + type: string + type: object project.ReportProjectRequestBody: properties: reason: @@ -823,6 +830,30 @@ paths: summary: 获取当前用户的待支付订单 tags: - payment + /api/v1/projects/{id}/receive/token: + get: + description: 项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交 + parameters: + - description: 项目ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/project.ProjectResponse' + - properties: + data: + $ref: '#/definitions/project.ReceiveTokenResponseData' + type: object + summary: 获取领取凭证 + tags: + - project /api/v1/projects/{id}/receivers: get: consumes: diff --git a/frontend/.env.example b/frontend/.env.example index f804ffa..24970cc 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,3 @@ FRONTEND_BASE_URL=http://localhost:3000 BACKEND_BASE_URL=http://localhost:8000 NEXT_PUBLIC_HCAPTCHA_SITE_KEY=YOUR_NEXT_PUBLIC_HCAPTCHA_SITE_KEY -HCAPTCHA_SECRET_KEY=YOUR_HCAPTCHA_SECRET_KEY \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5db7b68..4c41764 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,7 +13,6 @@ ENV NODE_ENV=production ENV NEXT_PUBLIC_FRONTEND_BASE_URL=__NEXT_PUBLIC_FRONTEND_BASE_URL__ ENV NEXT_PUBLIC_BACKEND_BASE_URL=https://build-placeholder.invalid ENV NEXT_PUBLIC_HCAPTCHA_SITE_KEY=__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__ -ENV HCAPTCHA_SECRET_KEY=__HCAPTCHA_SECRET_KEY__ ARG VERSION="" ARG BUILD_DATE="" @@ -37,7 +36,6 @@ RUN grep -rl \ -e "__NEXT_PUBLIC_FRONTEND_BASE_URL__" \ -e "https://build-placeholder.invalid" \ -e "__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__" \ - -e "__HCAPTCHA_SECRET_KEY__" \ /app/.next > /app/.replace.files # ---- runner stage ---- diff --git a/frontend/components/common/receive/ReceiveContent.tsx b/frontend/components/common/receive/ReceiveContent.tsx index 389194e..9e4d85f 100644 --- a/frontend/components/common/receive/ReceiveContent.tsx +++ b/frontend/components/common/receive/ReceiveContent.tsx @@ -302,6 +302,7 @@ export function ReceiveContent({data}: ReceiveContentProps) { const [isCheckingPendingPayment, setIsCheckingPendingPayment] = useState(false); const [isContinuingPayment, setIsContinuingPayment] = useState(false); const verifyRef = useRef(null); + const receiveTokenRef = useRef(null); /** * 检查项目是否可以领取(时间限制) @@ -315,7 +316,7 @@ export function ReceiveContent({data}: ReceiveContentProps) { /** * 处理项目领取(触发验证) */ - const handleReceive = () => { + const handleReceive = async () => { if (!projectId || hasReceived || isVerifying) return; // 检查项目时间 @@ -331,7 +332,16 @@ export function ReceiveContent({data}: ReceiveContentProps) { return; } - // 触发验证 + // 先向服务端获取一次性领取凭证,再触发验证;验证通过后两者一起提交 + setIsVerifying(true); + try { + const {token} = await services.project.getReceiveToken(projectId); + receiveTokenRef.current = token; + } catch (error) { + setIsVerifying(false); + toast.error(error instanceof Error ? error.message : '获取领取凭证失败'); + return; + } verifyRef.current?.execute(); }; @@ -367,8 +377,14 @@ export function ReceiveContent({data}: ReceiveContentProps) { * 验证成功后处理 */ const handleVerifySuccess = async (token: string) => { - // 调用领取接口 - const result = await services.project.receiveProjectSafe(projectId, token); + const receiveToken = receiveTokenRef.current; + receiveTokenRef.current = null; + if (!receiveToken) { + toast.error('领取凭证已失效,请重试'); + throw new Error('missing receive token'); + } + // 调用领取接口:验证码 + 领取凭证一起提交 + const result = await services.project.receiveProjectSafe(projectId, token, receiveToken); if (!result.success) { toast.error(result.error || '领取失败'); @@ -407,6 +423,7 @@ export function ReceiveContent({data}: ReceiveContentProps) { * 验证结束回调 */ const handleVerifyEnd = () => { + receiveTokenRef.current = null; setIsVerifying(false); }; diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 18ee63e..96ed4fd 100755 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -34,6 +34,5 @@ replace_placeholder() { replace_placeholder "__NEXT_PUBLIC_FRONTEND_BASE_URL__" "$NEXT_PUBLIC_FRONTEND_BASE_URL" replace_placeholder "https://build-placeholder.invalid" "$NEXT_PUBLIC_BACKEND_BASE_URL" replace_placeholder "__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__" "$NEXT_PUBLIC_HCAPTCHA_SITE_KEY" -replace_placeholder "__HCAPTCHA_SECRET_KEY__" "$HCAPTCHA_SECRET_KEY" exec "$@" diff --git a/frontend/lib/services/project/project.service.ts b/frontend/lib/services/project/project.service.ts index f12ed6d..e8ab6aa 100644 --- a/frontend/lib/services/project/project.service.ts +++ b/frontend/lib/services/project/project.service.ts @@ -17,6 +17,8 @@ import { ProjectListResponse, ApiRequestParams, ReceiveProjectData, + ReceiveTokenData, + ReceiveTokenResponse, ReportProjectResponse, ProjectReceiver, ProjectReceiversResponse, @@ -120,16 +122,30 @@ export class ProjectService extends BaseService { } /** - * 领取项目内容(必须带验证码) + * 获取领取凭证(服务端签发的一次性 token) + * @param projectId - 项目ID + */ + static async getReceiveToken(projectId: string): Promise { + const response = await apiClient.get(`${this.basePath}/${projectId}/receive/token`); + if (response.data.error_msg) { + throw new Error(response.data.error_msg); + } + return response.data.data; + } + + /** + * 领取项目内容(验证码 + 领取凭证一起提交) * @param projectId - 项目ID * @param captchaToken - hCaptcha验证令牌 + * @param receiveToken - 服务端签发的领取凭证 * @returns 领取结果,包含领取内容 */ - static async receiveProject(projectId: string, captchaToken: string): Promise { + static async receiveProject(projectId: string, captchaToken: string, receiveToken: string): Promise { const response = await apiClient.post( `${this.basePath}/${projectId}/receive`, { captcha_token: captchaToken, + receive_token: receiveToken, }, ); if (response.data.error_msg) { @@ -370,13 +386,13 @@ export class ProjectService extends BaseService { * @param captchaToken - hCaptcha验证令牌 * @returns 领取结果,包含成功状态、领取内容和错误信息 */ - static async receiveProjectSafe(projectId: string, captchaToken: string): Promise<{ + static async receiveProjectSafe(projectId: string, captchaToken: string, receiveToken: string): Promise<{ success: boolean; data?: ReceiveProjectData; error?: string; }> { try { - const data = await this.receiveProject(projectId, captchaToken); + const data = await this.receiveProject(projectId, captchaToken, receiveToken); return {success: true, data}; } catch (error) { const errorMessage = error instanceof Error ? error.message : '领取项目内容失败'; diff --git a/frontend/lib/services/project/types.ts b/frontend/lib/services/project/types.ts index c17cf99..7bfce26 100644 --- a/frontend/lib/services/project/types.ts +++ b/frontend/lib/services/project/types.ts @@ -211,6 +211,18 @@ export interface ReceiveProjectData { */ export type ReceiveProjectResponse = BackendResponse; +/** + * 领取凭证 + */ +export interface ReceiveTokenData { + /** 一次性凭证,领取时随 captcha_token 一并提交 */ + token: string; + /** 有效期(秒) */ + expires_in: number; +} + +export type ReceiveTokenResponse = BackendResponse; + /** * 获取项目详情响应数据 */ diff --git a/frontend/middleware.ts b/frontend/middleware.ts index ce81975..d582c6d 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,175 +1,10 @@ -import {NextRequest, NextResponse} from 'next/server'; - -const HCAPTCHA_VERIFY_URL = 'https://hcaptcha.com/siteverify'; -const HCAPTCHA_SECRET_KEY = process.env.HCAPTCHA_SECRET_KEY || 'your-hcaptcha-secret-key'; -const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL || 'http://localhost:8000'; -const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; - -interface HCaptchaResponse { - success: boolean; - 'error-codes'?: string[]; -} - -interface ReceiveRequestBody { - captcha_token?: string; - - [key: string]: string | number | boolean | null | undefined; -} - -/** - * 获取客户端IP地址 - */ -function getClientIP(request: NextRequest): string { - const forwardedFor = request.headers.get('x-forwarded-for'); - const realIp = request.headers.get('x-real-ip'); - return forwardedFor?.split(',')[0]?.trim() || realIp || 'unknown'; -} - -/** - * 格式化错误信息 - */ -function formatErrorMessage(error: unknown, fallbackMessage: string): string { - if (IS_DEVELOPMENT && error instanceof Error) { - return `${fallbackMessage}: ${error.message}`; - } - return fallbackMessage; -} - -/** - * 验证hCaptcha令牌 - */ -async function verifyCaptcha(token: string, remoteip?: string): Promise<{ success: boolean; error?: string }> { - try { - const response = await fetch(HCAPTCHA_VERIFY_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - secret: HCAPTCHA_SECRET_KEY, - response: token, - remoteip: remoteip || '', - }), - }); - - if (!response.ok) { - return {success: false, error: 'hCaptcha服务暂时不可用,请稍后重试'}; - } - - const data: HCaptchaResponse = await response.json(); - - if (!data.success) { - const errorCodes = data['error-codes'] || []; - - // 根据错误代码返回用户友好的错误信息 - if (errorCodes.includes('missing-input-response')) { - return {success: false, error: '请完成人机验证'}; - } - if (errorCodes.includes('invalid-input-response')) { - return {success: false, error: '验证码无效,请重新验证'}; - } - if (errorCodes.includes('timeout-or-duplicate')) { - return {success: false, error: '验证已过期,请重新验证'}; - } - if (errorCodes.includes('invalid-input-secret')) { - return {success: false, error: '验证服务配置错误,请联系管理员'}; - } - - return {success: false, error: '人机验证失败,请重新验证'}; - } - - return {success: true}; - } catch (error) { - return {success: false, error: formatErrorMessage(error, '验证服务连接失败,请检查网络后重试')}; - } -} +import {NextResponse} from 'next/server'; /** - * 处理领取请求 + * 领取请求的 hCaptcha 校验已移至后端(与领取凭证在同一请求内校验), + * 这里不再拦截,/api/* 统一由 next.config.ts 的 rewrites 代理到后端。 */ -async function handleReceiveRequest(request: NextRequest, pathname: string): Promise { - const clientIP = getClientIP(request); - - try { - // 解析请求体 - const body: ReceiveRequestBody = await request.json().catch(() => ({})); - - // 验证必要参数 - if (!body.captcha_token) { - return NextResponse.json( - {error_msg: '缺少必要的验证信息'}, - {status: 400}, - ); - } - - // 验证hCaptcha - const captchaResult = await verifyCaptcha(body.captcha_token, clientIP); - if (!captchaResult.success) { - return NextResponse.json( - {error_msg: captchaResult.error || '人机验证失败,请重新验证'}, - {status: 400}, - ); - } - - // 准备转发到后端的请求 - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - const {captcha_token: _captchaToken, ...backendBody} = body; - const backendUrl = `${BACKEND_BASE_URL}${pathname}`; - - const backendResponse = await fetch(backendUrl, { - method: request.method, - headers: { - 'Content-Type': 'application/json', - 'X-Forwarded-For': clientIP, - 'X-Original-Host': request.headers.get('host') || '', - 'Cookie': request.headers.get('cookie') || '', - 'User-Agent': 'CDK-Frontend-Middleware', - 'Referer': request.headers.get('referer') || '', - 'Origin': request.headers.get('origin') || '', - }, - body: Object.keys(backendBody).length > 0 ? JSON.stringify(backendBody) : undefined, - credentials: 'include', - }); - - const backendData = await backendResponse.json(); - - return NextResponse.json(backendData, { - status: backendResponse.status, - headers: { - 'Set-Cookie': backendResponse.headers.get('Set-Cookie') || '', - }, - }); - } catch (error) { - return NextResponse.json( - {error_msg: formatErrorMessage(error, '服务器内部错误,请稍后重试')}, - {status: 500}, - ); - } -} - -/** - * 检查是否为领取请求 - */ -function isReceiveRequest(pathname: string, method: string): boolean { - return pathname.includes('/receive') && method === 'POST'; -} - -/** - * 中间件主函数 - */ -export async function middleware(request: NextRequest) { - const {pathname} = request.nextUrl; - - if (!pathname.startsWith('/api/')) { - return NextResponse.next(); - } - - // 处理领取请求 - if (isReceiveRequest(pathname, request.method)) { - return handleReceiveRequest(request, pathname); - } - - // 其他请求直接通过 +export function middleware() { return NextResponse.next(); } diff --git a/internal/apps/project/err.go b/internal/apps/project/err.go index e948856..a1af145 100644 --- a/internal/apps/project/err.go +++ b/internal/apps/project/err.go @@ -25,19 +25,25 @@ package project const ( - NoPermission = "无权限" - AlreadyReceived = "已有用户领取,不允许删除" - TimeTooEarly = "未到开启时间" - TimeTooLate = "已经结束" - TrustLevelNotMatch = "社区等级未达标,需要信任等级 %d" - UnknownError = "未知异常" - ScoreNotEnough = "社区分数未达标,需要分数 %d" - SameIPReceived = "已有相同IP领取" - NoStock = "无库存" - NotFound = "项目不存在" - AlreadyReported = "已举报过当前项目" - RequirementsFailed = "未达到项目发起者设置的条件" - TooManyRequests = "创建项目太频繁,请稍后再试" + NoPermission = "无权限" + AlreadyReceived = "已有用户领取,不允许删除" + TimeTooEarly = "未到开启时间" + TimeTooLate = "已经结束" + TrustLevelNotMatch = "社区等级未达标,需要信任等级 %d" + UnknownError = "未知异常" + ScoreNotEnough = "社区分数未达标,需要分数 %d" + SameIPReceived = "已有相同IP领取" + NoStock = "无库存" + NotFound = "项目不存在" + AlreadyReported = "已举报过当前项目" + RequirementsFailed = "未达到项目发起者设置的条件" + TooManyRequests = "创建项目太频繁,请稍后再试" + ReceiveTokenInvalid = "领取凭证无效或已过期,请重试" + CaptchaRequired = "请先完成人机验证" + CaptchaInvalid = "人机验证无效,请重新验证" + CaptchaExpired = "人机验证已过期,请重新验证" + CaptchaSolvedTooEarly = "人机验证无效,请点击领取后再完成验证" + CaptchaUnavailable = "人机验证服务暂时不可用,请稍后重试" // Payment 相关 InvalidPrice = "金额必须大于等于 0" InvalidPriceDecimals = "金额最多保留 2 位小数" diff --git a/internal/apps/project/receive_token.go b/internal/apps/project/receive_token.go new file mode 100644 index 0000000..319fc2d --- /dev/null +++ b/internal/apps/project/receive_token.go @@ -0,0 +1,279 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package project + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" + "github.com/linux-do/cdk/internal/utils" + "github.com/redis/go-redis/v9" +) + +// 领取凭证(receive token)+ 服务端人机验证 +// +// 领取分两步: +// 1. GET /projects/:id/receive/token 项目可领取时签发一次性凭证(绑定 用户+项目),记录签发时间 +// 2. POST /projects/:id/receive 提交 captcha_token + receive_token +// +// 服务端校验顺序:消费凭证(一次性) → hCaptcha siteverify → 验证码解出时间(challenge_ts) 必须晚于凭证签发时间。 +// 最后一条保证验证码只能在"取凭证之后"解出,提前用 window.hcaptcha.execute() 囤的验证码无法使用; +// 正常用户的流程天然是 点击 → 取凭证 → 弹验证码,不受影响。 + +const ( + // receiveTokenTTL 凭证有效期 + receiveTokenTTL = 2 * time.Minute + // receiveTokenBytes 凭证随机字节数 + receiveTokenBytes = 32 + // receiveBodyMaxBytes 领取请求体上限 + receiveBodyMaxBytes = 64 << 10 + // captchaTokenMaxLength 验证码 token 长度上限 + captchaTokenMaxLength = 8192 + // captchaClockSkew 允许 hCaptcha 与本服务之间的时钟偏差(challenge_ts 精度 1s,双方均 NTP 对时) + captchaClockSkew = 3 * time.Second + // defaultCaptchaVerifyURL hCaptcha 官方校验地址 + defaultCaptchaVerifyURL = "https://api.hcaptcha.com/siteverify" +) + +// receiveTokenConsumeScript 原子比较并删除:token 一致才删除并返回存储值(含签发时间),保证一次性 +var receiveTokenConsumeScript = redis.NewScript(` +local v = redis.call('GET', KEYS[1]) +if v and string.sub(v, 1, #ARGV[1] + 1) == ARGV[1] .. ':' then + redis.call('DEL', KEYS[1]) + return v +end +return false +`) + +// ReceiveTokenResponseData 领取凭证响应 +type ReceiveTokenResponseData struct { + Token string `json:"token"` + ExpiresIn int `json:"expires_in"` +} + +type receiveRequestBody struct { + CaptchaToken string `json:"captcha_token"` + ReceiveToken string `json:"receive_token"` +} + +type captchaVerifyResponse struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts"` + ErrorCodes []string `json:"error-codes"` +} + +func receiveTokenKey(projectID string, userID uint64) string { + return fmt.Sprintf("project:%s:receive_token:%d", projectID, userID) +} + +// GetReceiveToken +// @Tags project +// @Summary 获取领取凭证 +// @Description 项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交 +// @Produce json +// @Param id path string true "项目ID" +// @Success 200 {object} ProjectResponse{data=ReceiveTokenResponseData} +// @Router /api/v1/projects/{id}/receive/token [get] +func GetReceiveToken(c *gin.Context) { + ctx := c.Request.Context() + project, ok := GetProjectFromContext(c) + if !ok || project == nil { + c.JSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: UnknownError}) + return + } + + raw := make([]byte, receiveTokenBytes) + if _, err := rand.Read(raw); err != nil { + c.JSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: err.Error()}) + return + } + token := hex.EncodeToString(raw) + + // 存储格式:token:签发时间(unix 秒) + value := token + ":" + strconv.FormatInt(time.Now().Unix(), 10) + if err := db.Redis.Set(ctx, receiveTokenKey(project.ID, oauth.GetUserIDFromContext(c)), value, receiveTokenTTL).Err(); err != nil { + c.JSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: err.Error()}) + return + } + + c.JSON(http.StatusOK, ProjectResponse{Data: ReceiveTokenResponseData{ + Token: token, + ExpiresIn: int(receiveTokenTTL / time.Second), + }}) +} + +// consumeReceiveToken 消费凭证,返回签发时间;凭证无效返回 ok=false +func consumeReceiveToken(ctx context.Context, projectID string, userID uint64, token string) (issuedAt time.Time, ok bool, err error) { + if len(token) != receiveTokenBytes*2 { + return time.Time{}, false, nil + } + if _, decErr := hex.DecodeString(token); decErr != nil { + return time.Time{}, false, nil + } + value, err := receiveTokenConsumeScript.Run(ctx, db.Redis, []string{receiveTokenKey(projectID, userID)}, token).Text() + if errors.Is(err, redis.Nil) { + return time.Time{}, false, nil + } + if err != nil { + return time.Time{}, false, err + } + unix, parseErr := strconv.ParseInt(strings.TrimPrefix(value, token+":"), 10, 64) + if parseErr != nil { + return time.Time{}, false, nil + } + return time.Unix(unix, 0), true, nil +} + +// verifyCaptcha 调用 hCaptcha siteverify。 +// 返回 (用户可见错误, 服务端错误):前者非空表示校验不通过;后者非空表示校验服务异常。 +func verifyCaptcha(ctx context.Context, token, remoteIP string, issuedAt time.Time) (string, error) { + cfg := config.Config.Captcha + if cfg.SecretKey == "" { + return "", errors.New("captcha secret_key is not configured") + } + verifyURL := cfg.VerifyURL + if verifyURL == "" { + verifyURL = defaultCaptchaVerifyURL + } + + form := url.Values{} + form.Set("secret", cfg.SecretKey) + form.Set("response", token) + if remoteIP != "" { + form.Set("remoteip", remoteIP) + } + + resp, err := utils.Request(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()), + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, nil) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("captcha verify status %d", resp.StatusCode) + } + + var result captchaVerifyResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if !result.Success { + for _, code := range result.ErrorCodes { + switch code { + case "timeout-or-duplicate": + return CaptchaExpired, nil + case "invalid-input-secret", "missing-input-secret", "sitekey-secret-mismatch": + return "", fmt.Errorf("captcha verify config error: %v", result.ErrorCodes) + } + } + return CaptchaInvalid, nil + } + + // 顺序校验:验证码必须在凭证签发之后解出 + challengeTS, err := time.Parse(time.RFC3339, result.ChallengeTS) + if err != nil { + return CaptchaInvalid, nil + } + if challengeTS.Add(captchaClockSkew).Before(issuedAt) { + return CaptchaSolvedTooEarly, nil + } + return "", nil +} + +// ReceiveTokenMiddleware 校验并消费领取凭证,然后在服务端完成人机验证;需在 ReceiveProjectMiddleware 之后 +func ReceiveTokenMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + project, ok := GetProjectFromContext(c) + if !ok || project == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: UnknownError}) + return + } + + // 读取 body 并回填,保证后续 handler 仍可读取 + var body receiveRequestBody + if c.Request.Body != nil { + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, receiveBodyMaxBytes)) + _ = c.Request.Body.Close() + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: ReceiveTokenInvalid}) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(raw)) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &body) + } + } + body.CaptchaToken = strings.TrimSpace(body.CaptchaToken) + body.ReceiveToken = strings.TrimSpace(body.ReceiveToken) + + if body.CaptchaToken == "" || len(body.CaptchaToken) > captchaTokenMaxLength { + c.AbortWithStatusJSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: CaptchaRequired}) + return + } + + // 1. 消费凭证(一次性) + issuedAt, valid, err := consumeReceiveToken(ctx, project.ID, oauth.GetUserIDFromContext(c), body.ReceiveToken) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: err.Error()}) + return + } + if !valid { + c.AbortWithStatusJSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: ReceiveTokenInvalid}) + return + } + + // 2. 服务端人机验证 + 顺序校验 + userErr, err := verifyCaptcha(ctx, body.CaptchaToken, c.ClientIP(), issuedAt) + if err != nil { + logger.ErrorF(ctx, "[Captcha] verify failed: %v", err) + c.AbortWithStatusJSON(http.StatusServiceUnavailable, ProjectResponse{ErrorMsg: CaptchaUnavailable}) + return + } + if userErr != "" { + c.AbortWithStatusJSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: userErr}) + return + } + + c.Next() + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 6472117..d7f96ce 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -40,6 +40,7 @@ type configModel struct { OpenAPIRisk openAPIRiskConfig `mapstructure:"openapi_risk"` Otel otelConfig `mapstructure:"otel"` Payment PaymentConfig `mapstructure:"payment"` + Captcha captchaConfig `mapstructure:"captcha"` } // appConfig 应用基本配置 @@ -66,6 +67,14 @@ type projectAppConfig struct { } `mapstructure:"create_project_rate_limit"` } +// captchaConfig hCaptcha 配置(领取接口的人机验证由后端校验) +type captchaConfig struct { + // SecretKey hCaptcha 服务端密钥,必填 + SecretKey string `mapstructure:"secret_key"` + // VerifyURL siteverify 地址,默认 https://api.hcaptcha.com/siteverify + VerifyURL string `mapstructure:"verify_url"` +} + // OAuth2Config OAuth2认证配置 type OAuth2Config struct { ClientID string `mapstructure:"client_id"` diff --git a/internal/router/router.go b/internal/router/router.go index 2c896a9..c774f24 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -117,7 +117,8 @@ func Serve() { projectRouter.DELETE("/:id", project.ProjectCreatorPermMiddleware(), project.DeleteProject) projectRouter.GET("/:id/receivers", project.ProjectCreatorPermMiddleware(), project.ListProjectReceivers) projectRouter.GET("/:id/pending-payment", payment.GetPendingPayment) - projectRouter.POST("/:id/receive", project.ReceiveProjectMiddleware(), payment.DispatchReceive) + projectRouter.GET("/:id/receive/token", project.ReceiveProjectMiddleware(), project.GetReceiveToken) + projectRouter.POST("/:id/receive", project.ReceiveProjectMiddleware(), project.ReceiveTokenMiddleware(), payment.DispatchReceive) projectRouter.POST("/:id/report", project.ReportProject) projectRouter.GET("/received/chart", project.ListReceiveHistoryChart) projectRouter.GET("/received", project.ListReceiveHistory)