mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-03 20:50:19 +08:00
@ -1081,7 +1081,7 @@ class ApplicationEditPage extends React.Component {
|
||||
submitApplicationEdit(exitAfterSave) {
|
||||
const application = Setting.deepCopy(this.state.application);
|
||||
application.providers = application.providers?.filter(provider => this.state.providers.map(provider => provider.name).includes(provider.name));
|
||||
application.signinMethods = application.signinMethods?.filter(signinMethod => ["Password", "Verification code", "WebAuthn", "LDAP"].includes(signinMethod.name));
|
||||
application.signinMethods = application.signinMethods?.filter(signinMethod => ["Password", "Verification code", "WebAuthn", "LDAP", "Face ID"].includes(signinMethod.name));
|
||||
|
||||
ApplicationBackend.updateApplication("admin", this.state.applicationName, application)
|
||||
.then((res) => {
|
||||
|
@ -1164,6 +1164,10 @@ export function isLdapEnabled(application) {
|
||||
return isSigninMethodEnabled(application, "LDAP");
|
||||
}
|
||||
|
||||
export function isFaceIdEnabled(application) {
|
||||
return isSigninMethodEnabled(application, "Face ID");
|
||||
}
|
||||
|
||||
export function getLoginLink(application) {
|
||||
let url;
|
||||
if (application === null) {
|
||||
|
@ -40,6 +40,7 @@ import {DeleteMfa} from "./backend/MfaBackend";
|
||||
import {CheckCircleOutlined, HolderOutlined, UsergroupAddOutlined} from "@ant-design/icons";
|
||||
import * as MfaBackend from "./backend/MfaBackend";
|
||||
import AccountAvatar from "./account/AccountAvatar";
|
||||
import FaceIdTable from "./table/FaceIdTable";
|
||||
|
||||
const {Option} = Select;
|
||||
|
||||
@ -59,6 +60,7 @@ class UserEditPage extends React.Component {
|
||||
loading: true,
|
||||
returnUrl: null,
|
||||
idCardInfo: ["ID card front", "ID card back", "ID card with person"],
|
||||
openFaceRecognitionModal: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -974,6 +976,21 @@ class UserEditPage extends React.Component {
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
} else if (accountItem.name === "Face ID") {
|
||||
return (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("user:Face ids"), i18next.t("user:Face ids"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<FaceIdTable
|
||||
title={i18next.t("user:Face ids")}
|
||||
table={this.state.user.faceIds}
|
||||
onUpdateTable={(table) => {this.updateUserField("faceIds", table);}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import React, {Suspense, lazy} from "react";
|
||||
import {Button, Checkbox, Col, Form, Input, Result, Spin, Tabs} from "antd";
|
||||
import {ArrowLeftOutlined, LockOutlined, UserOutlined} from "@ant-design/icons";
|
||||
import {withRouter} from "react-router-dom";
|
||||
@ -36,6 +36,7 @@ import {CaptchaModal, CaptchaRule} from "../common/modal/CaptchaModal";
|
||||
import RedirectForm from "../common/RedirectForm";
|
||||
import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./mfa/MfaAuthVerifyForm";
|
||||
import {GoogleOneTapLoginVirtualButton} from "./GoogleLoginButton";
|
||||
const FaceRecognitionModal = lazy(() => import("../common/modal/FaceRecognitionModal"));
|
||||
|
||||
class LoginPage extends React.Component {
|
||||
constructor(props) {
|
||||
@ -52,6 +53,7 @@ class LoginPage extends React.Component {
|
||||
validEmail: false,
|
||||
enableCaptchaModal: CaptchaRule.Never,
|
||||
openCaptchaModal: false,
|
||||
openFaceRecognitionModal: false,
|
||||
verifyCaptcha: undefined,
|
||||
samlResponse: "",
|
||||
relayState: "",
|
||||
@ -214,6 +216,7 @@ class LoginPage extends React.Component {
|
||||
}
|
||||
case "WebAuthn": return "webAuthn";
|
||||
case "LDAP": return "ldap";
|
||||
case "Face ID": return "faceId";
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,6 +266,8 @@ class LoginPage extends React.Component {
|
||||
values["signinMethod"] = "WebAuthn";
|
||||
} else if (this.state.loginMethod === "ldap") {
|
||||
values["signinMethod"] = "LDAP";
|
||||
} else if (this.state.loginMethod === "faceId") {
|
||||
values["signinMethod"] = "Face ID";
|
||||
}
|
||||
const oAuthParams = Util.getOAuthGetParameters();
|
||||
|
||||
@ -340,6 +345,13 @@ class LoginPage extends React.Component {
|
||||
this.signInWithWebAuthn(username, values);
|
||||
return;
|
||||
}
|
||||
if (this.state.loginMethod === "faceId") {
|
||||
this.setState({
|
||||
openFaceRecognitionModal: true,
|
||||
values: values,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.state.loginMethod === "password" || this.state.loginMethod === "ldap") {
|
||||
if (this.state.enableCaptchaModal === CaptchaRule.Always) {
|
||||
this.setState({
|
||||
@ -657,6 +669,25 @@ class LoginPage extends React.Component {
|
||||
i18next.t("login:Sign In")
|
||||
}
|
||||
</Button>
|
||||
{
|
||||
this.state.loginMethod === "faceId" ?
|
||||
<Suspense fallback={null}>
|
||||
<FaceRecognitionModal
|
||||
visible={this.state.openFaceRecognitionModal}
|
||||
onOk={(faceId) => {
|
||||
const values = this.state.values;
|
||||
values["faceId"] = faceId;
|
||||
|
||||
this.login(values);
|
||||
this.setState({openFaceRecognitionModal: false});
|
||||
}}
|
||||
onCancel={() => this.setState({openFaceRecognitionModal: false})}
|
||||
/>
|
||||
</Suspense>
|
||||
:
|
||||
<>
|
||||
</>
|
||||
}
|
||||
{
|
||||
this.renderCaptchaModal(application)
|
||||
}
|
||||
@ -721,7 +752,7 @@ class LoginPage extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
const showForm = Setting.isPasswordEnabled(application) || Setting.isCodeSigninEnabled(application) || Setting.isWebAuthnEnabled(application) || Setting.isLdapEnabled(application);
|
||||
const showForm = Setting.isPasswordEnabled(application) || Setting.isCodeSigninEnabled(application) || Setting.isWebAuthnEnabled(application) || Setting.isLdapEnabled(application) || Setting.isFaceIdEnabled(application);
|
||||
if (showForm) {
|
||||
let loginWidth = 320;
|
||||
if (Setting.getLanguage() === "fr") {
|
||||
@ -1029,6 +1060,7 @@ class LoginPage extends React.Component {
|
||||
[generateItemKey("Verification code", "Phone only"), {label: i18next.t("login:Verification code"), key: "verificationCodePhone"}],
|
||||
[generateItemKey("WebAuthn", "None"), {label: i18next.t("login:WebAuthn"), key: "webAuthn"}],
|
||||
[generateItemKey("LDAP", "None"), {label: i18next.t("login:LDAP"), key: "ldap"}],
|
||||
[generateItemKey("Face ID", "None"), {label: i18next.t("login:Face ID"), key: "faceId"}],
|
||||
]);
|
||||
|
||||
application?.signinMethods?.forEach((signinMethod) => {
|
||||
|
175
web/src/common/modal/FaceRecognitionModal.js
Normal file
175
web/src/common/modal/FaceRecognitionModal.js
Normal file
@ -0,0 +1,175 @@
|
||||
// Copyright 2024 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 * as faceapi from "face-api.js";
|
||||
import React, {useState} from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import {Button, Modal, Progress, Spin, message} from "antd";
|
||||
import i18next from "i18next";
|
||||
|
||||
const FaceRecognitionModal = (props) => {
|
||||
const {visible, onOk, onCancel} = props;
|
||||
const [modelsLoaded, setModelsLoaded] = React.useState(false);
|
||||
|
||||
const webcamRef = React.useRef();
|
||||
const canvasRef = React.useRef();
|
||||
const detection = React.useRef(null);
|
||||
const [percent, setPercent] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadModels = async() => {
|
||||
// const MODEL_URL = process.env.PUBLIC_URL + "/models";
|
||||
// const MODEL_URL = "https://justadudewhohacks.github.io/face-api.js/models";
|
||||
// const MODEL_URL = "https://cdn.casbin.org/site/casdoor/models";
|
||||
const MODEL_URL = "https://cdn.casdoor.com/casdoor/models";
|
||||
|
||||
Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
|
||||
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
|
||||
]).then((val) => {
|
||||
setModelsLoaded(true);
|
||||
}).catch((err) => {
|
||||
message.error(i18next.t("login:Model loading failure"));
|
||||
onCancel();
|
||||
});
|
||||
};
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setPercent(0);
|
||||
if (modelsLoaded && webcamRef.current?.video) {
|
||||
handleStreamVideo(null);
|
||||
}
|
||||
} else {
|
||||
clearInterval(detection.current);
|
||||
detection.current = null;
|
||||
}
|
||||
return () => {
|
||||
clearInterval(detection.current);
|
||||
detection.current = null;
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const handleStreamVideo = (e) => {
|
||||
let count = 0;
|
||||
let goodCount = 0;
|
||||
if (!detection.current) {
|
||||
detection.current = setInterval(async() => {
|
||||
if (modelsLoaded && webcamRef.current?.video && visible) {
|
||||
const faces = await faceapi.detectAllFaces(webcamRef.current.video, new faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceDescriptors();
|
||||
|
||||
count++;
|
||||
if (count % 50 === 0) {
|
||||
message.warning(i18next.t("login:Please ensure sufficient lighting and align your face in the center of the recognition box"));
|
||||
} else if (count > 300) {
|
||||
message.error(i18next.t("login:Face recognition failed"));
|
||||
onCancel();
|
||||
}
|
||||
if (faces.length === 1) {
|
||||
const face = faces[0];
|
||||
setPercent(Math.round(face.detection.score * 100));
|
||||
const array = Array.from(face.descriptor);
|
||||
if (face.detection.score > 0.9) {
|
||||
goodCount++;
|
||||
if (face.detection.score > 0.99 || goodCount > 10) {
|
||||
clearInterval(detection.current);
|
||||
onOk(array);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPercent(Math.round(percent / 2));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraError = () => {
|
||||
message.error(i18next.t("login:There was a problem accessing the WEBCAM. Grant permission and reload the page."));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
open={visible}
|
||||
title={i18next.t("login:Face Recognition")}
|
||||
width={350}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Progress percent={percent} />
|
||||
<div style={{marginTop: "20px", marginBottom: "50px", justifyContent: "center", alignContent: "center", position: "relative", flexDirection: "column"}}>
|
||||
{
|
||||
modelsLoaded ?
|
||||
<div style={{display: "flex", justifyContent: "center", alignContent: "center"}}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
videoConstraints={{facingMode: "user"}}
|
||||
onUserMedia={handleStreamVideo}
|
||||
onUserMediaError={handleCameraError}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
height: "220px",
|
||||
verticalAlign: "middle",
|
||||
width: "220px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
></Webcam>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
width: "240px",
|
||||
height: "240px",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}>
|
||||
<svg width="240" height="240" fill="none">
|
||||
<circle
|
||||
strokeDasharray="700"
|
||||
strokeDashoffset={700 - 6.9115 * percent}
|
||||
strokeWidth="4"
|
||||
cx="120"
|
||||
cy="120"
|
||||
r="110"
|
||||
stroke="#5734d3"
|
||||
transform="rotate(-90, 120, 120)"
|
||||
strokeLinecap="round"
|
||||
style={{transition: "all .2s linear"}}
|
||||
></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<canvas ref={canvasRef} style={{position: "absolute"}} />
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<Spin tip={i18next.t("login:Loading")} size="large" style={{display: "flex", justifyContent: "center", alignContent: "center"}}>
|
||||
<div className="content" />
|
||||
</Spin>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaceRecognitionModal;
|
@ -105,6 +105,7 @@ class AccountTable extends React.Component {
|
||||
{name: "Multi-factor authentication", label: i18next.t("user:Multi-factor authentication")},
|
||||
{name: "WebAuthn credentials", label: i18next.t("user:WebAuthn credentials")},
|
||||
{name: "Managed accounts", label: i18next.t("user:Managed accounts")},
|
||||
{name: "Face ID", label: i18next.t("user:Face ID")},
|
||||
];
|
||||
};
|
||||
|
||||
|
134
web/src/table/FaceIdTable.js
Normal file
134
web/src/table/FaceIdTable.js
Normal file
@ -0,0 +1,134 @@
|
||||
// Copyright 2024 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, {Suspense, lazy} from "react";
|
||||
import {Button, Col, Input, Row, Table} from "antd";
|
||||
import i18next from "i18next";
|
||||
import * as Setting from "../Setting";
|
||||
const FaceRecognitionModal = lazy(() => import("../common/modal/FaceRecognitionModal"));
|
||||
|
||||
class FaceIdTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
classes: props,
|
||||
openFaceRecognitionModal: false,
|
||||
};
|
||||
}
|
||||
|
||||
updateTable(table) {
|
||||
this.props.onUpdateTable(table);
|
||||
}
|
||||
|
||||
updateField(table, index, key, value) {
|
||||
table[index][key] = value;
|
||||
this.updateTable(table);
|
||||
}
|
||||
|
||||
deleteRow(table, i) {
|
||||
table = Setting.deleteRow(table, i);
|
||||
this.updateTable(table);
|
||||
}
|
||||
|
||||
addFaceId(table, faceIdData) {
|
||||
const faceId = {
|
||||
name: Setting.getRandomName(),
|
||||
faceIdData: faceIdData,
|
||||
};
|
||||
if (table === undefined || table === null) {
|
||||
table = [];
|
||||
}
|
||||
table = Setting.addRow(table, faceId);
|
||||
this.updateTable(table);
|
||||
}
|
||||
|
||||
renderTable(table) {
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("general:Name"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: "200px",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Input defaultValue={text} onChange={e => {
|
||||
this.updateField(table, index, "name", e.target.value);
|
||||
}} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:FaceIdData"),
|
||||
dataIndex: "faceIdData",
|
||||
key: "faceIdData",
|
||||
render: (text, record, index) => {
|
||||
const front = text.slice(0, 3).join(", ");
|
||||
const back = text.slice(-3).join(", ");
|
||||
return "[" + front + " ... " + back + "]";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
key: "action",
|
||||
width: "100px",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Button style={{marginTop: "5px", marginBottom: "5px", marginRight: "5px"}} type="primary" danger onClick={() => {this.deleteRow(table, index);}}>
|
||||
{i18next.t("general:Delete")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table scroll={{x: "max-content"}} columns={columns} dataSource={this.props.table} size="middle" bordered pagination={false}
|
||||
title={() => (
|
||||
<div>
|
||||
{i18next.t("user:Face ids")}
|
||||
<Button disabled={this.props.table?.length >= 5} style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.setState({openFaceRecognitionModal: true})}>
|
||||
{i18next.t("general:Add Face Id")}
|
||||
</Button>
|
||||
<Suspense fallback={null}>
|
||||
<FaceRecognitionModal
|
||||
visible={this.state.openFaceRecognitionModal}
|
||||
onOk={(faceIdData) => {
|
||||
this.addFaceId(table, faceIdData);
|
||||
this.setState({openFaceRecognitionModal: false});
|
||||
}}
|
||||
onCancel={() => this.setState({openFaceRecognitionModal: false})}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={24}>
|
||||
{
|
||||
this.renderTable(this.props.table)
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FaceIdTable;
|
@ -71,6 +71,7 @@ class SigninMethodTable extends React.Component {
|
||||
{name: "Verification code", displayName: i18next.t("login:Verification code")},
|
||||
{name: "WebAuthn", displayName: i18next.t("login:WebAuthn")},
|
||||
{name: "LDAP", displayName: i18next.t("login:LDAP")},
|
||||
{name: "Face ID", displayName: i18next.t("login:Face ID")},
|
||||
];
|
||||
const columns = [
|
||||
{
|
||||
|
Reference in New Issue
Block a user