diff --git a/ROADMAP.md b/ROADMAP.md index 949266f2..8ab3b4c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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] 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] 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 diff --git a/frontend/public/locales/en-US/common.json b/frontend/public/locales/en-US/common.json index b72bb7de..3df7a55d 100644 --- a/frontend/public/locales/en-US/common.json +++ b/frontend/public/locales/en-US/common.json @@ -34,7 +34,9 @@ "human": "I'm a human", "verifying": "Verifying...", "verified": "You're a human" - } + }, + "verify": "Click to verify", + "verified": "Verified" }, "errors": { "401": "Please login.", diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index cd614ecd..852d03a8 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -543,6 +543,13 @@ "plainCaptcha": "Plain graphic", "reCaptchaV2": "reCAPTCHA V2", "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.", + "tcaptchaSecretDes": "SecretId and SecretKey of a Tencent Cloud API credential used to call DescribeCaptchaResult.", "turnstileSiteKey": "Site Key", "turnstileSiteKSecret": "Secret", "cap": "Cap", diff --git a/frontend/public/locales/zh-CN/common.json b/frontend/public/locales/zh-CN/common.json index 3166c66b..7e1da725 100644 --- a/frontend/public/locales/zh-CN/common.json +++ b/frontend/public/locales/zh-CN/common.json @@ -34,7 +34,9 @@ "human": "我是人类", "verifying": "验证中…", "verified": "您通过了验证" - } + }, + "verify": "点击验证", + "verified": "已验证" }, "errors": { "401": "请先登录", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index a94e1cd3..5ce2b8e0 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -543,6 +543,13 @@ "plainCaptcha": "图形", "reCaptchaV2": "reCAPTCHA V2", "turnstile": "Cloudflare Turnstile", + "tcaptcha": "腾讯验证码", + "tcaptchaAppId": "CaptchaAppId", + "tcaptchaAppSecretKey": "AppSecretKey", + "tcaptchaSecretId": "SecretId", + "tcaptchaSecretKey": "SecretKey", + "tcaptchaDes": "在 <0>腾讯验证码控制台 获取 CaptchaAppId 和 AppSecretKey。", + "tcaptchaSecretDes": "用于调用 DescribeCaptchaResult 接口的腾讯云 API 密钥(SecretId / SecretKey)。", "turnstileSiteKey": "站点密钥", "turnstileSiteKSecret": "密钥", "cap": "Cap", diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index fdc2238c..697ea048 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -4,7 +4,6 @@ import { User } from "./user.ts"; export enum CaptchaType { NORMAL = "normal", RECAPTCHA = "recaptcha", - // Deprecated TCAPTCHA = "tcaptcha", TURNSTILE = "turnstile", CAP = "cap", @@ -22,6 +21,7 @@ export interface SiteConfig { user?: User; captcha_ReCaptchaKey?: string; captcha_type?: CaptchaType; + tcaptcha_app_id?: string; turnstile_site_id?: string; captcha_cap_instance_url?: string; captcha_cap_site_key?: string; diff --git a/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx b/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx index e2771f3e..63a67aac 100644 --- a/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx +++ b/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx @@ -11,6 +11,7 @@ import { SettingContext } from "../SettingWrapper.tsx"; import CapCaptcha from "./CapCaptcha.tsx"; import GraphicCaptcha from "./GraphicCaptcha.tsx"; import ReCaptcha from "./ReCaptcha.tsx"; +import TCaptcha from "./TCaptcha.tsx"; import TurnstileCaptcha from "./TurnstileCaptcha.tsx"; const Captcha = () => { @@ -132,6 +133,15 @@ const Captcha = () => { {t("settings.reCaptchaV2")} + + + {t("settings.tcaptcha")} + + { + + + diff --git a/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx b/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx new file mode 100644 index 00000000..9c53b28e --- /dev/null +++ b/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx @@ -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 ( + + + + setSettings({ captcha_TCaptcha_CaptchaAppId: e.target.value })} + required + /> + + ]} + /> + + + + + + setSettings({ captcha_TCaptcha_AppSecretKey: e.target.value })} + required + /> + + + + + setSettings({ captcha_TCaptcha_SecretId: e.target.value })} + required + /> + {t("settings.tcaptchaSecretDes")} + + + + + setSettings({ captcha_TCaptcha_SecretKey: e.target.value })} + required + /> + + + + ); +}; + +export default TCaptcha; diff --git a/frontend/src/component/Admin/Settings/Settings.tsx b/frontend/src/component/Admin/Settings/Settings.tsx index 7cd858f5..f7c46a27 100644 --- a/frontend/src/component/Admin/Settings/Settings.tsx +++ b/frontend/src/component/Admin/Settings/Settings.tsx @@ -234,6 +234,10 @@ const Settings = () => { "captcha_CaptchaLen", "captcha_ReCaptchaKey", "captcha_ReCaptchaSecret", + "captcha_TCaptcha_CaptchaAppId", + "captcha_TCaptcha_AppSecretKey", + "captcha_TCaptcha_SecretId", + "captcha_TCaptcha_SecretKey", "captcha_turnstile_site_key", "captcha_turnstile_site_secret", "captcha_cap_instance_url", diff --git a/frontend/src/component/Common/Captcha/Captcha.tsx b/frontend/src/component/Common/Captcha/Captcha.tsx index 49325653..2c7b1ff6 100644 --- a/frontend/src/component/Common/Captcha/Captcha.tsx +++ b/frontend/src/component/Common/Captcha/Captcha.tsx @@ -3,6 +3,7 @@ import { useAppSelector } from "../../../redux/hooks.ts"; import CapCaptcha from "./CapCaptcha.tsx"; import DefaultCaptcha from "./DefaultCaptcha.tsx"; import ReCaptchaV2 from "./ReCaptchaV2.tsx"; +import TCaptcha from "./TCaptcha.tsx"; import TurnstileCaptcha from "./TurnstileCaptcha.tsx"; export interface CaptchaProps { @@ -19,18 +20,15 @@ export interface CaptchaParams { export const Captcha = (props: CaptchaProps) => { const captchaType = useAppSelector((state) => state.siteConfig.basic.config.captcha_type); - // const recaptcha = useRecaptcha(setCaptchaLoading); - // const tcaptcha = useTCaptcha(setCaptchaLoading); - switch (captchaType) { case CaptchaType.RECAPTCHA: return ; + case CaptchaType.TCAPTCHA: + return ; case CaptchaType.TURNSTILE: return ; case CaptchaType.CAP: return ; - // case "tcaptcha": - // return { ...tcaptcha, captchaRefreshRef, captchaLoading }; default: return ; } diff --git a/frontend/src/component/Common/Captcha/TCaptcha.tsx b/frontend/src/component/Common/Captcha/TCaptcha.tsx new file mode 100644 index 00000000..c137fc72 --- /dev/null +++ b/frontend/src/component/Common/Captcha/TCaptcha.tsx @@ -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, + ) => { 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 ( + + + {verified ? t("captcha.verified") : t("captcha.verify")} + + + ); +}; + +export default TCaptcha; diff --git a/inventory/setting.go b/inventory/setting.go index 18e1f004..53bd11fa 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -591,6 +591,10 @@ var DefaultSettings = map[string]string{ "captcha_CaptchaLen": "6", "captcha_ReCaptchaKey": "defaultKey", "captcha_ReCaptchaSecret": "defaultSecret", + "captcha_TCaptcha_CaptchaAppId": "", + "captcha_TCaptcha_AppSecretKey": "", + "captcha_TCaptcha_SecretId": "", + "captcha_TCaptcha_SecretKey": "", "captcha_turnstile_site_key": "", "captcha_turnstile_site_secret": "", "captcha_cap_instance_url": "", diff --git a/middleware/captcha.go b/middleware/captcha.go index 9169d5f7..82ce0969 100644 --- a/middleware/captcha.go +++ b/middleware/captcha.go @@ -15,6 +15,7 @@ import ( request2 "github.com/cloudreve/Cloudreve/v4/pkg/request" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/tcaptcha" "github.com/gin-gonic/gin" "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)) switch settings.CaptchaType(c) { - case setting.CaptchaNormal, setting.CaptchaTcaptcha: + case setting.CaptchaNormal: if service.Ticket == "" || !base64Captcha.VerifyCaptcha(service.Ticket, service.Captcha) { c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err)) c.Abort() 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 case setting.CaptchaReCaptcha: captchaSetting := settings.ReCaptcha(c) diff --git a/pkg/tcaptcha/client.go b/pkg/tcaptcha/client.go new file mode 100644 index 00000000..94ddeb0b --- /dev/null +++ b/pkg/tcaptcha/client.go @@ -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) +} diff --git a/pkg/tcaptcha/client_test.go b/pkg/tcaptcha/client_test.go new file mode 100644 index 00000000..57dcd672 --- /dev/null +++ b/pkg/tcaptcha/client_test.go @@ -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) +} diff --git a/service/basic/site.go b/service/basic/site.go index 1415eb86..f8d9b267 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -40,6 +40,7 @@ type SiteConfig struct { ForgetCaptcha bool `json:"forget_captcha,omitempty"` Authn bool `json:"authn,omitempty"` ReCaptchaKey string `json:"captcha_ReCaptchaKey,omitempty"` + TCaptchaAppID string `json:"tcaptcha_app_id,omitempty"` CaptchaType setting.CaptchaType `json:"captcha_type,omitempty"` TurnstileSiteID string `json:"turnstile_site_id,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, LogoLight: logo.Light, CaptchaType: settings.CaptchaType(c), + TCaptchaAppID: settings.TcCaptcha(c).AppID, TurnstileSiteID: settings.TurnstileCaptcha(c).Key, ReCaptchaKey: reCaptcha.Key, CapInstanceURL: capCaptcha.InstanceURL,