feat: support configurable captcha(reCaptcha & hCaptcha) (#765)

* feat: support configurable captcha(layered architecture)

* refactor & add captcha logo

* rename captcha

* Update authz.go

* Update hcaptcha.go

* Update default.go

* Update recaptcha.go

Co-authored-by: Gucheng <85475922+nomeguy@users.noreply.github.com>
This commit is contained in:
Resulte Lee
2022-06-18 16:00:31 +08:00
committed by GitHub
parent ae4ab9902b
commit 2e42511bc4
25 changed files with 687 additions and 72 deletions

View File

@ -95,7 +95,8 @@ p, *, *, GET, /api/get-providers, *, *
p, *, *, POST, /api/unlink, *, * p, *, *, POST, /api/unlink, *, *
p, *, *, POST, /api/set-password, *, * p, *, *, POST, /api/set-password, *, *
p, *, *, POST, /api/send-verification-code, *, * p, *, *, POST, /api/send-verification-code, *, *
p, *, *, GET, /api/get-human-check, *, * p, *, *, GET, /api/get-captcha, *, *
p, *, *, POST, /api/verify-captcha, *, *
p, *, *, POST, /api/reset-email-or-phone, *, * p, *, *, POST, /api/reset-email-or-phone, *, *
p, *, *, POST, /api/upload-resource, *, * p, *, *, POST, /api/upload-resource, *, *
p, *, *, GET, /.well-known/openid-configuration, *, * p, *, *, GET, /.well-known/openid-configuration, *, *

29
captcha/default.go Normal file
View File

@ -0,0 +1,29 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package captcha
import "github.com/casdoor/casdoor/object"
type DefaultCaptchaProvider struct {
}
func NewDefaultCaptchaProvider() *DefaultCaptchaProvider {
captcha := &DefaultCaptchaProvider{}
return captcha
}
func (captcha *DefaultCaptchaProvider) VerifyCaptcha(token, clientSecret string) (bool, error) {
return object.VerifyCaptcha(clientSecret, token), nil
}

60
captcha/hcaptcha.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package captcha
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
)
const HCaptchaVerifyUrl = "https://hcaptcha.com/siteverify"
type HCaptchaProvider struct {
}
func NewHCaptchaProvider() *HCaptchaProvider {
captcha := &HCaptchaProvider{}
return captcha
}
func (captcha *HCaptchaProvider) VerifyCaptcha(token, clientSecret string) (bool, error) {
reqData := url.Values{
"secret": {clientSecret},
"response": {token},
}
resp, err := http.PostForm(HCaptchaVerifyUrl, reqData)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return false, err
}
type captchaResponse struct {
Success bool `json:"success"`
}
captchaResp := &captchaResponse{}
err = json.Unmarshal(body, captchaResp)
if err != nil {
return false, err
}
return captchaResp.Success, nil
}

30
captcha/provider.go Normal file
View File

@ -0,0 +1,30 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package captcha
type CaptchaProvider interface {
VerifyCaptcha(token, clientSecret string) (bool, error)
}
func GetCaptchaProvider(captchaType string) CaptchaProvider {
if captchaType == "Default" {
return NewDefaultCaptchaProvider()
} else if captchaType == "reCAPTCHA" {
return NewReCaptchaProvider()
} else if captchaType == "hCaptcha" {
return NewHCaptchaProvider()
}
return nil
}

60
captcha/recaptcha.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package captcha
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
)
const ReCaptchaVerifyUrl = "https://recaptcha.net/recaptcha/api/siteverify"
type ReCaptchaProvider struct {
}
func NewReCaptchaProvider() *ReCaptchaProvider {
captcha := &ReCaptchaProvider{}
return captcha
}
func (captcha *ReCaptchaProvider) VerifyCaptcha(token, clientSecret string) (bool, error) {
reqData := url.Values{
"secret": {clientSecret},
"response": {token},
}
resp, err := http.PostForm(ReCaptchaVerifyUrl, reqData)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return false, err
}
type captchaResponse struct {
Success bool `json:"success"`
}
captchaResp := &captchaResponse{}
err = json.Unmarshal(body, captchaResp)
if err != nil {
return false, err
}
return captchaResp.Success, nil
}

View File

