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
parent
c58d3d1ad8
commit
c9b9414505
@ -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;
|
||||||
@ -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;
|
||||||
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue