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