feat(captcha): real Tencent Captcha verification

captcha_type=tcaptcha previously fell through to the built-in graphic
captcha. Adds pkg/tcaptcha implementing the Tencent Cloud
DescribeCaptchaResult call with TC3-HMAC-SHA256 request signing, a
TCaptcha.js popup widget component emitting {ticket, randstr}, site
config exposure of the app id, and admin credential fields.

Generated with Devin
pull/3589/head
Tomas Dvorak 2 weeks ago
parent c58d3d1ad8
commit c9b9414505

@ -222,6 +222,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Storage policy overflow chain (upstream #2178 item 4) — `PolicySetting.OverflowPolicyID` links a fallback policy; `overflowChain` walks hops with cycle guard + hop cap, skipping suspended members and resolving load-balance members to weighted children; `PrepareUpload` spills to the first member with headroom for the file size so name/size/extension rules apply to the landing policy; `PreValidateUpload` checks aggregate chain headroom since batches may split across members; admin policy editor gains an Overflow policy select, en+zh locales - [x] Storage policy overflow chain (upstream #2178 item 4) — `PolicySetting.OverflowPolicyID` links a fallback policy; `overflowChain` walks hops with cycle guard + hop cap, skipping suspended members and resolving load-balance members to weighted children; `PrepareUpload` spills to the first member with headroom for the file size so name/size/extension rules apply to the landing policy; `PreValidateUpload` checks aggregate chain headroom since batches may split across members; admin policy editor gains an Overflow policy select, en+zh locales
- [x] Thumbnail generation controls (upstream #2178 items 7+8) — `PolicySetting.ThumbForceProxy` skips the backend's native thumbnail API even when supported, implying the local proxy pipeline; `PolicySetting.ThumbStoragePolicyID` redirects generated thumb entities to a designated policy (honored via `PreferredStoragePolicy` for thumbnail uploads in `PrepareUpload`); Thumbnails section gains both controls, en+zh locales - [x] Thumbnail generation controls (upstream #2178 items 7+8) — `PolicySetting.ThumbForceProxy` skips the backend's native thumbnail API even when supported, implying the local proxy pipeline; `PolicySetting.ThumbStoragePolicyID` redirects generated thumb entities to a designated policy (honored via `PreferredStoragePolicy` for thumbnail uploads in `PrepareUpload`); Thumbnails section gains both controls, en+zh locales
- [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales - [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales
- [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales
## 6. Phase D — desktop, all platforms ## 6. Phase D — desktop, all platforms

@ -34,7 +34,9 @@
"human": "I'm a human", "human": "I'm a human",
"verifying": "Verifying...", "verifying": "Verifying...",
"verified": "You're a human" "verified": "You're a human"
} },
"verify": "Click to verify",
"verified": "Verified"
}, },
"errors": { "errors": {
"401": "Please login.", "401": "Please login.",

@ -543,6 +543,13 @@
"plainCaptcha": "Plain graphic", "plainCaptcha": "Plain graphic",
"reCaptchaV2": "reCAPTCHA V2", "reCaptchaV2": "reCAPTCHA V2",
"turnstile": "Cloudflare Turnstile", "turnstile": "Cloudflare Turnstile",
"tcaptcha": "Tencent Captcha",
"tcaptchaAppId": "CaptchaAppId",
"tcaptchaAppSecretKey": "AppSecretKey",
"tcaptchaSecretId": "SecretId",
"tcaptchaSecretKey": "SecretKey",
"tcaptchaDes": "Get your CaptchaAppId and AppSecretKey from the <0>Tencent Captcha console</0>.",
"tcaptchaSecretDes": "SecretId and SecretKey of a Tencent Cloud API credential used to call DescribeCaptchaResult.",
"turnstileSiteKey": "Site Key", "turnstileSiteKey": "Site Key",
"turnstileSiteKSecret": "Secret", "turnstileSiteKSecret": "Secret",
"cap": "Cap", "cap": "Cap",

@ -34,7 +34,9 @@
"human": "我是人类", "human": "我是人类",
"verifying": "验证中…", "verifying": "验证中…",
"verified": "您通过了验证" "verified": "您通过了验证"
} },
"verify": "点击验证",
"verified": "已验证"
}, },
"errors": { "errors": {
"401": "请先登录", "401": "请先登录",

@ -543,6 +543,13 @@
"plainCaptcha": "图形", "plainCaptcha": "图形",
"reCaptchaV2": "reCAPTCHA V2", "reCaptchaV2": "reCAPTCHA V2",
"turnstile": "Cloudflare Turnstile", "turnstile": "Cloudflare Turnstile",
"tcaptcha": "腾讯验证码",
"tcaptchaAppId": "CaptchaAppId",
"tcaptchaAppSecretKey": "AppSecretKey",
"tcaptchaSecretId": "SecretId",
"tcaptchaSecretKey": "SecretKey",
"tcaptchaDes": "在 <0>腾讯验证码控制台</0> 获取 CaptchaAppId 和 AppSecretKey。",
"tcaptchaSecretDes": "用于调用 DescribeCaptchaResult 接口的腾讯云 API 密钥(SecretId / SecretKey)。",
"turnstileSiteKey": "站点密钥", "turnstileSiteKey": "站点密钥",
"turnstileSiteKSecret": "密钥", "turnstileSiteKSecret": "密钥",
"cap": "Cap", "cap": "Cap",

@ -4,7 +4,6 @@ import { User } from "./user.ts";
export enum CaptchaType { export enum CaptchaType {
NORMAL = "normal", NORMAL = "normal",
RECAPTCHA = "recaptcha", RECAPTCHA = "recaptcha",
// Deprecated
TCAPTCHA = "tcaptcha", TCAPTCHA = "tcaptcha",
TURNSTILE = "turnstile", TURNSTILE = "turnstile",
CAP = "cap", CAP = "cap",
@ -22,6 +21,7 @@ export interface SiteConfig {
user?: User; user?: User;
captcha_ReCaptchaKey?: string; captcha_ReCaptchaKey?: string;
captcha_type?: CaptchaType; captcha_type?: CaptchaType;
tcaptcha_app_id?: string;
turnstile_site_id?: string; turnstile_site_id?: string;
captcha_cap_instance_url?: string; captcha_cap_instance_url?: string;
captcha_cap_site_key?: string; captcha_cap_site_key?: string;

@ -11,6 +11,7 @@ import { SettingContext } from "../SettingWrapper.tsx";
import CapCaptcha from "./CapCaptcha.tsx"; import CapCaptcha from "./CapCaptcha.tsx";
import GraphicCaptcha from "./GraphicCaptcha.tsx"; import GraphicCaptcha from "./GraphicCaptcha.tsx";
import ReCaptcha from "./ReCaptcha.tsx"; import ReCaptcha from "./ReCaptcha.tsx";
import TCaptcha from "./TCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx"; import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
const Captcha = () => { const Captcha = () => {
@ -132,6 +133,15 @@ const Captcha = () => {
{t("settings.reCaptchaV2")} {t("settings.reCaptchaV2")}
</ListItemText> </ListItemText>
</SquareMenuItem> </SquareMenuItem>
<SquareMenuItem value={CaptchaType.TCAPTCHA}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.tcaptcha")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={CaptchaType.TURNSTILE}> <SquareMenuItem value={CaptchaType.TURNSTILE}>
<ListItemText <ListItemText
slotProps={{ slotProps={{
@ -160,6 +170,9 @@ const Captcha = () => {
<Collapse in={values.captcha_type === CaptchaType.RECAPTCHA} unmountOnExit> <Collapse in={values.captcha_type === CaptchaType.RECAPTCHA} unmountOnExit>
<ReCaptcha setSettings={setSettings} values={values} /> <ReCaptcha setSettings={setSettings} values={values} />
</Collapse> </Collapse>
<Collapse in={values.captcha_type === CaptchaType.TCAPTCHA} unmountOnExit>
<TCaptcha setSettings={setSettings} values={values} />
</Collapse>
<Collapse in={values.captcha_type === CaptchaType.TURNSTILE} unmountOnExit> <Collapse in={values.captcha_type === CaptchaType.TURNSTILE} unmountOnExit>
<TurnstileCaptcha setSettings={setSettings} values={values} /> <TurnstileCaptcha setSettings={setSettings} values={values} />
</Collapse> </Collapse>

@ -0,0 +1,66 @@
import { FormControl, Stack } from "@mui/material";
import { Trans, useTranslation } from "react-i18next";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import { NoMarginHelperText } from "../Settings.tsx";
export interface TCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const TCaptcha = ({ values, setSettings }: TCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.tcaptchaAppId")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_TCaptcha_CaptchaAppId}
onChange={(e) => setSettings({ captcha_TCaptcha_CaptchaAppId: e.target.value })}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.tcaptchaDes"
ns={"dashboard"}
components={[<a key={0} href="https://console.cloud.tencent.com/captcha/graphical" target="_blank" rel="noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.tcaptchaAppSecretKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_TCaptcha_AppSecretKey}
onChange={(e) => setSettings({ captcha_TCaptcha_AppSecretKey: e.target.value })}
required
/>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.tcaptchaSecretId")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_TCaptcha_SecretId}
onChange={(e) => setSettings({ captcha_TCaptcha_SecretId: e.target.value })}
required
/>
<NoMarginHelperText>{t("settings.tcaptchaSecretDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.tcaptchaSecretKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_TCaptcha_SecretKey}
onChange={(e) => setSettings({ captcha_TCaptcha_SecretKey: e.target.value })}
required
/>
</FormControl>
</SettingForm>
</Stack>
);
};
export default TCaptcha;

@ -234,6 +234,10 @@ const Settings = () => {
"captcha_CaptchaLen", "captcha_CaptchaLen",
"captcha_ReCaptchaKey", "captcha_ReCaptchaKey",
"captcha_ReCaptchaSecret", "captcha_ReCaptchaSecret",
"captcha_TCaptcha_CaptchaAppId",
"captcha_TCaptcha_AppSecretKey",
"captcha_TCaptcha_SecretId",
"captcha_TCaptcha_SecretKey",
"captcha_turnstile_site_key", "captcha_turnstile_site_key",
"captcha_turnstile_site_secret", "captcha_turnstile_site_secret",
"captcha_cap_instance_url", "captcha_cap_instance_url",

@ -3,6 +3,7 @@ import { useAppSelector } from "../../../redux/hooks.ts";
import CapCaptcha from "./CapCaptcha.tsx"; import CapCaptcha from "./CapCaptcha.tsx";
import DefaultCaptcha from "./DefaultCaptcha.tsx"; import DefaultCaptcha from "./DefaultCaptcha.tsx";
import ReCaptchaV2 from "./ReCaptchaV2.tsx"; import ReCaptchaV2 from "./ReCaptchaV2.tsx";
import TCaptcha from "./TCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx"; import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
export interface CaptchaProps { export interface CaptchaProps {
@ -19,18 +20,15 @@ export interface CaptchaParams {
export const Captcha = (props: CaptchaProps) => { export const Captcha = (props: CaptchaProps) => {
const captchaType = useAppSelector((state) => state.siteConfig.basic.config.captcha_type); const captchaType = useAppSelector((state) => state.siteConfig.basic.config.captcha_type);
// const recaptcha = useRecaptcha(setCaptchaLoading);
// const tcaptcha = useTCaptcha(setCaptchaLoading);
switch (captchaType) { switch (captchaType) {
case CaptchaType.RECAPTCHA: case CaptchaType.RECAPTCHA:
return <ReCaptchaV2 {...props} />; return <ReCaptchaV2 {...props} />;
case CaptchaType.TCAPTCHA:
return <TCaptcha {...props} />;
case CaptchaType.TURNSTILE: case CaptchaType.TURNSTILE:
return <TurnstileCaptcha {...props} />; return <TurnstileCaptcha {...props} />;
case CaptchaType.CAP: case CaptchaType.CAP:
return <CapCaptcha {...props} />; return <CapCaptcha {...props} />;
// case "tcaptcha":
// return { ...tcaptcha, captchaRefreshRef, captchaLoading };
default: default:
return <DefaultCaptcha {...props} />; return <DefaultCaptcha {...props} />;
} }

@ -0,0 +1,111 @@
import { Box } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../../../redux/hooks.ts";
import { SecondaryButton } from "../StyledComponents.tsx";
import { CaptchaParams } from "./Captcha.tsx";
const TCAPTCHA_SCRIPT = "https://turing.captcha.qcloud.com/TCaptcha.js";
const TCAPTCHA_SCRIPT_ID = "tcaptcha-script";
export interface TCaptchaProps {
onStateChange: (state: CaptchaParams) => void;
generation: number;
fullWidth?: boolean;
[x: string]: unknown;
}
declare global {
interface Window {
TencentCaptcha?: new (
appId: string,
callback: (res: { ret: number; ticket: string; randstr: string }) => void,
options?: Record<string, unknown>,
) => { show: () => void; destroy?: () => void };
}
}
// TCaptcha renders a verify button opening Tencent's captcha popup. On
// success the widget yields { ticket, randstr }, which the backend verifies
// via DescribeCaptchaResult.
const TCaptcha = ({ onStateChange, generation, fullWidth, ...rest }: TCaptchaProps) => {
const { t } = useTranslation("common");
const appId = useAppSelector((state) => state.siteConfig.basic.config.tcaptcha_app_id);
const [ready, setReady] = useState(false);
const [verified, setVerified] = useState(false);
const captchaRef = useRef<{ show: () => void; destroy?: () => void } | null>(null);
const onStateChangeRef = useRef(onStateChange);
useEffect(() => {
onStateChangeRef.current = onStateChange;
}, [onStateChange]);
useEffect(() => {
if (!appId) {
return;
}
let script = document.getElementById(TCAPTCHA_SCRIPT_ID) as HTMLScriptElement | null;
const onLoad = () => setReady(true);
if (!script) {
script = document.createElement("script");
script.id = TCAPTCHA_SCRIPT_ID;
script.src = TCAPTCHA_SCRIPT;
script.async = true;
script.onload = onLoad;
document.head.appendChild(script);
} else if (window.TencentCaptcha) {
setReady(true);
} else {
script.onload = onLoad;
}
}, [appId]);
// Regeneration invalidates a previous solve.
useEffect(() => {
if (generation > 0) {
setVerified(false);
captchaRef.current = null;
}
}, [generation]);
const openCaptcha = () => {
if (!window.TencentCaptcha || !appId) {
return;
}
captchaRef.current?.destroy?.();
const instance = new window.TencentCaptcha(
appId,
(res) => {
if (res.ret === 0) {
setVerified(true);
onStateChangeRef.current({ ticket: res.ticket, randstr: res.randstr });
}
},
{ needFeedBack: false },
);
captchaRef.current = instance;
instance.show();
};
if (!appId) {
return null;
}
return (
<Box sx={{ textAlign: "center", ...(fullWidth && { width: "100%" }) }} {...rest}>
<SecondaryButton
variant="outlined"
fullWidth={fullWidth}
onClick={openCaptcha}
disabled={!ready}
color={verified ? "success" : "primary"}
>
{verified ? t("captcha.verified") : t("captcha.verify")}
</SecondaryButton>
</Box>
);
};
export default TCaptcha;

@ -591,6 +591,10 @@ var DefaultSettings = map[string]string{
"captcha_CaptchaLen": "6", "captcha_CaptchaLen": "6",
"captcha_ReCaptchaKey": "defaultKey", "captcha_ReCaptchaKey": "defaultKey",
"captcha_ReCaptchaSecret": "defaultSecret", "captcha_ReCaptchaSecret": "defaultSecret",
"captcha_TCaptcha_CaptchaAppId": "",
"captcha_TCaptcha_AppSecretKey": "",
"captcha_TCaptcha_SecretId": "",
"captcha_TCaptcha_SecretKey": "",
"captcha_turnstile_site_key": "", "captcha_turnstile_site_key": "",
"captcha_turnstile_site_secret": "", "captcha_turnstile_site_secret": "",
"captcha_cap_instance_url": "", "captcha_cap_instance_url": "",

@ -15,6 +15,7 @@ import (
request2 "github.com/cloudreve/Cloudreve/v4/pkg/request" request2 "github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/tcaptcha"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha" "github.com/mojocn/base64Captcha"
) )
@ -71,13 +72,48 @@ func CaptchaRequired(enabled func(c *gin.Context) bool) gin.HandlerFunc {
c.Request.Body = io.NopCloser(bytes.NewReader(bodyData)) c.Request.Body = io.NopCloser(bytes.NewReader(bodyData))
switch settings.CaptchaType(c) { switch settings.CaptchaType(c) {
case setting.CaptchaNormal, setting.CaptchaTcaptcha: case setting.CaptchaNormal:
if service.Ticket == "" || !base64Captcha.VerifyCaptcha(service.Ticket, service.Captcha) { if service.Ticket == "" || !base64Captcha.VerifyCaptcha(service.Ticket, service.Captcha) {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err)) c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err))
c.Abort() c.Abort()
return return
} }
break
case setting.CaptchaTcaptcha:
captchaSetting := settings.TcCaptcha(c)
if captchaSetting.AppID == "" || captchaSetting.AppSecretKey == "" ||
captchaSetting.SecretID == "" || captchaSetting.SecretKey == "" {
l.Warning("TCaptcha verification failed: missing configuration")
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha configuration error", nil))
c.Abort()
return
}
if service.Ticket == "" || service.Randstr == "" {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, nil))
c.Abort()
return
}
r := dep.RequestClient(
request2.WithContext(c),
request2.WithLogger(logging.FromContext(c)),
)
ok, err := tcaptcha.Verify(c, r, captchaSetting, service.Ticket, service.Randstr, c.ClientIP())
if err != nil {
l.Warning("TCaptcha verification failed: %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
if !ok {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaRefresh, nil))
c.Abort()
return
}
break break
case setting.CaptchaReCaptcha: case setting.CaptchaReCaptcha:
captchaSetting := settings.ReCaptcha(c) captchaSetting := settings.ReCaptcha(c)

@ -0,0 +1,138 @@
// Package tcaptcha verifies Tencent Cloud Captcha tickets via the
// DescribeCaptchaResult API (TencentCloud API v3, TC3-HMAC-SHA256 signing).
package tcaptcha
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
)
const (
endpoint = "captcha.tencentcloudapi.com"
service = "captcha"
version = "2019-07-22"
action = "DescribeCaptchaResult"
signAlgorithm = "TC3-HMAC-SHA256"
signedHeaders = "content-type;host;x-tc-action"
captchaTypeNew = 9 // interactive captcha (popup widget)
)
type describeResultResponse struct {
Response struct {
CaptchaCode int `json:"CaptchaCode"`
CaptchaMsg string `json:"CaptchaMsg"`
Error *struct {
Code string `json:"Code"`
Message string `json:"Message"`
} `json:"Error"`
} `json:"Response"`
}
// Verify checks a ticket produced by the Tencent captcha widget. CaptchaCode
// 1 means the ticket is valid.
func Verify(ctx context.Context, client request.Client, cfg *setting.TcCaptcha, ticket, randstr, userIP string) (bool, error) {
appID, err := strconv.ParseInt(strings.TrimSpace(cfg.AppID), 10, 64)
if err != nil {
return false, fmt.Errorf("invalid CaptchaAppId: %w", err)
}
payload, err := json.Marshal(map[string]any{
"CaptchaType": captchaTypeNew,
"Ticket": ticket,
"Randstr": randstr,
"UserIp": userIP,
"CaptchaAppId": appID,
"AppSecretKey": cfg.AppSecretKey,
})
if err != nil {
return false, err
}
timestamp := time.Now().Unix()
auth := sign(cfg.SecretID, cfg.SecretKey, string(payload), timestamp)
res, err := client.Request(
"POST",
"https://"+endpoint,
strings.NewReader(string(payload)),
request.WithContext(ctx),
request.WithHeader(http.Header{
"Content-Type": []string{"application/json; charset=utf-8"},
"Host": []string{endpoint},
"X-TC-Action": []string{action},
"X-TC-Version": []string{version},
"X-TC-Timestamp": []string{strconv.FormatInt(timestamp, 10)},
"Authorization": []string{auth},
}),
).CheckHTTPResponse(http.StatusOK).GetResponse()
if err != nil {
return false, err
}
var parsed describeResultResponse
if err := json.Unmarshal([]byte(res), &parsed); err != nil {
return false, fmt.Errorf("failed to parse captcha result: %w", err)
}
if parsed.Response.Error != nil {
return false, fmt.Errorf("captcha api error %s: %s", parsed.Response.Error.Code, parsed.Response.Error.Message)
}
return parsed.Response.CaptchaCode == 1, nil
}
// sign builds the TC3-HMAC-SHA256 Authorization header value.
func sign(secretID, secretKey, payload string, timestamp int64) string {
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
canonicalRequest := strings.Join([]string{
"POST",
"/",
"",
"content-type:application/json; charset=utf-8\n" +
"host:" + endpoint + "\n" +
"x-tc-action:" + strings.ToLower(action) + "\n",
signedHeaders,
sha256Hex(payload),
}, "\n")
credentialScope := date + "/" + service + "/tc3_request"
stringToSign := strings.Join([]string{
signAlgorithm,
strconv.FormatInt(timestamp, 10),
credentialScope,
sha256Hex(canonicalRequest),
}, "\n")
signingKey := hmacSHA256(
hmacSHA256(
hmacSHA256([]byte("TC3"+secretKey), date),
service,
),
"tc3_request",
)
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
return fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
signAlgorithm, secretID, credentialScope, signedHeaders, signature)
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func hmacSHA256(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}

@ -0,0 +1,29 @@
package tcaptcha
import (
"strings"
"testing"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require"
)
func TestSignProducesStableAuthorization(t *testing.T) {
auth := sign("AKIDTEST", "secret", `{"CaptchaType":9}`, 1700000000)
require.True(t, strings.HasPrefix(auth, "TC3-HMAC-SHA256 Credential=AKIDTEST/2023-11-14/captcha/tc3_request"))
require.Contains(t, auth, "SignedHeaders=content-type;host;x-tc-action")
require.Contains(t, auth, "Signature=")
// Signature is the last 64 hex chars and deterministic.
sig := auth[strings.LastIndex(auth, "Signature=")+len("Signature="):]
require.Len(t, sig, 64)
require.Equal(t, auth, sign("AKIDTEST", "secret", `{"CaptchaType":9}`, 1700000000))
require.NotEqual(t, auth, sign("AKIDTEST", "secret2", `{"CaptchaType":9}`, 1700000000))
}
func TestVerifyRejectsBadAppID(t *testing.T) {
ok, err := Verify(t.Context(), nil, &setting.TcCaptcha{AppID: "not-a-number"}, "t", "r", "1.2.3.4")
require.Error(t, err)
require.False(t, ok)
}

@ -40,6 +40,7 @@ type SiteConfig struct {
ForgetCaptcha bool `json:"forget_captcha,omitempty"` ForgetCaptcha bool `json:"forget_captcha,omitempty"`
Authn bool `json:"authn,omitempty"` Authn bool `json:"authn,omitempty"`
ReCaptchaKey string `json:"captcha_ReCaptchaKey,omitempty"` ReCaptchaKey string `json:"captcha_ReCaptchaKey,omitempty"`
TCaptchaAppID string `json:"tcaptcha_app_id,omitempty"`
CaptchaType setting.CaptchaType `json:"captcha_type,omitempty"` CaptchaType setting.CaptchaType `json:"captcha_type,omitempty"`
TurnstileSiteID string `json:"turnstile_site_id,omitempty"` TurnstileSiteID string `json:"turnstile_site_id,omitempty"`
CapInstanceURL string `json:"captcha_cap_instance_url,omitempty"` CapInstanceURL string `json:"captcha_cap_instance_url,omitempty"`
@ -254,6 +255,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
Logo: logo.Normal, Logo: logo.Normal,
LogoLight: logo.Light, LogoLight: logo.Light,
CaptchaType: settings.CaptchaType(c), CaptchaType: settings.CaptchaType(c),
TCaptchaAppID: settings.TcCaptcha(c).AppID,
TurnstileSiteID: settings.TurnstileCaptcha(c).Key, TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
ReCaptchaKey: reCaptcha.Key, ReCaptchaKey: reCaptcha.Key,
CapInstanceURL: capCaptcha.InstanceURL, CapInstanceURL: capCaptcha.InstanceURL,

Loading…
Cancel
Save