@ -75,12 +75,14 @@ type Response struct {
Data2 interface{} `json:"data2"` Data2 interface{} `json:"data2"`
} }
type HumanCheck struct { type Captcha struct {
Type string `json:"type"` Type string `json:"type"`
AppKey string `json:"appKey"` AppKey string `json:"appKey"`
Scene string `json:"scene"` Scene string `json:"scene"`
CaptchaId string `json:"captchaId"` CaptchaId string `json:"captchaId"`
CaptchaImage interface{} `json:"captchaImage"` CaptchaImage []byte `json:"captchaImage"`
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
} }
// Signup // Signup
@ -291,20 +293,27 @@ func (c *ApiController) GetUserinfo() {
c.ServeJSON() c.ServeJSON()
} }
// GetHumanCheck ... // GetCaptcha ...
// @Tag Login API // @Tag Login API
// @Title GetHumancheck // @Title GetCaptcha
// @router /api/get-human-check [get] // @router /api/get-captcha [get]
func (c *ApiController) GetHumanCheck() { func (c *ApiController) GetCaptcha() {
c.Data["json"] = HumanCheck{Type: "none"} applicationId := c.Input().Get("applicationId")
isCurrentProvider := c.Input().Get("isCurrentProvider")
provider := object.GetDefaultHumanCheckProvider() captchaProvider, err := object.GetCaptchaProviderByApplication(applicationId, isCurrentProvider)
if provider == nil { if err != nil {
id, img := object.GetCaptcha() c.ResponseError(err.Error())
c.Data["json"] = HumanCheck{Type: "captcha", CaptchaId: id, CaptchaImage: img}
c.ServeJSON()
return return
} }
c.ServeJSON() if captchaProvider.Type == "Default" {
id, img := object.GetCaptcha()
c.ResponseOk(Captcha{Type: captchaProvider.Type, CaptchaId: id, CaptchaImage: img})
return
} else if captchaProvider.Type != "" {
c.ResponseOk(Captcha{Type: captchaProvider.Type, ClientId: captchaProvider.ClientId, ClientSecret: captchaProvider.ClientSecret})
return
}
c.ResponseOk(Captcha{Type: "none"})
} }

View File

