feat: add "Face ID" login method (#2782)

Face Login via face-api.js
This commit is contained in:
HGZ-20
2024-03-16 09:04:00 +08:00
committed by GitHub
parent 57431a59ad
commit 391a533ce1
16 changed files with 506 additions and 7 deletions

View File

@ -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) => {

View File

@ -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) {

View File

@ -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>
);
}
}

View File

@ -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) => {

View 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;

View File

@ -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")},
];
};

View 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")}&nbsp;&nbsp;&nbsp;&nbsp;
<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;

View File

@ -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 = [
{