mirror of
https://github.com/casdoor/casdoor.git
synced 2025-05-23 02:35:49 +08:00
feat: support forced binding MFA after login (#1845)
This commit is contained in:
parent
0b5ecca5c8
commit
9092cad631
@ -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
|
||||
|
@ -51,6 +51,7 @@ const (
|
||||
const (
|
||||
MfaSessionUserId = "MfaSessionUserId"
|
||||
NextMfa = "NextMfa"
|
||||
RequiredMfa = "RequiredMfa"
|
||||
)
|
||||
|
||||
func GetMfaUtil(providerType string, config *MfaProps) MfaInterface {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("general:MFA items"), i18next.t("general:MFA items - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<MfaTable
|
||||
title={i18next.t("general:MFA items")}
|
||||
table={this.state.organization.mfaItems ?? []}
|
||||
onUpdateTable={(value) => {this.updateOrganizationField("mfaItems", value);}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("theme:Theme"), i18next.t("theme:Theme - Tooltip"))} :
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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 = "";
|
||||
|
@ -97,9 +97,9 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
|
||||
});
|
||||
};
|
||||
|
||||
if (mfaProps.type === SmsMfaType) {
|
||||
if (mfaProps?.type === SmsMfaType) {
|
||||
return <MfaSmsVerifyForm onFinish={onFinish} application={application} />;
|
||||
} else if (mfaProps.type === TotpMfaType) {
|
||||
} else if (mfaProps?.type === TotpMfaType) {
|
||||
return <MfaTotpVerifyForm onFinish={onFinish} mfaProps={mfaProps} />;
|
||||
} else {
|
||||
return <div></div>;
|
||||
@ -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 <CheckPasswordForm
|
||||
user={this.getUser()}
|
||||
onSuccess={() => {
|
||||
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 <MfaVerifyForm
|
||||
mfaProps={{...this.state.mfaProps}}
|
||||
mfaProps={this.state.mfaProps}
|
||||
application={this.state.application}
|
||||
user={this.getUser()}
|
||||
onSuccess={() => {
|
||||
@ -214,10 +230,18 @@ class MfaSetupPage extends React.Component {
|
||||
}}
|
||||
/>;
|
||||
case 2:
|
||||
if (!this.state.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EnableMfaForm user={this.getUser()} mfaProps={{type: this.state.type, ...this.state.mfaProps}}
|
||||
onSuccess={() => {
|
||||
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 {
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={24} style={{display: "flex", justifyContent: "center"}}>
|
||||
<div style={{marginTop: "10px", textAlign: "center"}}>{this.renderStep()}</div>
|
||||
<div style={{marginTop: "10px", textAlign: "center"}}>
|
||||
{this.renderStep()}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
@ -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)}
|
||||
<div style={{marginTop: "50px"}}>
|
||||
<Button disabled={!Setting.isPromptAnswered(this.state.user, application)} type="primary" size="large" onClick={() => {this.submitUserEdit(true);}}>{i18next.t("code:Submit and complete")}</Button>
|
||||
</div>;
|
||||
</>;
|
||||
}
|
||||
|
||||
renderPromptMfa() {
|
||||
return <MfaSetupPage
|
||||
applicationName={this.getApplicationObj().name}
|
||||
account={this.props.account}
|
||||
current={1}
|
||||
isAuthenticated={true}
|
||||
isPromptPage={true}
|
||||
redirectUri={this.getRedirectUrl()}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const application = this.getApplicationObj();
|
||||
if (application === null) {
|
||||
@ -259,12 +282,7 @@ class PromptPage extends React.Component {
|
||||
{
|
||||
Setting.renderLogo(application)
|
||||
}
|
||||
{
|
||||
this.renderContent(application)
|
||||
}
|
||||
<div style={{marginTop: "50px"}}>
|
||||
<Button disabled={!Setting.isPromptAnswered(this.state.user, application)} type="primary" size="large" onClick={() => {this.submitUserEdit(true);}}>{i18next.t("code:Submit and complete")}</Button>
|
||||
</div>
|
||||
{this.state.promptType !== "mfa" ? this.renderPromptProvider(application) : this.renderPromptMfa(application)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
160
web/src/table/MfaTable.js
Normal file
160
web/src/table/MfaTable.js
Normal file
@ -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 (
|
||||
<Select virtual={false} style={{width: "100%"}}
|
||||
value={text}
|
||||
onChange={value => {
|
||||
this.updateField(table, index, "name", value);
|
||||
}} >
|
||||
{
|
||||
Setting.getDeduplicatedArray(MfaItems, table, "name").map((item, index) => <Option key={index} value={item.name}>{item.name}</Option>)
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("application:Rule"),
|
||||
dataIndex: "rule",
|
||||
key: "rule",
|
||||
width: "100px",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Select virtual={false} style={{width: "100%"}}
|
||||
value={text}
|
||||
defaultValue="Optional"
|
||||
options={[
|
||||
{value: "Optional", label: i18next.t("organization:Optional")},
|
||||
{value: "Required", label: i18next.t("organization:Required")}].map((item) =>
|
||||
Setting.getOption(item.label, item.value))
|
||||
}
|
||||
onChange={value => {
|
||||
this.updateField(table, index, "rule", value);
|
||||
}} >
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
key: "action",
|
||||
width: "100px",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
|
||||
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
|
||||
</Tooltip>
|
||||
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
|
||||
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
|
||||
</Tooltip>
|
||||
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
|
||||
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table scroll={{x: "max-content"}} rowKey="name" columns={columns} dataSource={table} size="middle" bordered pagination={false}
|
||||
title={() => (
|
||||
<div>
|
||||
{this.props.title}
|
||||
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col span={24}>
|
||||
{
|
||||
this.renderTable(this.props.table)
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MfaTable;
|
Loading…
x
Reference in New Issue
Block a user