feat: add local uniface face id support (#5588)

This commit is contained in:
angelwings
2026-06-17 22:04:30 +08:00
committed by Yang Luo
parent 8449a35796
commit 8af1a62ef4
14 changed files with 339 additions and 27 deletions
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 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.
package faceId
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type LocalUniFaceProvider struct {
Endpoint string
ApiKey string
Client *http.Client
}
type localUniFaceCompareRequest struct {
ImageA string `json:"imageA"`
ImageB string `json:"imageB"`
}
type localUniFaceCompareResponse struct {
Matched bool `json:"matched"`
Score float64 `json:"score"`
Reason string `json:"reason"`
Detail string `json:"detail"`
}
func NewLocalUniFaceProvider(endpoint string, apiKey string) *LocalUniFaceProvider {
return &LocalUniFaceProvider{
Endpoint: strings.TrimRight(endpoint, "/"),
ApiKey: apiKey,
Client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (provider *LocalUniFaceProvider) Check(base64ImageA string, base64ImageB string) (bool, error) {
if provider.Endpoint == "" {
return false, fmt.Errorf("Local UniFace endpoint is empty")
}
body, err := json.Marshal(localUniFaceCompareRequest{
ImageA: base64ImageA,
ImageB: base64ImageB,
})
if err != nil {
return false, err
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/compare", provider.Endpoint), bytes.NewReader(body))
if err != nil {
return false, err
}
request.Header.Set("Content-Type", "application/json")
if provider.ApiKey != "" {
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.ApiKey))
}
client := provider.Client
if client == nil {
client = http.DefaultClient
}
response, err := client.Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return false, err
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return false, fmt.Errorf("Local UniFace compare failed with status %d: %s", response.StatusCode, string(responseBody))
}
var compareResponse localUniFaceCompareResponse
if err = json.Unmarshal(responseBody, &compareResponse); err != nil {
return false, err
}
return compareResponse.Matched, nil
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 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.
package faceId
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestLocalUniFaceProviderCheckCallsCompareEndpoint(t *testing.T) {
var requestBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/compare" {
t.Fatalf("expected path /v1/compare, got %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("expected application/json content type, got %s", r.Header.Get("Content-Type"))
}
if r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("expected bearer token authorization, got %s", r.Header.Get("Authorization"))
}
err := json.NewDecoder(r.Body).Decode(&requestBody)
if err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"matched":true,"score":0.82,"threshold":0.6}`))
}))
defer server.Close()
provider := NewLocalUniFaceProvider(server.URL+"/", "secret")
matched, err := provider.Check("login-image", "registered-image")
if err != nil {
t.Fatal(err)
}
if !matched {
t.Fatal("expected match")
}
if requestBody["imageA"] != "login-image" {
t.Fatalf("expected imageA login-image, got %#v", requestBody["imageA"])
}
if requestBody["imageB"] != "registered-image" {
t.Fatalf("expected imageB registered-image, got %#v", requestBody["imageB"])
}
}
func TestLocalUniFaceProviderCheckReturnsFalseForNonMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"matched":false,"score":0.3,"threshold":0.6}`))
}))
defer server.Close()
provider := NewLocalUniFaceProvider(server.URL, "")
matched, err := provider.Check("login-image", "registered-image")
if err != nil {
t.Fatal(err)
}
if matched {
t.Fatal("expected non-match")
}
}
func TestLocalUniFaceProviderCheckReturnsErrorForServiceError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"detail":"no face detected"}`, http.StatusBadRequest)
}))
defer server.Close()
provider := NewLocalUniFaceProvider(server.URL, "")
_, err := provider.Check("login-image", "registered-image")
if err == nil {
t.Fatal("expected error")
}
}
+4
View File
@@ -19,5 +19,9 @@ type FaceIdProvider interface {
}
func GetFaceIdProvider(typ string, clientId string, clientSecret string, endPoint string) FaceIdProvider {
if typ == "Local UniFace" {
return NewLocalUniFaceProvider(endPoint, clientSecret)
}
return NewAliyunFaceIdProvider(clientId, clientSecret, endPoint)
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 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.
package faceId
import "testing"
func TestGetFaceIdProviderLocalUniFace(t *testing.T) {
provider := GetFaceIdProvider("Local UniFace", "", "secret", "http://127.0.0.1:8100")
localProvider, ok := provider.(*LocalUniFaceProvider)
if !ok {
t.Fatalf("expected *LocalUniFaceProvider, got %T", provider)
}
if localProvider.ApiKey != "secret" {
t.Fatalf("expected api key secret, got %s", localProvider.ApiKey)
}
}
func TestGetFaceIdProviderAlibabaCloudFacebody(t *testing.T) {
provider := GetFaceIdProvider("Alibaba Cloud Facebody", "accessKey", "accessSecret", "endpoint")
if _, ok := provider.(*AliyunFaceIdProvider); !ok {
t.Fatalf("expected *AliyunFaceIdProvider, got %T", provider)
}
}
+1 -1
View File
@@ -292,7 +292,7 @@ type Address struct {
type FaceId struct {
Name string `xorm:"varchar(100) notnull pk" json:"name"`
FaceIdData []float64 `json:"faceIdData"`
ImageUrl string `json:"ImageUrl"`
ImageUrl string `json:"imageUrl"`
}
func GetUserFieldStringValue(user *User, fieldName string) (bool, string, error) {
+30
View File
@@ -15,6 +15,7 @@
package object
import (
"encoding/json"
"fmt"
"reflect"
"strings"
@@ -33,6 +34,35 @@ func updateUserColumn(column string, user *User) bool {
return affected != 0
}
func TestFaceIdUsesLowerCamelImageUrlJsonField(t *testing.T) {
var faceId FaceId
err := json.Unmarshal([]byte(`{"name":"face","imageUrl":"http://example.com/face.jpg","faceIdData":[]}`), &faceId)
if err != nil {
t.Fatal(err)
}
if faceId.ImageUrl != "http://example.com/face.jpg" {
t.Fatalf("ImageUrl = %q, want %q", faceId.ImageUrl, "http://example.com/face.jpg")
}
data, err := json.Marshal(faceId)
if err != nil {
t.Fatal(err)
}
var fields map[string]interface{}
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["imageUrl"]; !ok {
t.Fatalf("marshaled FaceId does not contain imageUrl: %s", string(data))
}
if _, ok := fields["ImageUrl"]; ok {
t.Fatalf("marshaled FaceId unexpectedly contains ImageUrl: %s", string(data))
}
}
func TestSyncAvatarsFromGitHub(t *testing.T) {
InitConfig()
+1
View File
@@ -1050,6 +1050,7 @@ class ProviderEditPage extends React.Component {
{
(this.state.provider.category === "Storage" && this.state.provider.type === "Google Cloud Storage") ||
(this.state.provider.category === "Email" && (this.state.provider.type === "Azure ACS" || this.state.provider.type === "SendGrid" || this.state.provider.type === "Resend")) ||
(this.state.provider.category === "Face ID" && this.state.provider.type === "Local UniFace") ||
(this.state.provider.category === "Notification" && (this.state.provider.type === "Line" || this.state.provider.type === "Telegram" || this.state.provider.type === "Bark" || this.state.provider.type === "Discord" || this.state.provider.type === "Slack" || this.state.provider.type === "Pushbullet" || this.state.provider.type === "Pushover" || this.state.provider.type === "Lark" || this.state.provider.type === "Microsoft Teams" || this.state.provider.type === "WeCom")) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
+5
View File
@@ -442,6 +442,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_aliyun.png`,
url: "https://vision.aliyun.com/facebody",
},
"Local UniFace": {
logo: `${StaticBaseUrl}/img/social_default.png`,
url: "https://github.com/yakhyo/uniface",
},
},
"MFA": {
"RADIUS": {
@@ -1475,6 +1479,7 @@ export function getProviderTypeOptions(category) {
} else if (category === "Face ID") {
return ([
{id: "Alibaba Cloud Facebody", name: "Alibaba Cloud Facebody"},
{id: "Local UniFace", name: "Local UniFace"},
]);
} else if (category === "MFA") {
return ([
+2 -2
View File
@@ -951,9 +951,9 @@ class LoginPage extends React.Component {
}
{
this.state.loginMethod === "faceId" ?
this.state.haveFaceIdProvider ? <Suspense fallback={null}><FaceRecognitionCommonModal visible={this.state.openFaceRecognitionModal} onOk={(FaceIdImage) => {
this.state.haveFaceIdProvider ? <Suspense fallback={null}><FaceRecognitionCommonModal visible={this.state.openFaceRecognitionModal} onOk={(faceIdImage) => {
const values = this.state.values;
values["FaceIdImage"] = FaceIdImage;
values["faceIdImage"] = faceIdImage;
this.login(values);
this.setState({openFaceRecognitionModal: false});
}} onCancel={() => this.setState({openFaceRecognitionModal: false, loginLoading: false})} /></Suspense> :
+19 -6
View File
@@ -20,8 +20,12 @@ import i18next from "i18next";
import Dragger from "antd/es/upload/Dragger";
import * as Setting from "../../Setting";
// This modal has three modes controlled by props:
// withImage=false → camera mode: captures face descriptor array (faceIdData) for recognition login
// withImage=true → image-upload mode: user drags/drops a photo; camera is skipped
// withImage=true captureImage=true → camera-capture mode: captures a JPEG image URL for image-based enrollment
const FaceRecognitionModal = (props) => {
const {visible, onOk, onCancel, withImage} = props;
const {visible, onOk, onCancel, withImage, captureImage} = props;
const [modelsLoaded, setModelsLoaded] = React.useState(false);
const [isCameraCaptured, setIsCameraCaptured] = useState(false);
@@ -59,7 +63,7 @@ const FaceRecognitionModal = (props) => {
}, [visible, modelsLoaded]);
React.useEffect(() => {
if (withImage) {
if (withImage && !captureImage) {
return;
}
if (visible) {
@@ -87,7 +91,7 @@ const FaceRecognitionModal = (props) => {
}, [visible, modelsLoaded]);
React.useEffect(() => {
if (withImage) {
if (withImage && !captureImage) {
return;
}
if (isCameraCaptured) {
@@ -113,7 +117,7 @@ const FaceRecognitionModal = (props) => {
}, [isCameraCaptured]);
const handleStreamVideo = () => {
if (withImage) {
if (withImage && !captureImage) {
return;
}
let count = 0;
@@ -138,7 +142,16 @@ const FaceRecognitionModal = (props) => {
goodCount++;
if (face.detection.score > 0.99 || goodCount > 10) {
clearInterval(detection.current);
onOk(array);
if (captureImage) {
const canvas = document.createElement("canvas");
canvas.width = videoRef.current.videoWidth;
canvas.height = videoRef.current.videoHeight;
const context = canvas.getContext("2d");
context.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height);
onOk(canvas.toDataURL("image/jpeg", 0.92));
} else {
onOk(array);
}
}
}
} else {
@@ -175,7 +188,7 @@ const FaceRecognitionModal = (props) => {
});
};
if (!withImage) {
if (!withImage || captureImage) {
return (
<div>
<Modal
+1
View File
@@ -23,6 +23,7 @@
},
"application": {
"Add Face ID": "Add Face ID",
"Add Face ID with Camera": "Add Face ID with Camera",
"Add Face ID with Image": "Add Face ID with Image",
"Always": "Always",
"Array": "Array",
+1
View File
@@ -23,6 +23,7 @@
},
"application": {
"Add Face ID": "添加人脸ID",
"Add Face ID with Camera": "拍照添加人脸ID",
"Add Face ID with Image": "添加图片人脸ID",
"Always": "始终开启",
"Array": "数组",
+4 -2
View File
@@ -21,7 +21,9 @@ import i18next from "i18next";
export function renderFaceIdProviderFields(provider, updateProviderField) {
return (
<>
{["Alibaba Cloud Facebody"].includes(provider.type) ? null : (
{/* Only show the intranet endpoint field for provider types that use it.
Add new Face ID provider types here when they support intranet/extranet endpoint pairs. */}
{[].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint (Intranet)"), i18next.t("provider:Region endpoint for Intranet"))} :
@@ -32,7 +34,7 @@ export function renderFaceIdProviderFields(provider, updateProviderField) {
}} />
</Col>
</Row>
)}
) : null}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
+39 -16
View File
@@ -16,7 +16,7 @@ import React, {Suspense, lazy} from "react";
import {Button, Col, Input, Row, Table, Upload} from "antd";
import i18next from "i18next";
import * as Setting from "../Setting";
import {UploadOutlined} from "@ant-design/icons";
import {CameraOutlined, UploadOutlined} from "@ant-design/icons";
import * as ResourceBackend from "../backend/ResourceBackend";
const FaceRecognitionModal = lazy(() => import("../common/modal/FaceRecognitionModal"));
@@ -26,6 +26,7 @@ class FaceIdTable extends React.Component {
this.state = {
classes: props,
openFaceRecognitionModal: false,
openFaceImageCameraModal: false,
};
}
@@ -68,6 +69,29 @@ class FaceIdTable extends React.Component {
this.updateTable(table);
}
async dataUrlToFile(dataUrl, filename) {
const res = await fetch(dataUrl);
const blob = await res.blob();
return new File([blob], filename, {type: blob.type || "image/jpeg"});
}
uploadFaceImage(table, file, loadingField) {
this.setState({[loadingField]: true});
const filename = file.name;
const fullFilePath = `resource/${this.props.account.owner}/${this.props.account.name}/${filename}`;
ResourceBackend.uploadResource(this.props.account.owner, this.props.account.name, "custom", "ResourceListPage", fullFilePath, file)
.then(res => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("application:File uploaded successfully"));
this.addFaceImage(table, res.data);
} else {
Setting.showMessage("error", res.msg);
}
}).finally(() => {
this.setState({[loadingField]: false, openFaceImageCameraModal: false});
});
}
renderTable(table) {
const columns = [
{
@@ -116,21 +140,7 @@ class FaceIdTable extends React.Component {
];
const handleUpload = (info) => {
this.setState({uploading: true});
const filename = info.fileList[0].name;
const fullFilePath = `resource/${this.props.account.owner}/${this.props.account.name}/${filename}`;
ResourceBackend.uploadResource(this.props.account.owner, this.props.account.name, "custom", "ResourceListPage", fullFilePath, info.file)
.then(res => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("application:File uploaded successfully"));
this.addFaceImage(table, res.data);
} else {
Setting.showMessage("error", res.msg);
}
}).finally(() => {
this.setState({uploading: false});
});
this.uploadFaceImage(table, info.file, "uploading");
};
return (
@@ -144,6 +154,9 @@ class FaceIdTable extends React.Component {
<Button disabled={this.props.table?.length >= 5} style={{marginRight: "10px"}} size="small" onClick={() => this.setState({openFaceRecognitionModal: true, withImage: true})}>
{i18next.t("application:Add Face ID with Image")}
</Button>
<Button disabled={this.props.table?.length >= 5} style={{marginRight: "10px"}} icon={<CameraOutlined />} loading={this.state.uploadingCamera} size="small" onClick={() => this.setState({openFaceImageCameraModal: true})}>
{i18next.t("application:Add Face ID with Camera")}
</Button>
<Upload maxCount={1} accept="image/*" showUploadList={false}
beforeUpload={file => {return false;}} onChange={info => {handleUpload(info);}}>
<Button id="upload-button" icon={<UploadOutlined />} loading={this.state.uploading} size="small">
@@ -160,6 +173,16 @@ class FaceIdTable extends React.Component {
}}
onCancel={() => this.setState({openFaceRecognitionModal: false})}
/>
<FaceRecognitionModal
visible={this.state.openFaceImageCameraModal}
withImage={true}
captureImage={true}
onOk={async(imageDataUrl) => {
const file = await this.dataUrlToFile(imageDataUrl, `face-id-${Date.now()}.jpg`);
this.uploadFaceImage(table, file, "uploadingCamera");
}}
onCancel={() => this.setState({openFaceImageCameraModal: false})}
/>
</Suspense>
</div>
)}