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 ( +
+ +
+ ); + }, + }, + ]; + + return ( + ( +
+ {this.props.title}     + +
+ )} + /> + ); + } + + render() { + return ( +
+ +
+ { + this.renderTable(this.props.table) + } + + + + ); + } +} + +export default MfaTable;