@ -19,6 +19,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/casdoor/casdoor/captcha"
"github.com/casdoor/casdoor/object" "github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util" "github.com/casdoor/casdoor/util"
) )
@ -48,15 +49,25 @@ func (c *ApiController) SendVerificationCode() {
checkUser := c.Ctx.Request.Form.Get("checkUser") checkUser := c.Ctx.Request.Form.Get("checkUser")
remoteAddr := util.GetIPFromRequest(c.Ctx.Request) remoteAddr := util.GetIPFromRequest(c.Ctx.Request)
if len(destType) == 0 || len(dest) == 0 || len(orgId) == 0 || !strings.Contains(orgId, "/") || len(checkType) == 0 || len(checkId) == 0 || len(checkKey) == 0 { if len(destType) == 0 || len(dest) == 0 || len(orgId) == 0 || !strings.Contains(orgId, "/") || len(checkType) == 0 {
c.ResponseError("Missing parameter.") c.ResponseError("Missing parameter.")
return return
} }
isHuman := false provider := captcha.GetCaptchaProvider(checkType)
captchaProvider := object.GetDefaultHumanCheckProvider() if provider == nil {
if captchaProvider == nil { c.ResponseError("Invalid captcha provider.")
isHuman = object.VerifyCaptcha(checkId, checkKey) return
}
if checkKey == "" {
c.ResponseError("Missing parameter: checkKey.")
return
}
isHuman, err := provider.VerifyCaptcha(checkKey, checkId)
if err != nil {
c.ResponseError("Failed to verify captcha: %v", err)
return
} }
if !isHuman { if !isHuman {
@ -173,3 +184,36 @@ func (c *ApiController) ResetEmailOrPhone() {
c.Data["json"] = Response{Status: "ok"} c.Data["json"] = Response{Status: "ok"}
c.ServeJSON() c.ServeJSON()
} }
// VerifyCaptcha ...
// @Title VerifyCaptcha
// @Tag Verification API
// @router /verify-captcha [post]
func (c *ApiController) VerifyCaptcha() {
captchaType := c.Ctx.Request.Form.Get("captchaType")
captchaToken := c.Ctx.Request.Form.Get("captchaToken")
clientSecret := c.Ctx.Request.Form.Get("clientSecret")
if captchaToken == "" {
c.ResponseError("Missing parameter: captchaToken.")
return
}
if clientSecret == "" {
c.ResponseError("Missing parameter: clientSecret.")
return
}
provider := captcha.GetCaptchaProvider(captchaType)
if provider == nil {
c.ResponseError("Invalid captcha provider.")
return
}
isValid, err := provider.VerifyCaptcha(captchaToken, clientSecret)
if err != nil {
c.ResponseError("Failed to verify captcha: %v", err)
return
}
c.ResponseOk(isValid)
}

View File

@ -142,8 +142,8 @@ func GetProvider(id string) *Provider {
return getProvider(owner, name) return getProvider(owner, name)
} }
func GetDefaultHumanCheckProvider() *Provider { func GetDefaultCaptchaProvider() *Provider {
provider := Provider{Owner: "admin", Category: "HumanCheck"} provider := Provider{Owner: "admin", Category: "Captcha"}
existed, err := adapter.Engine.Get(&provider) existed, err := adapter.Engine.Get(&provider)
if err != nil { if err != nil {
panic(err) panic(err)
@ -225,3 +225,37 @@ func (p *Provider) getPaymentProvider() (pp.PaymentProvider, *Cert, error) {
func (p *Provider) GetId() string { func (p *Provider) GetId() string {
return fmt.Sprintf("%s/%s", p.Owner, p.Name) return fmt.Sprintf("%s/%s", p.Owner, p.Name)
} }
func GetCaptchaProviderByOwnerName(applicationId string) (*Provider, error) {
owner, name := util.GetOwnerAndNameFromId(applicationId)
provider := Provider{Owner: owner, Name: name, Category: "Captcha"}
existed, err := adapter.Engine.Get(&provider)
if err != nil {
return nil, err
}
if !existed {
return nil, fmt.Errorf("the provider: %s does not exist", applicationId)
}
return &provider, nil
}
func GetCaptchaProviderByApplication(applicationId, isCurrentProvider string) (*Provider, error) {
if isCurrentProvider == "true" {
return GetCaptchaProviderByOwnerName(applicationId)
}
application := GetApplication(applicationId)
if application == nil || len(application.Providers) == 0 {
return nil, fmt.Errorf("invalid application id")
}
for _, provider := range application.Providers {
if provider.Provider == nil {
continue
}
if provider.Provider.Category == "Captcha" {
return GetCaptchaProviderByOwnerName(fmt.Sprintf("%s/%s", provider.Provider.Owner, provider.Provider.Name))
}
}
return nil, fmt.Errorf("no captcha provider found")
}

View File

@ -94,8 +94,9 @@ func initAPI() {
beego.Router("/api/check-user-password", &controllers.ApiController{}, "POST:CheckUserPassword") beego.Router("/api/check-user-password", &controllers.ApiController{}, "POST:CheckUserPassword")
beego.Router("/api/get-email-and-phone", &controllers.ApiController{}, "POST:GetEmailAndPhone") beego.Router("/api/get-email-and-phone", &controllers.ApiController{}, "POST:GetEmailAndPhone")
beego.Router("/api/send-verification-code", &controllers.ApiController{}, "POST:SendVerificationCode") beego.Router("/api/send-verification-code", &controllers.ApiController{}, "POST:SendVerificationCode")
beego.Router("/api/verify-captcha", &controllers.ApiController{}, "POST:VerifyCaptcha")
beego.Router("/api/reset-email-or-phone", &controllers.ApiController{}, "POST:ResetEmailOrPhone") beego.Router("/api/reset-email-or-phone", &controllers.ApiController{}, "POST:ResetEmailOrPhone")
beego.Router("/api/get-human-check", &controllers.ApiController{}, "GET:GetHumanCheck") beego.Router("/api/get-captcha", &controllers.ApiController{}, "GET:GetCaptcha")
beego.Router("/api/get-ldap-user", &controllers.ApiController{}, "POST:GetLdapUser") beego.Router("/api/get-ldap-user", &controllers.ApiController{}, "POST:GetLdapUser")
beego.Router("/api/get-ldaps", &controllers.ApiController{}, "POST:GetLdaps") beego.Router("/api/get-ldaps", &controllers.ApiController{}, "POST:GetLdaps")

View File

@ -378,12 +378,12 @@
} }
} }
}, },
"/api/api/get-human-check": { "/api/api/get-captcha": {
"get": { "get": {
"tags": [ "tags": [
"Login API" "Login API"
], ],
"operationId": "ApiController.GetHumancheck" "operationId": "ApiController.GetCaptcha"
} }
}, },
"/api/api/reset-email-or-phone": { "/api/api/reset-email-or-phone": {

View File

@ -243,11 +243,11 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/api/get-human-check: /api/api/get-captcha:
get: get:
tags: tags:
- Login API - Login API
operationId: ApiController.GetHumancheck operationId: ApiController.GetCaptcha
/api/api/reset-email-or-phone: /api/api/reset-email-or-phone:
post: post:
tags: tags:

View File

@ -20,6 +20,7 @@ import * as Setting from "./Setting";
import i18next from "i18next"; import i18next from "i18next";
import { authConfig } from "./auth/Auth"; import { authConfig } from "./auth/Auth";
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { CaptchaPreview } from "./common/CaptchaPreview";
const { Option } = Select; const { Option } = Select;
const { TextArea } = Input; const { TextArea } = Input;
@ -77,6 +78,8 @@ class ProviderEditPage extends React.Component {
} else { } else {
return Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip")); return Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"));
} }
case "Captcha":
return Setting.getLabel(i18next.t("provider:Site key"), i18next.t("provider:Site key - Tooltip"));
default: default:
return Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip")); return Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"));
} }
@ -94,6 +97,8 @@ class ProviderEditPage extends React.Component {
} else { } else {
return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip")); return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"));
} }
case "Captcha":
return Setting.getLabel(i18next.t("provider:Secret key"), i18next.t("provider:Secret key - Tooltip"));
default: default:
return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip")); return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"));
} }
@ -193,6 +198,8 @@ class ProviderEditPage extends React.Component {
this.updateProviderField('domain', Setting.getFullServerUrl()); this.updateProviderField('domain', Setting.getFullServerUrl());
} else if (value === "SAML") { } else if (value === "SAML") {
this.updateProviderField('type', 'Aliyun IDaaS'); this.updateProviderField('type', 'Aliyun IDaaS');
} else if (value === "Captcha") {
this.updateProviderField('type', 'Default');
} }
})}> })}>
{ {
@ -203,6 +210,7 @@ class ProviderEditPage extends React.Component {
{id: 'Storage', name: 'Storage'}, {id: 'Storage', name: 'Storage'},
{id: 'SAML', name: 'SAML'}, {id: 'SAML', name: 'SAML'},
{id: 'Payment', name: 'Payment'}, {id: 'Payment', name: 'Payment'},
{id: 'Captcha', name: 'Captcha'},
].map((providerCategory, index) => <Option key={index} value={providerCategory.id}>{providerCategory.name}</Option>) ].map((providerCategory, index) => <Option key={index} value={providerCategory.id}>{providerCategory.name}</Option>)
} }
</Select> </Select>
@ -341,26 +349,31 @@ class ProviderEditPage extends React.Component {
</React.Fragment> </React.Fragment>
) )
} }
<Row style={{marginTop: '20px'}} > {
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}> this.state.provider.type !== "Default" &&
{this.getClientIdLabel()} <>
</Col> <Row style={{marginTop: '20px'}} >
<Col span={22} > <Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
<Input value={this.state.provider.clientId} onChange={e => { {this.getClientIdLabel()}
this.updateProviderField('clientId', e.target.value); </Col>
}} /> <Col span={22} >
</Col> <Input value={this.state.provider.clientId} onChange={e => {
</Row> this.updateProviderField('clientId', e.target.value);
<Row style={{marginTop: '20px'}} > }} />
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}> </Col>
{this.getClientSecretLabel()} </Row>
</Col> <Row style={{marginTop: '20px'}} >
<Col span={22} > <Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
<Input value={this.state.provider.clientSecret} onChange={e => { {this.getClientSecretLabel()}
this.updateProviderField('clientSecret', e.target.value); </Col>
}} /> <Col span={22} >
</Col> <Input value={this.state.provider.clientSecret} onChange={e => {
</Row> this.updateProviderField('clientSecret', e.target.value);
}} />
</Col>
</Row>
</>
}
{ {
this.state.provider.type !== "WeChat" ? null : ( this.state.provider.type !== "WeChat" ? null : (
<React.Fragment> <React.Fragment>
@ -627,16 +640,39 @@ class ProviderEditPage extends React.Component {
) : null ) : null
} }
{this.getAppIdRow()} {this.getAppIdRow()}
<Row style={{marginTop: '20px'}} > {
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}> this.state.provider.type !== "Default" &&
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} : <Row style={{marginTop: '20px'}} >
</Col> <Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
<Col span={22} > {Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :
<Input prefix={<LinkOutlined/>} value={this.state.provider.providerUrl} onChange={e => { </Col>
this.updateProviderField('providerUrl', e.target.value); <Col span={22} >
}} /> <Input prefix={<LinkOutlined/>} value={this.state.provider.providerUrl} onChange={e => {
</Col> this.updateProviderField('providerUrl', e.target.value);
</Row> }} />
</Col>
</Row>
}
{
this.state.provider.category === "Captcha" &&
<Row style={{marginTop: '20px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Preview"), i18next.t("general:Preview - Tooltip"))} :
</Col>
<Col span={22} >
<CaptchaPreview
provider={this.state.provider}
providerName={this.state.providerName}
clientSecret={this.state.provider.clientSecret}
captchaType={this.state.provider.type}
owner={this.state.provider.owner}
clientId={this.state.provider.clientId}
name={this.state.provider.name}
providerUrl={this.state.provider.providerUrl}
/>
</Col>
</Row>
}
</Card> </Card>
) )
} }

