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": "位大小",