From 25d56ee8d5e4416ae53dae6e7b4787557beffde2 Mon Sep 17 00:00:00 2001 From: wenxuan70 Date: Fri, 28 Oct 2022 13:38:14 +0800 Subject: [PATCH] feat: allow captcha to be enabled when logging in (#1211) * Fix bug in GetAcceptLanguage() * feat: allow captcha to be enabled when logging in * feat: when the login password is wrong, enable captcha * feat: Restrict captcha from frontend * fix: modify CaptchaModal component * fix: modify the words of i18n * Update data.json Co-authored-by: Gucheng Wang Co-authored-by: hsluoyz --- captcha/provider.go | 11 +++ controllers/account.go | 4 + controllers/auth.go | 21 ++++ object/check.go | 17 ++++ object/init.go | 2 +- object/provider_item.go | 1 + web/src/ProviderTable.js | 24 ++++- web/src/SignupTable.js | 2 +- web/src/auth/LoginPage.js | 81 ++++++++++++++++ web/src/common/CaptchaModal.js | 159 +++++++++++++++++++++++++++++++ web/src/common/CaptchaPreview.js | 133 ++++++-------------------- web/src/locales/de/data.json | 4 +- web/src/locales/en/data.json | 4 +- web/src/locales/es/data.json | 4 +- web/src/locales/fr/data.json | 4 +- web/src/locales/ja/data.json | 4 +- web/src/locales/ko/data.json | 4 +- web/src/locales/ru/data.json | 4 +- web/src/locales/zh/data.json | 4 +- 19 files changed, 373 insertions(+), 114 deletions(-) create mode 100644 web/src/common/CaptchaModal.js diff --git a/captcha/provider.go b/captcha/provider.go index 88aaee42..6968d3c8 100644 --- a/captcha/provider.go +++ b/captcha/provider.go @@ -14,6 +14,8 @@ package captcha +import "fmt" + type CaptchaProvider interface { VerifyCaptcha(token, clientSecret string) (bool, error) } @@ -32,3 +34,12 @@ func GetCaptchaProvider(captchaType string) CaptchaProvider { } return nil } + +func VerifyCaptchaByCaptchaType(captchaType, token, clientSecret string) (bool, error) { + provider := GetCaptchaProvider(captchaType) + if provider == nil { + return false, fmt.Errorf("invalid captcha provider: %s", captchaType) + } + + return provider.VerifyCaptcha(token, clientSecret) +} diff --git a/controllers/account.go b/controllers/account.go index 127964ec..236a7c22 100644 --- a/controllers/account.go +++ b/controllers/account.go @@ -64,6 +64,10 @@ type RequestForm struct { RelayState string `json:"relayState"` SamlRequest string `json:"samlRequest"` SamlResponse string `json:"samlResponse"` + + CaptchaType string `json:"captchaType"` + CaptchaToken string `json:"captchaToken"` + ClientSecret string `json:"clientSecret"` } type Response struct { diff --git a/controllers/auth.go b/controllers/auth.go index c9090289..dfaadf32 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -23,6 +23,8 @@ import ( "strings" "time" + "github.com/casdoor/casdoor/captcha" + "github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/idp" "github.com/casdoor/casdoor/object" @@ -251,6 +253,25 @@ func (c *ApiController) Login() { return } } else { + application := object.GetApplication(fmt.Sprintf("admin/%s", form.Application)) + if application == nil { + c.ResponseError(fmt.Sprintf("The application: %s does not exist", form.Application)) + return + } + + if object.CheckToEnableCaptcha(application) { + isHuman, err := captcha.VerifyCaptchaByCaptchaType(form.CaptchaType, form.CaptchaToken, form.ClientSecret) + if err != nil { + c.ResponseError(err.Error()) + return + } + + if !isHuman { + c.ResponseError("Turing test failed.") + return + } + } + password := form.Password user, msg = object.CheckUserPassword(form.Organization, form.Username, password, c.GetAcceptLanguage()) } diff --git a/object/check.go b/object/check.go index e081ed71..d4609ba0 100644 --- a/object/check.go +++ b/object/check.go @@ -340,3 +340,20 @@ func CheckUsername(username string, lang string) string { return "" } + +func CheckToEnableCaptcha(application *Application) bool { + if len(application.Providers) == 0 { + return false + } + + for _, providerItem := range application.Providers { + if providerItem.Provider == nil { + continue + } + if providerItem.Provider.Category == "Captcha" && providerItem.Provider.Type == "Default" { + return providerItem.Rule == "Always" + } + } + + return false +} diff --git a/object/init.go b/object/init.go index 1c0f7a13..b7d51489 100644 --- a/object/init.go +++ b/object/init.go @@ -143,7 +143,7 @@ func initBuiltInApplication() { EnablePassword: true, EnableSignUp: true, Providers: []*ProviderItem{ - {Name: "provider_captcha_default", CanSignUp: false, CanSignIn: false, CanUnlink: false, Prompted: false, AlertType: "None", Provider: nil}, + {Name: "provider_captcha_default", CanSignUp: false, CanSignIn: false, CanUnlink: false, Prompted: false, AlertType: "None", Rule: "None", Provider: nil}, }, SignupItems: []*SignupItem{ {Name: "ID", Visible: false, Required: true, Prompted: false, Rule: "Random"}, diff --git a/object/provider_item.go b/object/provider_item.go index 80ee95a1..00083da2 100644 --- a/object/provider_item.go +++ b/object/provider_item.go @@ -21,6 +21,7 @@ type ProviderItem struct { CanUnlink bool `json:"canUnlink"` Prompted bool `json:"prompted"` AlertType string `json:"alertType"` + Rule string `json:"rule"` Provider *Provider `json:"provider"` } diff --git a/web/src/ProviderTable.js b/web/src/ProviderTable.js index 79b6c79b..8762e41a 100644 --- a/web/src/ProviderTable.js +++ b/web/src/ProviderTable.js @@ -39,7 +39,7 @@ class ProviderTable extends React.Component { } addRow(table) { - const row = {name: Setting.getNewRowNameForTable(table, "Please select a provider"), canSignUp: true, canSignIn: true, canUnlink: true, alertType: "None"}; + const row = {name: Setting.getNewRowNameForTable(table, "Please select a provider"), canSignUp: true, canSignIn: true, canUnlink: true, alertType: "None", rule: "None"}; if (table === undefined) { table = []; } @@ -193,6 +193,28 @@ class ProviderTable extends React.Component { // ) // } // }, + { + title: i18next.t("application:Rule"), + dataIndex: "rule", + key: "rule", + width: "100px", + render: (text, record, index) => { + if (record.provider?.category !== "Captcha") { + return null; + } + return ( + + ); + }, + }, { title: i18next.t("general:Action"), key: "action", diff --git a/web/src/SignupTable.js b/web/src/SignupTable.js index da577b30..7c906794 100644 --- a/web/src/SignupTable.js +++ b/web/src/SignupTable.js @@ -164,7 +164,7 @@ class SignupTable extends React.Component { }, }, { - title: i18next.t("application:rule"), + title: i18next.t("application:Rule"), dataIndex: "rule", key: "rule", width: "155px", diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js index 05c71827..3c68a9cd 100644 --- a/web/src/auth/LoginPage.js +++ b/web/src/auth/LoginPage.js @@ -29,6 +29,7 @@ import CustomGithubCorner from "../CustomGithubCorner"; import {CountDownInput} from "../common/CountDownInput"; import SelectLanguageBox from "../SelectLanguageBox"; import {withTranslation} from "react-i18next"; +import {CaptchaModal} from "../common/CaptchaModal"; const {TabPane} = Tabs; @@ -48,6 +49,9 @@ class LoginPage extends React.Component { validEmail: false, validPhone: false, loginMethod: "password", + enableCaptchaModal: false, + openCaptchaModal: false, + verifyCaptcha: undefined, }; if (this.state.type === "cas" && props.match?.params.casApplicationName !== undefined) { @@ -68,6 +72,18 @@ class LoginPage extends React.Component { } } + componentDidUpdate(prevProps, prevState, snapshot) { + if (this.state.application && !prevState.application) { + const defaultCaptchaProviderItems = this.getDefaultCaptchaProviderItems(this.state.application); + + if (!defaultCaptchaProviderItems) { + return; + } + + this.setState({enableCaptchaModal: defaultCaptchaProviderItems.some(providerItem => providerItem.rule === "Always")}); + } + } + getApplicationLogin() { const oAuthParams = Util.getOAuthGetParameters(); AuthBackend.getApplicationLogin(oAuthParams) @@ -225,6 +241,23 @@ class LoginPage extends React.Component { return; } + if (this.state.loginMethod === "password" && this.state.enableCaptchaModal) { + this.setState({ + openCaptchaModal: true, + verifyCaptcha: (captchaType, captchaToken, secret) => { + values["captchaType"] = captchaType; + values["captchaToken"] = captchaToken; + values["clientSecret"] = secret; + + this.login(values); + }, + }); + } else { + this.login(values); + } + } + + login(values) { // here we are supposed to determine whether Casdoor is working as an OAuth server or CAS server if (this.state.type === "cas") { // CAS @@ -239,6 +272,8 @@ class LoginPage extends React.Component { } Util.showMessage("success", msg); + this.setState({openCaptchaModal: false}); + if (casParams.service !== "") { const st = res.data; const newUrl = new URL(casParams.service); @@ -246,6 +281,7 @@ class LoginPage extends React.Component { window.location.href = newUrl.toString(); } } else { + this.setState({openCaptchaModal: false}); Util.showMessage("error", `Failed to log in: ${res.msg}`); } }); @@ -258,6 +294,7 @@ class LoginPage extends React.Component { .then((res) => { if (res.status === "ok") { const responseType = values["type"]; + if (responseType === "login") { Util.showMessage("success", "Logged in successfully"); @@ -275,6 +312,7 @@ class LoginPage extends React.Component { Setting.goToLink(`${redirectUri}?SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`); } } else { + this.setState({openCaptchaModal: false}); Util.showMessage("error", `Failed to log in: ${res.msg}`); } }); @@ -418,6 +456,9 @@ class LoginPage extends React.Component { i18next.t("login:Sign In") } + { + this.renderCaptchaModal(application) + } { this.renderFooter(application) } @@ -460,6 +501,46 @@ class LoginPage extends React.Component { } } + getDefaultCaptchaProviderItems(application) { + const providers = application?.providers; + + if (providers === undefined || providers === null) { + return null; + } + + return providers.filter(providerItem => { + if (providerItem.provider === undefined || providerItem.provider === null) { + return false; + } + + return providerItem.provider.category === "Captcha" && providerItem.provider.type === "Default"; + }); + } + + renderCaptchaModal(application) { + if (!this.state.enableCaptchaModal) { + return null; + } + + const provider = this.getDefaultCaptchaProviderItems(application) + .filter(providerItem => providerItem.rule === "Always") + .map(providerItem => providerItem.provider)[0]; + + return this.state.verifyCaptcha?.(captchaType, captchaToken, secret)} + canCancel={false} + />; + } + renderFooter(application) { if (this.state.mode === "signup") { return ( diff --git a/web/src/common/CaptchaModal.js b/web/src/common/CaptchaModal.js new file mode 100644 index 00000000..953dbd1f --- /dev/null +++ b/web/src/common/CaptchaModal.js @@ -0,0 +1,159 @@ +// 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 i18next from "i18next"; +import React, {useEffect} from "react"; +import * as UserBackend from "../backend/UserBackend"; +import {CaptchaWidget} from "./CaptchaWidget"; +import {SafetyOutlined} from "@ant-design/icons"; + +export const CaptchaModal = ({ + owner, + name, + captchaType, + subType, + clientId, + clientId2, + clientSecret, + clientSecret2, + open, + onOk, + onCancel, + canCancel, +}) => { + const [visible, setVisible] = React.useState(false); + const [captchaImg, setCaptchaImg] = React.useState(""); + const [captchaToken, setCaptchaToken] = React.useState(""); + const [secret, setSecret] = React.useState(clientSecret); + const [secret2, setSecret2] = React.useState(clientSecret2); + + useEffect(() => { + setVisible(() => { + if (open) { + getCaptchaFromBackend(); + } else { + cleanUp(); + } + return open; + }); + }, [open]); + + const handleOk = () => { + onOk?.(captchaType, captchaToken, secret); + }; + + const handleCancel = () => { + onCancel?.(); + }; + + const cleanUp = () => { + setCaptchaToken(""); + }; + + const getCaptchaFromBackend = () => { + UserBackend.getCaptcha(owner, name, true).then((res) => { + if (captchaType === "Default") { + setSecret(res.captchaId); + setCaptchaImg(res.captchaImage); + } else { + setSecret(res.clientSecret); + setSecret2(res.clientSecret2); + } + }); + }; + + const renderDefaultCaptcha = () => { + return ( + + + + } + placeholder={i18next.t("general:Captcha")} + onPressEnter={handleOk} + onChange={(e) => setCaptchaToken(e.target.value)} + /> + + + ); + }; + + const onSubmit = (token) => { + setCaptchaToken(token); + }; + + const renderCheck = () => { + if (captchaType === "Default") { + return renderDefaultCaptcha(); + } else { + return ( + + + + + + ); + } + }; + + const renderFooter = () => { + if (canCancel) { + return [ + , + , + ]; + } else { + return [ + , + ]; + } + }; + + return ( + + + {renderCheck()} + + + ); +}; diff --git a/web/src/common/CaptchaPreview.js b/web/src/common/CaptchaPreview.js index c6f78981..278df1fa 100644 --- a/web/src/common/CaptchaPreview.js +++ b/web/src/common/CaptchaPreview.js @@ -12,13 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Button, Col, Input, Modal, Row} from "antd"; +import {Button} from "antd"; import React from "react"; import i18next from "i18next"; -import * as UserBackend from "../backend/UserBackend"; +import {CaptchaModal} from "./CaptchaModal"; import * as ProviderBackend from "../backend/ProviderBackend"; -import {SafetyOutlined} from "@ant-design/icons"; -import {CaptchaWidget} from "./CaptchaWidget"; +import * as UserBackend from "../backend/UserBackend"; export const CaptchaPreview = ({ provider, @@ -33,37 +32,9 @@ export const CaptchaPreview = ({ clientId2, clientSecret2, }) => { - const [visible, setVisible] = React.useState(false); - const [captchaImg, setCaptchaImg] = React.useState(""); - const [captchaToken, setCaptchaToken] = React.useState(""); - const [secret, setSecret] = React.useState(clientSecret); - const [secret2, setSecret2] = React.useState(clientSecret2); - - 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); - setSecret2(res.clientSecret2); - } - }); - }; + const [open, setOpen] = React.useState(false); const clickPreview = () => { - setVisible(true); provider.name = name; provider.clientId = clientId; provider.type = captchaType; @@ -71,64 +42,10 @@ export const CaptchaPreview = ({ if (clientSecret !== "***") { provider.clientSecret = clientSecret; ProviderBackend.updateProvider(owner, providerName, provider).then(() => { - getCaptchaFromBackend(); + setOpen(true); }); } else { - getCaptchaFromBackend(); - } - }; - - const renderDefaultCaptcha = () => { - return ( - - - - } - placeholder={i18next.t("general:Captcha")} - onPressEnter={handleOk} - onChange={(e) => setCaptchaToken(e.target.value)} - /> - - - ); - }; - - const onSubmit = (token) => { - setCaptchaToken(token); - }; - - const renderCheck = () => { - if (captchaType === "Default") { - return renderDefaultCaptcha(); - } else { - return ( - - - - - - ); + setOpen(true); } }; @@ -146,6 +63,16 @@ export const CaptchaPreview = ({ return false; }; + const onOk = (captchaType, captchaToken, secret) => { + UserBackend.verifyCaptcha(captchaType, captchaToken, secret).then(() => { + setOpen(false); + }); + }; + + const onCancel = () => { + setOpen(false); + }; + return ( - - {renderCheck()} - + ); }; diff --git a/web/src/locales/de/data.json b/web/src/locales/de/data.json index 31ba30ea..0327498d 100644 --- a/web/src/locales/de/data.json +++ b/web/src/locales/de/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Token läuft ab - Tooltip", "Token format": "Token-Format", "Token format - Tooltip": "Token-Format - Tooltip", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Bitgröße", diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index cce30742..f825219d 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Token expire - Tooltip", "Token format": "Token format", "Token format - Tooltip": "Token format - Tooltip", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Bit size", diff --git a/web/src/locales/es/data.json b/web/src/locales/es/data.json index 981acf92..3b4df0a1 100644 --- a/web/src/locales/es/data.json +++ b/web/src/locales/es/data.json @@ -45,7 +45,9 @@ "Token expire - Tooltip": "Expiración del Token - Tooltip", "Token format": "Formato del Token", "Token format - Tooltip": "Formato del Token - Tooltip", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Tamaño del Bit", diff --git a/web/src/locales/fr/data.json b/web/src/locales/fr/data.json index c8411e23..6477551e 100644 --- a/web/src/locales/fr/data.json +++ b/web/src/locales/fr/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Expiration du jeton - Info-bulle", "Token format": "Format du jeton", "Token format - Tooltip": "Format du jeton - infobulle", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Taille du bit", diff --git a/web/src/locales/ja/data.json b/web/src/locales/ja/data.json index 1317c87f..52a949e4 100644 --- a/web/src/locales/ja/data.json +++ b/web/src/locales/ja/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "トークンの有効期限 - ツールチップ", "Token format": "トークンのフォーマット", "Token format - Tooltip": "トークンフォーマット - ツールチップ", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "ビットサイズ", diff --git a/web/src/locales/ko/data.json b/web/src/locales/ko/data.json index 8e240f68..00edabba 100644 --- a/web/src/locales/ko/data.json +++ b/web/src/locales/ko/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Token expire - Tooltip", "Token format": "Token format", "Token format - Tooltip": "Token format - Tooltip", - "rule": "rule" + "Rule": "Rule", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Bit size", diff --git a/web/src/locales/ru/data.json b/web/src/locales/ru/data.json index 3f1913ce..fb2d4362 100644 --- a/web/src/locales/ru/data.json +++ b/web/src/locales/ru/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Истек токен - Подсказка", "Token format": "Формат токена", "Token format - Tooltip": "Формат токена - Подсказка", - "rule": "правило" + "Rule": "правило", + "None": "None", + "Always": "Always" }, "cert": { "Bit size": "Размер бита", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 0fd68d01..568752a1 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -68,7 +68,9 @@ "Token expire - Tooltip": "Access Token过期时间", "Token format": "Access Token格式", "Token format - Tooltip": "Access Token格式", - "rule": "规则" + "Rule": "规则", + "None": "关闭", + "Always": "始终开启" }, "cert": { "Bit size": "位大小",