View File

@ -134,6 +134,7 @@ class ProviderListPage extends BaseListPage {
{text: 'SMS', value: 'SMS', children: Setting.getProviderTypeOptions('SMS').map((o) => {return {text:o.id, value:o.name}})}, {text: 'SMS', value: 'SMS', children: Setting.getProviderTypeOptions('SMS').map((o) => {return {text:o.id, value:o.name}})},
{text: 'Storage', value: 'Storage', children: Setting.getProviderTypeOptions('Storage').map((o) => {return {text:o.id, value:o.name}})}, {text: 'Storage', value: 'Storage', children: Setting.getProviderTypeOptions('Storage').map((o) => {return {text:o.id, value:o.name}})},
{text: 'SAML', value: 'SAML', children: Setting.getProviderTypeOptions('SAML').map((o) => {return {text:o.id, value:o.name}})}, {text: 'SAML', value: 'SAML', children: Setting.getProviderTypeOptions('SAML').map((o) => {return {text:o.id, value:o.name}})},
{text: 'Captcha', value: 'Captcha', children: Setting.getProviderTypeOptions('Captcha').map((o) => {return {text:o.id, value:o.name}})},
], ],
sorter: true, sorter: true,
render: (text, record, index) => { render: (text, record, index) => {

View File

@ -106,6 +106,20 @@ export const OtherProviderInfo = {
url: "https://gc.org" url: "https://gc.org"
}, },
}, },
Captcha: {
"Default": {
logo: `${StaticBaseUrl}/img/social_default.png`,
url: "https://pkg.go.dev/github.com/dchest/captcha",
},
"reCAPTCHA": {
logo: `${StaticBaseUrl}/img/social_recaptcha.png`,
url: "https://www.google.com/recaptcha",
},
"hCaptcha": {
logo: `${StaticBaseUrl}/img/social_hcaptcha.png`,
url: "https://www.hcaptcha.com",
}
}
}; };
export function getCountryRegionData() { export function getCountryRegionData() {
@ -595,6 +609,12 @@ export function getProviderTypeOptions(category) {
{id: 'PayPal', name: 'PayPal'}, {id: 'PayPal', name: 'PayPal'},
{id: 'GC', name: 'GC'}, {id: 'GC', name: 'GC'},
]); ]);
} else if (category === "Captcha") {
return ([
{id: 'Default', name: 'Default'},
{id: 'reCAPTCHA', name: 'reCAPTCHA'},
{id: 'hCaptcha', name: 'hCaptcha'},
]);
} else { } else {
return []; return [];
} }

