diff --git a/controllers/auth.go b/controllers/auth.go
index ba510e6c..eeab553c 100644
--- a/controllers/auth.go
+++ b/controllers/auth.go
@@ -312,6 +312,11 @@ func (c *ApiController) Login() {
resp = c.HandleLoggedIn(application, user, &authForm)
+ organization := object.GetOrganizationByUser(user)
+ if user != nil && organization.HasRequiredMfa() && !user.IsMfaEnabled() {
+ resp.Msg = object.RequiredMfa
+ }
+
record := object.NewRecord(c.Ctx)
record.Organization = application.Organization
record.User = user.Name
diff --git a/object/mfa.go b/object/mfa.go
index c26c9509..98759ec5 100644
--- a/object/mfa.go
+++ b/object/mfa.go
@@ -51,6 +51,7 @@ const (
const (
MfaSessionUserId = "MfaSessionUserId"
NextMfa = "NextMfa"
+ RequiredMfa = "RequiredMfa"
)
func GetMfaUtil(providerType string, config *MfaProps) MfaInterface {
diff --git a/object/organization.go b/object/organization.go
index 4721c3bf..12224e04 100644
--- a/object/organization.go
+++ b/object/organization.go
@@ -38,6 +38,11 @@ type ThemeData struct {
IsEnabled bool `xorm:"bool" json:"isEnabled"`
}
+type MfaItem struct {
+ Name string `json:"name"`
+ Rule string `json:"rule"`
+}
+
type Organization struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
@@ -59,6 +64,7 @@ type Organization struct {
EnableSoftDeletion bool `json:"enableSoftDeletion"`
IsProfilePublic bool `json:"isProfilePublic"`
+ MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
AccountItems []*AccountItem `xorm:"varchar(3000)" json:"accountItems"`
}
@@ -408,3 +414,12 @@ func organizationChangeTrigger(oldName string, newName string) error {
return session.Commit()
}
+
+func (org *Organization) HasRequiredMfa() bool {
+ for _, item := range org.MfaItems {
+ if item.Rule == "Required" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/web/src/OrganizationEditPage.js b/web/src/OrganizationEditPage.js
index fd2a985c..68cb0655 100644
--- a/web/src/OrganizationEditPage.js
+++ b/web/src/OrganizationEditPage.js
@@ -24,6 +24,7 @@ import {LinkOutlined} from "@ant-design/icons";
import LdapTable from "./table/LdapTable";
import AccountTable from "./table/AccountTable";
import ThemeEditor from "./common/theme/ThemeEditor";
+import MfaTable from "./table/MfaTable";
const {Option} = Select;
@@ -316,6 +317,18 @@ class OrganizationEditPage extends React.Component {
/>
+
+
+ {Setting.getLabel(i18next.t("general:MFA items"), i18next.t("general:MFA items - Tooltip"))} :
+
+
+ {this.updateOrganizationField("mfaItems", value);}}
+ />
+
+
{Setting.getLabel(i18next.t("theme:Theme"), i18next.t("theme:Theme - Tooltip"))} :
diff --git a/web/src/auth/AuthBackend.js b/web/src/auth/AuthBackend.js
index 9c484348..fe40684c 100644
--- a/web/src/auth/AuthBackend.js
+++ b/web/src/auth/AuthBackend.js
@@ -15,7 +15,7 @@
import {authConfig} from "./Auth";
import * as Setting from "../Setting";
-export function getAccount(query) {
+export function getAccount(query = "") {
return fetch(`${authConfig.serverUrl}/api/get-account${query}`, {
method: "GET",
credentials: "include",
diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js
index ff66710e..901c99b3 100644
--- a/web/src/auth/LoginPage.js
+++ b/web/src/auth/LoginPage.js
@@ -33,7 +33,7 @@ import LanguageSelect from "../common/select/LanguageSelect";
import {CaptchaModal} from "../common/modal/CaptchaModal";
import {CaptchaRule} from "../common/modal/CaptchaModal";
import RedirectForm from "../common/RedirectForm";
-import {MfaAuthVerifyForm, NextMfa} from "./MfaAuthVerifyForm";
+import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./MfaAuthVerifyForm";
class LoginPage extends React.Component {
constructor(props) {
@@ -224,23 +224,26 @@ class LoginPage extends React.Component {
}
}
- postCodeLoginAction(res) {
+ postCodeLoginAction(resp) {
const application = this.getApplicationObj();
const ths = this;
const oAuthParams = Util.getOAuthGetParameters();
- const code = res.data;
+ const code = resp.data;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
const noRedirect = oAuthParams.noRedirect;
- if (Setting.hasPromptPage(application)) {
- AuthBackend.getAccount("")
- .then((res) => {
- let account = null;
- if (res.status === "ok") {
- account = res.data;
- account.organization = res.data2;
+ if (Setting.hasPromptPage(application) || resp.msg === RequiredMfa) {
+ AuthBackend.getAccount()
+ .then((res) => {
+ if (res.status === "ok") {
+ const account = res.data;
+ account.organization = res.data2;
this.onUpdateAccount(account);
+ if (resp.msg === RequiredMfa) {
+ Setting.goToLink(`/prompt/${application.name}?redirectUri=${oAuthParams.redirectUri}&code=${code}&state=${oAuthParams.state}&promptType=mfa`);
+ }
+
if (Setting.isPromptAnswered(account, application)) {
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`);
} else {
@@ -328,10 +331,20 @@ class LoginPage extends React.Component {
const responseType = values["type"];
if (responseType === "login") {
- Setting.showMessage("success", i18next.t("application:Logged in successfully"));
-
- const link = Setting.getFromLink();
- Setting.goToLink(link);
+ if (res.msg === RequiredMfa) {
+ AuthBackend.getAccount().then((res) => {
+ if (res.status === "ok") {
+ const account = res.data;
+ account.organization = res.data2;
+ this.onUpdateAccount(account);
+ }
+ });
+ Setting.goToLink(`/prompt/${this.getApplicationObj().name}?promptType=mfa`);
+ } else {
+ Setting.showMessage("success", i18next.t("application:Logged in successfully"));
+ const link = Setting.getFromLink();
+ Setting.goToLink(link);
+ }
} else if (responseType === "code") {
this.postCodeLoginAction(res);
} else if (responseType === "token" || responseType === "id_token") {
@@ -352,6 +365,7 @@ class LoginPage extends React.Component {
}
}
};
+
if (res.status === "ok") {
callback(res);
} else if (res.status === NextMfa) {
diff --git a/web/src/auth/MfaAuthVerifyForm.js b/web/src/auth/MfaAuthVerifyForm.js
index bbfb36a5..e2808a14 100644
--- a/web/src/auth/MfaAuthVerifyForm.js
+++ b/web/src/auth/MfaAuthVerifyForm.js
@@ -20,6 +20,7 @@ import {SmsMfaType} from "./MfaSetupPage";
import {MfaSmsVerifyForm} from "./MfaVerifyForm";
export const NextMfa = "NextMfa";
+export const RequiredMfa = "RequiredMfa";
export function MfaAuthVerifyForm({formValues, oAuthParams, mfaProps, application, onSuccess, onFail}) {
formValues.password = "";
diff --git a/web/src/auth/MfaSetupPage.js b/web/src/auth/MfaSetupPage.js
index c7fd6a08..44845f7a 100644
--- a/web/src/auth/MfaSetupPage.js
+++ b/web/src/auth/MfaSetupPage.js
@@ -97,9 +97,9 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
});
};
- if (mfaProps.type === SmsMfaType) {
+ if (mfaProps?.type === SmsMfaType) {
return ;
- } else if (mfaProps.type === TotpMfaType) {
+ } else if (mfaProps?.type === TotpMfaType) {
return ;
} else {
return ;
@@ -145,7 +145,11 @@ class MfaSetupPage extends React.Component {
super(props);
this.state = {
account: props.account,
- current: 0,
+ applicationName: (props.applicationName ?? props.account?.signupApplication) ?? "",
+ isAuthenticated: props.isAuthenticated ?? false,
+ isPromptPage: props.isPromptPage,
+ redirectUri: props.redirectUri,
+ current: props.current ?? 0,
type: props.type ?? SmsMfaType,
mfaProps: null,
};
@@ -155,8 +159,25 @@ class MfaSetupPage extends React.Component {
this.getApplication();
}
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ if (prevState.isAuthenticated === true && this.state.mfaProps === null) {
+ MfaBackend.MfaSetupInitiate({
+ type: this.state.type,
+ ...this.getUser(),
+ }).then((res) => {
+ if (res.status === "ok") {
+ this.setState({
+ mfaProps: res.data,
+ });
+ } else {
+ Setting.showMessage("error", i18next.t("mfa:Failed to initiate MFA"));
+ }
+ });
+ }
+ }
+
getApplication() {
- ApplicationBackend.getApplication("admin", this.state.account.signupApplication)
+ ApplicationBackend.getApplication("admin", this.state.applicationName)
.then((application) => {
if (application !== null) {
this.setState({
@@ -181,18 +202,9 @@ class MfaSetupPage extends React.Component {
return {
- MfaBackend.MfaSetupInitiate({
- type: this.state.type,
- ...this.getUser(),
- }).then((res) => {
- if (res.status === "ok") {
- this.setState({
- current: this.state.current + 1,
- mfaProps: res.data,
- });
- } else {
- Setting.showMessage("error", i18next.t("mfa:Failed to initiate MFA"));
- }
+ this.setState({
+ current: this.state.current + 1,
+ isAuthenticated: true,
});
}}
onFail={(res) => {
@@ -200,8 +212,12 @@ class MfaSetupPage extends React.Component {
}}
/>;
case 1:
+ if (!this.state.isAuthenticated) {
+ return null;
+ }
+
return {
@@ -214,10 +230,18 @@ class MfaSetupPage extends React.Component {
}}
/>;
case 2:
+ if (!this.state.isAuthenticated) {
+ return null;
+ }
+
return {
Setting.showMessage("success", i18next.t("general:Enabled successfully"));
- Setting.goToLinkSoft(this, "/account");
+ if (this.state.isPromptPage && this.state.redirectUri) {
+ Setting.goToLink(this.state.redirectUri);
+ } else {
+ Setting.goToLink("/account");
+ }
}}
onFail={(res) => {
Setting.showMessage("error", `${i18next.t("general:Failed to enable")}: ${res.msg}`);
@@ -265,7 +289,9 @@ class MfaSetupPage extends React.Component {
- {this.renderStep()}
+
+ {this.renderStep()}
+
);
diff --git a/web/src/auth/PromptPage.js b/web/src/auth/PromptPage.js
index 128efcdf..5528a75c 100644
--- a/web/src/auth/PromptPage.js
+++ b/web/src/auth/PromptPage.js
@@ -23,16 +23,19 @@ import AffiliationSelect from "../common/select/AffiliationSelect";
import OAuthWidget from "../common/OAuthWidget";
import RegionSelect from "../common/select/RegionSelect";
import {withRouter} from "react-router-dom";
+import MfaSetupPage from "./MfaSetupPage";
class PromptPage extends React.Component {
constructor(props) {
super(props);
+ const params = new URLSearchParams(this.props.location.search);
this.state = {
classes: props,
type: props.type,
applicationName: props.applicationName ?? (props.match === undefined ? null : props.match.params.applicationName),
application: null,
user: null,
+ promptType: params.get("promptType"),
};
}
@@ -225,6 +228,26 @@ class PromptPage extends React.Component {
});
}
+ renderPromptProvider(application) {
+ return <>
+ {this.renderContent(application)}
+
+
+
;
+ >;
+ }
+
+ renderPromptMfa() {
+ return ;
+ }
+
render() {
const application = this.getApplicationObj();
if (application === null) {
@@ -259,12 +282,7 @@ class PromptPage extends React.Component {
{
Setting.renderLogo(application)
}
- {
- this.renderContent(application)
- }
-
-
-
+ {this.state.promptType !== "mfa" ? this.renderPromptProvider(application) : this.renderPromptMfa(application)}
diff --git a/web/src/table/MfaTable.js b/web/src/table/MfaTable.js
new file mode 100644
index 00000000..1ae60339
--- /dev/null
+++ b/web/src/table/MfaTable.js
@@ -0,0 +1,160 @@
+// Copyright 2023 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 from "react";
+import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
+import {Button, Col, Row, Select, Table, Tooltip} from "antd";
+import * as Setting from "../Setting";
+import i18next from "i18next";
+
+const {Option} = Select;
+
+const MfaItems = [
+ {name: "Phone"},
+ {name: "Email"},
+];
+
+class MfaTable extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ classes: props,
+ };
+ }
+
+ updateTable(table) {
+ this.props.onUpdateTable(table);
+ }
+
+ updateField(table, index, key, value) {
+ table[index][key] = value;
+ this.updateTable(table);
+ }
+
+ addRow(table) {
+ const row = {name: Setting.getNewRowNameForTable(table, "Please select a MFA method"), rule: "Optional"};
+ if (table === undefined) {
+ table = [];
+ }
+ table = Setting.addRow(table, row);
+ this.updateTable(table);
+ }
+
+ deleteRow(table, i) {
+ table = Setting.deleteRow(table, i);
+ this.updateTable(table);
+ }
+
+ upRow(table, i) {
+ table = Setting.swapRow(table, i - 1, i);
+ this.updateTable(table);
+ }
+
+ downRow(table, i) {
+ table = Setting.swapRow(table, i, i + 1);
+ this.updateTable(table);
+ }
+
+ renderTable(table) {
+ const columns = [
+ {
+ title: i18next.t("general:Name"),
+ dataIndex: "name",
+ key: "name",
+ render: (text, record, index) => {
+ return (
+
+ );
+ },
+ },
+ {
+ title: i18next.t("application:Rule"),
+ dataIndex: "rule",
+ key: "rule",
+ width: "100px",
+ render: (text, record, index) => {
+ return (
+
+ );
+ },
+ },
+ {
+ title: i18next.t("general:Action"),
+ key: "action",
+ width: "100px",
+ render: (text, record, index) => {
+ return (
+
+
+ } size="small" onClick={() => this.upRow(table, index)} />
+
+
+ } size="small" onClick={() => this.downRow(table, index)} />
+
+
+ } size="small" onClick={() => this.deleteRow(table, index)} />
+
+
+ );
+ },
+ },
+ ];
+
+ return (
+ (
+
+ {this.props.title}
+
+
+ )}
+ />
+ );
+ }
+
+ render() {
+ return (
+
+
+
+ {
+ this.renderTable(this.props.table)
+ }
+
+
+
+ );
+ }
+}
+
+export default MfaTable;