mirror of
https://github.com/casdoor/casdoor.git
synced 2025-05-23 10:45:47 +08:00
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:
parent
ae4ab9902b
commit
2e42511bc4
@ -95,7 +95,8 @@ p, *, *, GET, /api/get-providers, *, *
|
||||
p, *, *, POST, /api/unlink, *, *
|
||||
p, *, *, POST, /api/set-password, *, *
|
||||
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/upload-resource, *, *
|
||||
p, *, *, GET, /.well-known/openid-configuration, *, *
|
||||
|
29
captcha/default.go
Normal file
29
captcha/default.go
Normal 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
60
captcha/hcaptcha.go
Normal 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
30
captcha/provider.go
Normal 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
60
captcha/recaptcha.go
Normal 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
|
||||
}
|
@ -75,12 +75,14 @@ type Response struct {
|
||||
Data2 interface{} `json:"data2"`
|
||||
}
|
||||
|
||||
type HumanCheck struct {
|
||||
Type string `json:"type"`
|
||||
AppKey string `json:"appKey"`
|
||||
Scene string `json:"scene"`
|
||||
CaptchaId string `json:"captchaId"`
|
||||
CaptchaImage interface{} `json:"captchaImage"`
|
||||
type Captcha struct {
|
||||
Type string `json:"type"`
|
||||
AppKey string `json:"appKey"`
|
||||
Scene string `json:"scene"`
|
||||
CaptchaId string `json:"captchaId"`
|
||||
CaptchaImage []byte `json:"captchaImage"`
|
||||
ClientId string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
}
|
||||
|
||||
// Signup
|
||||
@ -291,20 +293,27 @@ func (c *ApiController) GetUserinfo() {
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetHumanCheck ...
|
||||
// GetCaptcha ...
|
||||
// @Tag Login API
|
||||
// @Title GetHumancheck
|
||||
// @router /api/get-human-check [get]
|
||||
func (c *ApiController) GetHumanCheck() {
|
||||
c.Data["json"] = HumanCheck{Type: "none"}
|
||||
// @Title GetCaptcha
|
||||
// @router /api/get-captcha [get]
|
||||
func (c *ApiController) GetCaptcha() {
|
||||
applicationId := c.Input().Get("applicationId")
|
||||
isCurrentProvider := c.Input().Get("isCurrentProvider")
|
||||
|
||||
provider := object.GetDefaultHumanCheckProvider()
|
||||
if provider == nil {
|
||||
id, img := object.GetCaptcha()
|
||||
c.Data["json"] = HumanCheck{Type: "captcha", CaptchaId: id, CaptchaImage: img}
|
||||
c.ServeJSON()
|
||||
captchaProvider, err := object.GetCaptchaProviderByApplication(applicationId, isCurrentProvider)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
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"})
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/captcha"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
@ -48,15 +49,25 @@ func (c *ApiController) SendVerificationCode() {
|
||||
checkUser := c.Ctx.Request.Form.Get("checkUser")
|
||||
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.")
|
||||
return
|
||||
}
|
||||
|
||||
isHuman := false
|
||||
captchaProvider := object.GetDefaultHumanCheckProvider()
|
||||
if captchaProvider == nil {
|
||||
isHuman = object.VerifyCaptcha(checkId, checkKey)
|
||||
provider := captcha.GetCaptchaProvider(checkType)
|
||||
if provider == nil {
|
||||
c.ResponseError("Invalid captcha provider.")
|
||||
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 {
|
||||
@ -173,3 +184,36 @@ func (c *ApiController) ResetEmailOrPhone() {
|
||||
c.Data["json"] = Response{Status: "ok"}
|
||||
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)
|
||||
}
|
||||
|
@ -142,8 +142,8 @@ func GetProvider(id string) *Provider {
|
||||
return getProvider(owner, name)
|
||||
}
|
||||
|
||||
func GetDefaultHumanCheckProvider() *Provider {
|
||||
provider := Provider{Owner: "admin", Category: "HumanCheck"}
|
||||
func GetDefaultCaptchaProvider() *Provider {
|
||||
provider := Provider{Owner: "admin", Category: "Captcha"}
|
||||
existed, err := adapter.Engine.Get(&provider)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@ -225,3 +225,37 @@ func (p *Provider) getPaymentProvider() (pp.PaymentProvider, *Cert, error) {
|
||||
func (p *Provider) GetId() string {
|
||||
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")
|
||||
}
|
||||
|
@ -94,8 +94,9 @@ func initAPI() {
|
||||
beego.Router("/api/check-user-password", &controllers.ApiController{}, "POST:CheckUserPassword")
|
||||
beego.Router("/api/get-email-and-phone", &controllers.ApiController{}, "POST:GetEmailAndPhone")
|
||||
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/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-ldaps", &controllers.ApiController{}, "POST:GetLdaps")
|
||||
|
@ -378,12 +378,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/api/get-human-check": {
|
||||
"/api/api/get-captcha": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Login API"
|
||||
],
|
||||
"operationId": "ApiController.GetHumancheck"
|
||||
"operationId": "ApiController.GetCaptcha"
|
||||
}
|
||||
},
|
||||
"/api/api/reset-email-or-phone": {
|
||||
|
@ -243,11 +243,11 @@ paths:
|
||||
description: The Response object
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.Response'
|
||||
/api/api/get-human-check:
|
||||
/api/api/get-captcha:
|
||||
get:
|
||||
tags:
|
||||
- Login API
|
||||
operationId: ApiController.GetHumancheck
|
||||
operationId: ApiController.GetCaptcha
|
||||
/api/api/reset-email-or-phone:
|
||||
post:
|
||||
tags:
|
||||
|
@ -20,6 +20,7 @@ import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
import { authConfig } from "./auth/Auth";
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { CaptchaPreview } from "./common/CaptchaPreview";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
@ -77,6 +78,8 @@ class ProviderEditPage extends React.Component {
|
||||
} else {
|
||||
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:
|
||||
return Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"));
|
||||
}
|
||||
@ -94,6 +97,8 @@ class ProviderEditPage extends React.Component {
|
||||
} else {
|
||||
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:
|
||||
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());
|
||||
} else if (value === "SAML") {
|
||||
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: 'SAML', name: 'SAML'},
|
||||
{id: 'Payment', name: 'Payment'},
|
||||
{id: 'Captcha', name: 'Captcha'},
|
||||
].map((providerCategory, index) => <Option key={index} value={providerCategory.id}>{providerCategory.name}</Option>)
|
||||
}
|
||||
</Select>
|
||||
@ -341,26 +349,31 @@ class ProviderEditPage extends React.Component {
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{this.getClientIdLabel()}
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.clientId} onChange={e => {
|
||||
this.updateProviderField('clientId', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{this.getClientSecretLabel()}
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.clientSecret} onChange={e => {
|
||||
this.updateProviderField('clientSecret', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.provider.type !== "Default" &&
|
||||
<>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{this.getClientIdLabel()}
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.clientId} onChange={e => {
|
||||
this.updateProviderField('clientId', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{this.getClientSecretLabel()}
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.clientSecret} onChange={e => {
|
||||
this.updateProviderField('clientSecret', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
}
|
||||
{
|
||||
this.state.provider.type !== "WeChat" ? null : (
|
||||
<React.Fragment>
|
||||
@ -627,16 +640,39 @@ class ProviderEditPage extends React.Component {
|
||||
) : null
|
||||
}
|
||||
{this.getAppIdRow()}
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined/>} value={this.state.provider.providerUrl} onChange={e => {
|
||||
this.updateProviderField('providerUrl', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.provider.type !== "Default" &&
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined/>} value={this.state.provider.providerUrl} onChange={e => {
|
||||
this.updateProviderField('providerUrl', e.target.value);
|
||||
}} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -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: '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: 'Captcha', value: 'Captcha', children: Setting.getProviderTypeOptions('Captcha').map((o) => {return {text:o.id, value:o.name}})},
|
||||
],
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
|
@ -106,6 +106,20 @@ export const OtherProviderInfo = {
|
||||
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() {
|
||||
@ -595,6 +609,12 @@ export function getProviderTypeOptions(category) {
|
||||
{id: 'PayPal', name: 'PayPal'},
|
||||
{id: 'GC', name: 'GC'},
|
||||
]);
|
||||
} else if (category === "Captcha") {
|
||||
return ([
|
||||
{id: 'Default', name: 'Default'},
|
||||
{id: 'reCAPTCHA', name: 'reCAPTCHA'},
|
||||
{id: 'hCaptcha', name: 'hCaptcha'},
|
||||
]);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
@ -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) {
|
||||
let formData = new FormData();
|
||||
formData.append("dest", dest);
|
||||
@ -124,8 +148,8 @@ export function resetEmailOrPhone(dest, type, code) {
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function getHumanCheck() {
|
||||
return fetch(`${Setting.ServerUrl}/api/get-human-check`, {
|
||||
export function getCaptcha(owner, name, isCurrentProvider) {
|
||||
return fetch(`${Setting.ServerUrl}/api/get-captcha?applicationId=${owner}/${encodeURIComponent(name)}&isCurrentProvider=${isCurrentProvider}`, {
|
||||
method: "GET"
|
||||
}).then(res => res.json());
|
||||
}).then(res => res.json()).then(res => res.data);
|
||||
}
|
||||
|
139
web/src/common/CaptchaPreview.js
Normal file
139
web/src/common/CaptchaPreview.js
Normal 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>
|
||||
);
|
||||
};
|
62
web/src/common/CaptchaWidget.js
Normal file
62
web/src/common/CaptchaWidget.js
Normal 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>;
|
||||
};
|
@ -18,6 +18,8 @@ import * as Setting from "../Setting";
|
||||
import i18next from "i18next";
|
||||
import * as UserBackend from "../backend/UserBackend";
|
||||
import {SafetyOutlined} from "@ant-design/icons";
|
||||
import {authConfig} from "../auth/Auth";
|
||||
import { CaptchaWidget } from "./CaptchaWidget";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@ -30,6 +32,8 @@ export const CountDownInput = (props) => {
|
||||
const [checkId, setCheckId] = React.useState("");
|
||||
const [buttonLeftTime, setButtonLeftTime] = React.useState(0);
|
||||
const [buttonLoading, setButtonLoading] = React.useState(false);
|
||||
const [buttonDisabled, setButtonDisabled] = React.useState(true);
|
||||
const [clientId, setClientId] = React.useState("");
|
||||
|
||||
const handleCountDown = (leftTime = 60) => {
|
||||
let leftTimeSecond = leftTime
|
||||
@ -62,14 +66,19 @@ export const CountDownInput = (props) => {
|
||||
setKey("");
|
||||
}
|
||||
|
||||
const loadHumanCheck = () => {
|
||||
UserBackend.getHumanCheck().then(res => {
|
||||
const loadCaptcha = () => {
|
||||
UserBackend.getCaptcha("admin", authConfig.appName, false).then(res => {
|
||||
if (res.type === "none") {
|
||||
UserBackend.sendCode("none", "", "", ...onButtonClickArgs);
|
||||
} else if (res.type === "captcha") {
|
||||
} else if (res.type === "Default") {
|
||||
setCheckId(res.captchaId);
|
||||
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);
|
||||
} else {
|
||||
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 = () => {
|
||||
if (checkType === "captcha") return renderCaptcha();
|
||||
return null;
|
||||
if (checkType === "Default") {
|
||||
return renderCaptcha();
|
||||
} else {
|
||||
return (
|
||||
<CaptchaWidget
|
||||
captchaType={checkType}
|
||||
siteKey={clientId}
|
||||
onChange={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -116,7 +139,7 @@ export const CountDownInput = (props) => {
|
||||
{buttonLeftTime > 0 ? `${buttonLeftTime} s` : buttonLoading ? i18next.t("code:Sending Code") : i18next.t("code:Send Code")}
|
||||
</Button>
|
||||
}
|
||||
onSearch={loadHumanCheck}
|
||||
onSearch={loadCaptcha}
|
||||
/>
|
||||
<Modal
|
||||
closable={false}
|
||||
@ -128,8 +151,8 @@ export const CountDownInput = (props) => {
|
||||
cancelText={i18next.t("user:Cancel")}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{disabled: key.length !== 5}}
|
||||
width={248}
|
||||
okButtonProps={{disabled: key.length !== 5 && buttonDisabled}}
|
||||
width={348}
|
||||
>
|
||||
{
|
||||
renderCheck()
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"Scope - Tooltip": "Scope - Tooltip",
|
||||
"Secret access key": "Geheimer Zugangsschlüssel",
|
||||
"Secret key": "Secret key",
|
||||
"Secret key - Tooltip": "Secret key - Tooltip",
|
||||
"SecretAccessKey - Tooltip": "SecretAccessKey - Tooltip",
|
||||
"Sign Name": "Schild Name",
|
||||
"Sign Name - Tooltip": "Unique string-style identifier",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "HTML registrieren",
|
||||
"Signup HTML - Edit": "HTML registrieren - Bearbeiten",
|
||||
"Signup HTML - Tooltip": "HTML registrieren - Tooltip",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "Site key - Tooltip",
|
||||
"Sub type": "Sub type",
|
||||
"Sub type - Tooltip": "Sub type - Tooltip",
|
||||
"Template Code": "Vorlagencode",
|
||||
@ -578,6 +582,8 @@
|
||||
"Bio": "Bio",
|
||||
"Bio - Tooltip": "Bio - Tooltip",
|
||||
"Cancel": "Abbrechen",
|
||||
"Captcha Verify Failed": "Captcha Verify Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "Code gesendet",
|
||||
"Country/Region": "Land/Region",
|
||||
"Country/Region - Tooltip": "Country/Region",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"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": "Sign Name - Tooltip",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "Signup HTML",
|
||||
"Signup HTML - Edit": "Signup HTML - Edit",
|
||||
"Signup HTML - Tooltip": "Signup HTML - Tooltip",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "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 Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "Code Sent",
|
||||
"Country/Region": "Country/Region",
|
||||
"Country/Region - Tooltip": "Country/Region - Tooltip",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"Scope - Tooltip": "Scope - Tooltip",
|
||||
"Secret access key": "Clé d'accès secrète",
|
||||
"Secret key": "Secret key",
|
||||
"Secret key - Tooltip": "Secret key - Tooltip",
|
||||
"SecretAccessKey - Tooltip": "SecretAccessKey - Infobulle",
|
||||
"Sign Name": "Nom du panneau",
|
||||
"Sign Name - Tooltip": "Unique string-style identifier",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "Inscription HTML",
|
||||
"Signup HTML - Edit": "Inscription HTML - Modifier",
|
||||
"Signup HTML - Tooltip": "Inscription HTML - infobulle",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "Site key - Tooltip",
|
||||
"Sub type": "Sub type",
|
||||
"Sub type - Tooltip": "Sub type - Tooltip",
|
||||
"Template Code": "Code du modèle",
|
||||
@ -578,6 +582,8 @@
|
||||
"Bio": "Bio",
|
||||
"Bio - Tooltip": "Bio - Infobulle",
|
||||
"Cancel": "Abandonner",
|
||||
"Captcha Verify Failed": "Captcha Verify Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "Code envoyé",
|
||||
"Country/Region": "Pays/Région",
|
||||
"Country/Region - Tooltip": "Country/Region",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"Scope - Tooltip": "Scope - Tooltip",
|
||||
"Secret access key": "シークレットアクセスキー",
|
||||
"Secret key": "Secret key",
|
||||
"Secret key - Tooltip": "Secret key - Tooltip",
|
||||
"SecretAccessKey - Tooltip": "シークレットアクセスキー - ツールチップ",
|
||||
"Sign Name": "署名名",
|
||||
"Sign Name - Tooltip": "Unique string-style identifier",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "HTMLの登録",
|
||||
"Signup HTML - Edit": "HTMLの登録 - 編集",
|
||||
"Signup HTML - Tooltip": "サインアップ HTML - ツールチップ",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "Site key - Tooltip",
|
||||
"Sub type": "Sub type",
|
||||
"Sub type - Tooltip": "Sub type - Tooltip",
|
||||
"Template Code": "テンプレートコード",
|
||||
@ -578,6 +582,8 @@
|
||||
"Bio": "略歴",
|
||||
"Bio - Tooltip": "バイオチップ(ツールチップ)",
|
||||
"Cancel": "キャンセル",
|
||||
"Captcha Verify Failed": "Captcha Verify Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "コードを送信しました",
|
||||
"Country/Region": "国/地域",
|
||||
"Country/Region - Tooltip": "Country/Region",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"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",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "Signup HTML",
|
||||
"Signup HTML - Edit": "Signup HTML - Edit",
|
||||
"Signup HTML - Tooltip": "Signup HTML - Tooltip",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "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 Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "Code Sent",
|
||||
"Country/Region": "Country/Region",
|
||||
"Country/Region - Tooltip": "Country/Region",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"Scope - Tooltip": "Scope - Tooltip",
|
||||
"Secret access key": "Секретный ключ доступа",
|
||||
"Secret key": "Secret key",
|
||||
"Secret key - Tooltip": "Secret key - Tooltip",
|
||||
"SecretAccessKey - Tooltip": "SecretAccessKey - Подсказка",
|
||||
"Sign Name": "Имя подписи",
|
||||
"Sign Name - Tooltip": "Unique string-style identifier",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "Регистрация HTML",
|
||||
"Signup HTML - Edit": "Регистрация HTML - Редактировать",
|
||||
"Signup HTML - Tooltip": "Регистрация HTML - Подсказка",
|
||||
"Site key": "Site key",
|
||||
"Site key - Tooltip": "Site key - Tooltip",
|
||||
"Sub type": "Sub type",
|
||||
"Sub type - Tooltip": "Sub type - Tooltip",
|
||||
"Template Code": "Код шаблона",
|
||||
@ -578,6 +582,8 @@
|
||||
"Bio": "Био",
|
||||
"Bio - Tooltip": "Био - Подсказка",
|
||||
"Cancel": "Отмена",
|
||||
"Captcha Verify Failed": "Captcha Verify Failed",
|
||||
"Captcha Verify Success": "Captcha Verify Success",
|
||||
"Code Sent": "Код отправлен",
|
||||
"Country/Region": "Страна/регион",
|
||||
"Country/Region - Tooltip": "Country/Region",
|
||||
|
@ -445,6 +445,8 @@
|
||||
"Scope": "Scope",
|
||||
"Scope - Tooltip": "Scope - 工具提示",
|
||||
"Secret access key": "秘密访问密钥",
|
||||
"Secret key": "服务端密钥",
|
||||
"Secret key - Tooltip": "服务端密钥",
|
||||
"SecretAccessKey - Tooltip": "访问密钥-工具提示",
|
||||
"Sign Name": "签名名称",
|
||||
"Sign Name - Tooltip": "签名名称",
|
||||
@ -456,6 +458,8 @@
|
||||
"Signup HTML": "注册页面HTML",
|
||||
"Signup HTML - Edit": "注册页面HTML - 编辑",
|
||||
"Signup HTML - Tooltip": "自定义HTML,用于替换默认的注册页面样式",
|
||||
"Site key": "客户端密钥",
|
||||
"Site key - Tooltip": "客户端密钥",
|
||||
"Sub type": "子类型",
|
||||
"Sub type - Tooltip": "子类型",
|
||||
"Template Code": "模板代码",
|
||||
@ -578,6 +582,8 @@
|
||||
"Bio": "自我介绍",
|
||||
"Bio - Tooltip": "自我介绍",
|
||||
"Cancel": "取消",
|
||||
"Captcha Verify Failed": "验证码校验失败",
|
||||
"Captcha Verify Success": "验证码校验成功",
|
||||
"Code Sent": "验证码已发送",
|
||||
"Country/Region": "国家/地区",
|
||||
"Country/Region - Tooltip": "国家/地区",
|
||||
|
Loading…
x
Reference in New Issue
Block a user