View File

@ -112,6 +112,30 @@ export function sendCode(checkType, checkId, checkKey, dest, type, orgId, checkU
}); });
} }
export function verifyCaptcha(captchaType, captchaToken, clientSecret) {
let formData = new FormData();
formData.append("captchaType", captchaType);
formData.append("captchaToken", captchaToken);
formData.append("clientSecret", clientSecret);
return fetch(`${Setting.ServerUrl}/api/verify-captcha`, {
method: "POST",
credentials: "include",
body: formData
}).then(res => res.json()).then(res => {
if (res.status === "ok") {
if (res.data) {
Setting.showMessage("success", i18next.t("user:Captcha Verify Success"));
} else {
Setting.showMessage("error", i18next.t("user:Captcha Verify Failed"));
}
return true;
} else {
Setting.showMessage("error", i18next.t("user:" + res.msg));
return false;
}
});
}
export function resetEmailOrPhone(dest, type, code) { export function resetEmailOrPhone(dest, type, code) {
let formData = new FormData(); let formData = new FormData();
formData.append("dest", dest); formData.append("dest", dest);
@ -124,8 +148,8 @@ export function resetEmailOrPhone(dest, type, code) {
}).then(res => res.json()); }).then(res => res.json());
} }
export function getHumanCheck() { export function getCaptcha(owner, name, isCurrentProvider) {
return fetch(`${Setting.ServerUrl}/api/get-human-check`, { return fetch(`${Setting.ServerUrl}/api/get-captcha?applicationId=${owner}/${encodeURIComponent(name)}&isCurrentProvider=${isCurrentProvider}`, {
method: "GET" method: "GET"
}).then(res => res.json()); }).then(res => res.json()).then(res => res.data);
} }

View File

@ -0,0 +1,139 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Button, Col, Input, Modal, Row } from "antd";
import React from "react";
import i18next from "i18next";
import * as UserBackend from "../backend/UserBackend";
import * as ProviderBackend from "../backend/ProviderBackend";
import { SafetyOutlined } from "@ant-design/icons";
import { CaptchaWidget } from "./CaptchaWidget";
export const CaptchaPreview = ({ provider, providerName, clientSecret, captchaType, owner, clientId, name, providerUrl }) => {
const [visible, setVisible] = React.useState(false);
const [captchaImg, setCaptchaImg] = React.useState("");
const [captchaToken, setCaptchaToken] = React.useState("");
const [secret, setSecret] = React.useState(clientSecret);
const handleOk = () => {
UserBackend.verifyCaptcha(
captchaType,
captchaToken,
secret
).then(() => {
setCaptchaToken("");
setVisible(false);
});
};
const handleCancel = () => {
setVisible(false);
};
const getCaptchaFromBackend = () => {
UserBackend.getCaptcha(owner, name, true).then((res) => {
if (captchaType === "Default") {
setSecret(res.captchaId);
setCaptchaImg(res.captchaImage);
} else {
setSecret(res.clientSecret);
}
});
}
const clickPreview = () => {
setVisible(true);
provider.name = name;
provider.clientId = clientId;
provider.type = captchaType;
provider.providerUrl = providerUrl;
if (clientSecret !== "***") {
provider.clientSecret = clientSecret;
ProviderBackend.updateProvider(owner, providerName, provider).then(() => {
getCaptchaFromBackend();
});
} else {
getCaptchaFromBackend();
}
};
const renderDefaultCaptcha = () => {
return (
<Col>
<Row
style={{
backgroundImage: `url('data:image/png;base64,${captchaImg}')`,
backgroundRepeat: "no-repeat",
height: "80px",
width: "200px",
borderRadius: "3px",
border: "1px solid #ccc",
marginBottom: 10,
}}
/>
<Row>
<Input
autoFocus
value={captchaToken}
prefix={<SafetyOutlined />}
placeholder={i18next.t("general:Captcha")}
onPressEnter={handleOk}
onChange={(e) => setCaptchaToken(e.target.value)}
/>
</Row>
</Col>
);
};
const onSubmit = (token) => {
setCaptchaToken(token);
};
const renderCheck = () => {
if (captchaType === "Default") {
return renderDefaultCaptcha();
} else {
return (
<CaptchaWidget
captchaType={captchaType}
siteKey={clientId}
onChange={onSubmit}
/>
);
}
};
return (
<React.Fragment>
<Button style={{ fontSize: 14 }} type={"primary"} onClick={clickPreview}>
{i18next.t("general:Preview")}
</Button>
<Modal
closable={false}
maskClosable={false}
destroyOnClose={true}
title={i18next.t("general:Captcha")}
visible={visible}
okText={i18next.t("user:OK")}
cancelText={i18next.t("user:Cancel")}
onOk={handleOk}
onCancel={handleCancel}
width={348}
>
{renderCheck()}
</Modal>
</React.Fragment>
);
};

View File

@ -0,0 +1,62 @@
// Copyright 2022 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React, { useEffect } from "react";
export const CaptchaWidget = ({ captchaType, siteKey, onChange }) => {
const loadScript = (src) => {
var tag = document.createElement("script");
tag.async = false;
tag.src = src;
var body = document.getElementsByTagName("body")[0];
body.appendChild(tag);
};
useEffect(() => {
switch (captchaType) {
case "reCAPTCHA":
const reTimer = setInterval(() => {
if (!window.grecaptcha) {
loadScript("https://recaptcha.net/recaptcha/api.js");
}
if (window.grecaptcha) {
window.grecaptcha.render("captcha", {
sitekey: siteKey,
callback: onChange,
});
clearInterval(reTimer);
}
}, 300);
break;
case "hCaptcha":
const hTimer = setInterval(() => {
if (!window.hcaptcha) {
loadScript("https://js.hcaptcha.com/1/api.js");
}
if (window.hcaptcha) {
window.hcaptcha.render("captcha", {
sitekey: siteKey,
callback: onChange,
});
clearInterval(hTimer);
}
}, 300);
break;
default:
break;
}
}, [captchaType, siteKey]);
return <div id="captcha"></div>;
};

View File

@ -18,6 +18,8 @@ import * as Setting from "../Setting";
import i18next from "i18next"; import i18next from "i18next";
import * as UserBackend from "../backend/UserBackend"; import * as UserBackend from "../backend/UserBackend";
import {SafetyOutlined} from "@ant-design/icons"; import {SafetyOutlined} from "@ant-design/icons";
import {authConfig} from "../auth/Auth";
import { CaptchaWidget } from "./CaptchaWidget";
const { Search } = Input; const { Search } = Input;
@ -30,6 +32,8 @@ export const CountDownInput = (props) => {
const [checkId, setCheckId] = React.useState(""); const [checkId, setCheckId] = React.useState("");
const [buttonLeftTime, setButtonLeftTime] = React.useState(0); const [buttonLeftTime, setButtonLeftTime] = React.useState(0);
const [buttonLoading, setButtonLoading] = React.useState(false); const [buttonLoading, setButtonLoading] = React.useState(false);
const [buttonDisabled, setButtonDisabled] = React.useState(true);
const [clientId, setClientId] = React.useState("");
const handleCountDown = (leftTime = 60) => { const handleCountDown = (leftTime = 60) => {
let leftTimeSecond = leftTime let leftTimeSecond = leftTime
@ -62,14 +66,19 @@ export const CountDownInput = (props) => {
setKey(""); setKey("");
} }
const loadHumanCheck = () => { const loadCaptcha = () => {
UserBackend.getHumanCheck().then(res => { UserBackend.getCaptcha("admin", authConfig.appName, false).then(res => {
if (res.type === "none") { if (res.type === "none") {
UserBackend.sendCode("none", "", "", ...onButtonClickArgs); UserBackend.sendCode("none", "", "", ...onButtonClickArgs);
} else if (res.type === "captcha") { } else if (res.type === "Default") {
setCheckId(res.captchaId); setCheckId(res.captchaId);
setCaptchaImg(res.captchaImage); setCaptchaImg(res.captchaImage);
setCheckType("captcha"); setCheckType("Default");
setVisible(true);
} else if (res.type === "reCAPTCHA" || res.type === "hCaptcha") {
setCheckType(res.type);
setClientId(res.clientId);
setCheckId(res.clientSecret);
setVisible(true); setVisible(true);
} else { } else {
Setting.showMessage("error", i18next.t("signup:Unknown Check Type")); Setting.showMessage("error", i18next.t("signup:Unknown Check Type"));
@ -98,9 +107,23 @@ export const CountDownInput = (props) => {
) )
} }
const onSubmit = (token) => {
setButtonDisabled(false);
setKey(token);
}
const renderCheck = () => { const renderCheck = () => {
if (checkType === "captcha") return renderCaptcha(); if (checkType === "Default") {
return null; return renderCaptcha();
} else {
return (
<CaptchaWidget
captchaType={checkType}
siteKey={clientId}
onChange={onSubmit}
/>
);
}
} }
return ( return (
@ -116,7 +139,7 @@ export const CountDownInput = (props) => {
{buttonLeftTime > 0 ? `${buttonLeftTime} s` : buttonLoading ? i18next.t("code:Sending Code") : i18next.t("code:Send Code")} {buttonLeftTime > 0 ? `${buttonLeftTime} s` : buttonLoading ? i18next.t("code:Sending Code") : i18next.t("code:Send Code")}
</Button> </Button>
} }
onSearch={loadHumanCheck} onSearch={loadCaptcha}
/> />
<Modal <Modal
closable={false} closable={false}
@ -128,8 +151,8 @@ export const CountDownInput = (props) => {
cancelText={i18next.t("user:Cancel")} cancelText={i18next.t("user:Cancel")}
onOk={handleOk} onOk={handleOk}
onCancel={handleCancel} onCancel={handleCancel}
okButtonProps={{disabled: key.length !== 5}} okButtonProps={{disabled: key.length !== 5 && buttonDisabled}}
width={248} width={348}
> >
{ {
renderCheck() renderCheck()

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "Geheimer Zugangsschlüssel", "Secret access key": "Geheimer Zugangsschlüssel",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip", "SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip",
"Sign Name": "Schild Name", "Sign Name": "Schild Name",
"Sign Name - Tooltip": "Unique string-style identifier", "Sign Name - Tooltip": "Unique string-style identifier",
@ -456,6 +458,8 @@
"Signup HTML": "HTML registrieren", "Signup HTML": "HTML registrieren",
"Signup HTML - Edit": "HTML registrieren - Bearbeiten", "Signup HTML - Edit": "HTML registrieren - Bearbeiten",
"Signup HTML - Tooltip": "HTML registrieren - Tooltip", "Signup HTML - Tooltip": "HTML registrieren - Tooltip",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "Vorlagencode", "Template Code": "Vorlagencode",
@ -578,6 +582,8 @@
"Bio": "Bio", "Bio": "Bio",
"Bio - Tooltip": "Bio - Tooltip", "Bio - Tooltip": "Bio - Tooltip",
"Cancel": "Abbrechen", "Cancel": "Abbrechen",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "Code gesendet", "Code Sent": "Code gesendet",
"Country/Region": "Land/Region", "Country/Region": "Land/Region",
"Country/Region - Tooltip": "Country/Region", "Country/Region - Tooltip": "Country/Region",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "Secret access key", "Secret access key": "Secret access key",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip", "SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip",
"Sign Name": "Sign Name", "Sign Name": "Sign Name",
"Sign Name - Tooltip": "Sign Name - Tooltip", "Sign Name - Tooltip": "Sign Name - Tooltip",
@ -456,6 +458,8 @@
"Signup HTML": "Signup HTML", "Signup HTML": "Signup HTML",
"Signup HTML - Edit": "Signup HTML - Edit", "Signup HTML - Edit": "Signup HTML - Edit",
"Signup HTML - Tooltip": "Signup HTML - Tooltip", "Signup HTML - Tooltip": "Signup HTML - Tooltip",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "Template Code", "Template Code": "Template Code",
@ -578,6 +582,8 @@
"Bio": "Bio", "Bio": "Bio",
"Bio - Tooltip": "Bio - Tooltip", "Bio - Tooltip": "Bio - Tooltip",
"Cancel": "Cancel", "Cancel": "Cancel",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "Code Sent", "Code Sent": "Code Sent",
"Country/Region": "Country/Region", "Country/Region": "Country/Region",
"Country/Region - Tooltip": "Country/Region - Tooltip", "Country/Region - Tooltip": "Country/Region - Tooltip",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "Clé d'accès secrète", "Secret access key": "Clé d'accès secrète",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "SecretAccessKey - Infobulle", "SecretAccessKey - Tooltip": "SecretAccessKey - Infobulle",
"Sign Name": "Nom du panneau", "Sign Name": "Nom du panneau",
"Sign Name - Tooltip": "Unique string-style identifier", "Sign Name - Tooltip": "Unique string-style identifier",
@ -456,6 +458,8 @@
"Signup HTML": "Inscription HTML", "Signup HTML": "Inscription HTML",
"Signup HTML - Edit": "Inscription HTML - Modifier", "Signup HTML - Edit": "Inscription HTML - Modifier",
"Signup HTML - Tooltip": "Inscription HTML - infobulle", "Signup HTML - Tooltip": "Inscription HTML - infobulle",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "Code du modèle", "Template Code": "Code du modèle",
@ -578,6 +582,8 @@
"Bio": "Bio", "Bio": "Bio",
"Bio - Tooltip": "Bio - Infobulle", "Bio - Tooltip": "Bio - Infobulle",
"Cancel": "Abandonner", "Cancel": "Abandonner",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "Code envoyé", "Code Sent": "Code envoyé",
"Country/Region": "Pays/Région", "Country/Region": "Pays/Région",
"Country/Region - Tooltip": "Country/Region", "Country/Region - Tooltip": "Country/Region",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "シークレットアクセスキー", "Secret access key": "シークレットアクセスキー",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "シークレットアクセスキー - ツールチップ", "SecretAccessKey - Tooltip": "シークレットアクセスキー - ツールチップ",
"Sign Name": "署名名", "Sign Name": "署名名",
"Sign Name - Tooltip": "Unique string-style identifier", "Sign Name - Tooltip": "Unique string-style identifier",
@ -456,6 +458,8 @@
"Signup HTML": "HTMLの登録", "Signup HTML": "HTMLの登録",
"Signup HTML - Edit": "HTMLの登録 - 編集", "Signup HTML - Edit": "HTMLの登録 - 編集",
"Signup HTML - Tooltip": "サインアップ HTML - ツールチップ", "Signup HTML - Tooltip": "サインアップ HTML - ツールチップ",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "テンプレートコード", "Template Code": "テンプレートコード",
@ -578,6 +582,8 @@
"Bio": "略歴", "Bio": "略歴",
"Bio - Tooltip": "バイオチップ(ツールチップ)", "Bio - Tooltip": "バイオチップ(ツールチップ)",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "コードを送信しました", "Code Sent": "コードを送信しました",
"Country/Region": "国/地域", "Country/Region": "国/地域",
"Country/Region - Tooltip": "Country/Region", "Country/Region - Tooltip": "Country/Region",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "Secret access key", "Secret access key": "Secret access key",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip", "SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip",
"Sign Name": "Sign Name", "Sign Name": "Sign Name",
"Sign Name - Tooltip": "Unique string-style identifier", "Sign Name - Tooltip": "Unique string-style identifier",
@ -456,6 +458,8 @@
"Signup HTML": "Signup HTML", "Signup HTML": "Signup HTML",
"Signup HTML - Edit": "Signup HTML - Edit", "Signup HTML - Edit": "Signup HTML - Edit",
"Signup HTML - Tooltip": "Signup HTML - Tooltip", "Signup HTML - Tooltip": "Signup HTML - Tooltip",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "Template Code", "Template Code": "Template Code",
@ -578,6 +582,8 @@
"Bio": "Bio", "Bio": "Bio",
"Bio - Tooltip": "Bio - Tooltip", "Bio - Tooltip": "Bio - Tooltip",
"Cancel": "Cancel", "Cancel": "Cancel",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "Code Sent", "Code Sent": "Code Sent",
"Country/Region": "Country/Region", "Country/Region": "Country/Region",
"Country/Region - Tooltip": "Country/Region", "Country/Region - Tooltip": "Country/Region",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - Tooltip", "Scope - Tooltip": "Scope - Tooltip",
"Secret access key": "Секретный ключ доступа", "Secret access key": "Секретный ключ доступа",
"Secret key": "Secret key",
"Secret key - Tooltip": "Secret key - Tooltip",
"SecretAccessKey - Tooltip": "SecretAccessKey - Подсказка", "SecretAccessKey - Tooltip": "SecretAccessKey - Подсказка",
"Sign Name": "Имя подписи", "Sign Name": "Имя подписи",
"Sign Name - Tooltip": "Unique string-style identifier", "Sign Name - Tooltip": "Unique string-style identifier",
@ -456,6 +458,8 @@
"Signup HTML": "Регистрация HTML", "Signup HTML": "Регистрация HTML",
"Signup HTML - Edit": "Регистрация HTML - Редактировать", "Signup HTML - Edit": "Регистрация HTML - Редактировать",
"Signup HTML - Tooltip": "Регистрация HTML - Подсказка", "Signup HTML - Tooltip": "Регистрация HTML - Подсказка",
"Site key": "Site key",
"Site key - Tooltip": "Site key - Tooltip",
"Sub type": "Sub type", "Sub type": "Sub type",
"Sub type - Tooltip": "Sub type - Tooltip", "Sub type - Tooltip": "Sub type - Tooltip",
"Template Code": "Код шаблона", "Template Code": "Код шаблона",
@ -578,6 +582,8 @@
"Bio": "Био", "Bio": "Био",
"Bio - Tooltip": "Био - Подсказка", "Bio - Tooltip": "Био - Подсказка",
"Cancel": "Отмена", "Cancel": "Отмена",
"Captcha Verify Failed": "Captcha Verify Failed",
"Captcha Verify Success": "Captcha Verify Success",
"Code Sent": "Код отправлен", "Code Sent": "Код отправлен",
"Country/Region": "Страна/регион", "Country/Region": "Страна/регион",
"Country/Region - Tooltip": "Country/Region", "Country/Region - Tooltip": "Country/Region",

View File

@ -445,6 +445,8 @@
"Scope": "Scope", "Scope": "Scope",
"Scope - Tooltip": "Scope - 工具提示", "Scope - Tooltip": "Scope - 工具提示",
"Secret access key": "秘密访问密钥", "Secret access key": "秘密访问密钥",
"Secret key": "服务端密钥",
"Secret key - Tooltip": "服务端密钥",
"SecretAccessKey - Tooltip": "访问密钥-工具提示", "SecretAccessKey - Tooltip": "访问密钥-工具提示",
"Sign Name": "签名名称", "Sign Name": "签名名称",
"Sign Name - Tooltip": "签名名称", "Sign Name - Tooltip": "签名名称",
@ -456,6 +458,8 @@
"Signup HTML": "注册页面HTML", "Signup HTML": "注册页面HTML",
"Signup HTML - Edit": "注册页面HTML - 编辑", "Signup HTML - Edit": "注册页面HTML - 编辑",
"Signup HTML - Tooltip": "自定义HTML用于替换默认的注册页面样式", "Signup HTML - Tooltip": "自定义HTML用于替换默认的注册页面样式",
"Site key": "客户端密钥",
"Site key - Tooltip": "客户端密钥",
"Sub type": "子类型", "Sub type": "子类型",
"Sub type - Tooltip": "子类型", "Sub type - Tooltip": "子类型",
"Template Code": "模板代码", "Template Code": "模板代码",
@ -578,6 +582,8 @@
"Bio": "自我介绍", "Bio": "自我介绍",
"Bio - Tooltip": "自我介绍", "Bio - Tooltip": "自我介绍",
"Cancel": "取消", "Cancel": "取消",
"Captcha Verify Failed": "验证码校验失败",
"Captcha Verify Success": "验证码校验成功",
"Code Sent": "验证码已发送", "Code Sent": "验证码已发送",
"Country/Region": "国家/地区", "Country/Region": "国家/地区",
"Country/Region - Tooltip": "国家/地区", "Country/Region - Tooltip": "国家/地区",