188 lines
7.3 KiB
JavaScript
188 lines
7.3 KiB
JavaScript
|
|
/**
|
||
|
|
* POST /v1/authenticate — Autentisering/bedrägeri
|
||
|
|
* Kombinerar face verification + liveness + risk scoring
|
||
|
|
*/
|
||
|
|
import { Router } from 'express';
|
||
|
|
import * as ort from 'onnxruntime-node';
|
||
|
|
import sharp from 'sharp';
|
||
|
|
import { fetchImage, hashInput, saveResult, genReqId, requireAuth } from './utils.mjs';
|
||
|
|
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const FACE_DET_MODEL = '/opt/amos/data/kyc-service/models/face_detection_yunet_2023mar.onnx';
|
||
|
|
const LIVENESS_MODEL = '/opt/amos/data/kyc-service/models/2.7_80x80_MiniFASNetV2.onnx';
|
||
|
|
const FACE_REC_MODEL = '/opt/amos/data/kyc-service/models/face_recognition_sface_2021dec.onnx';
|
||
|
|
|
||
|
|
let detSession = null, liveSession = null, recSession = null;
|
||
|
|
async function getDetSession() {
|
||
|
|
if (!detSession) detSession = await ort.InferenceSession.create(FACE_DET_MODEL);
|
||
|
|
return detSession;
|
||
|
|
}
|
||
|
|
async function getLiveSession() {
|
||
|
|
if (!liveSession) liveSession = await ort.InferenceSession.create(LIVENESS_MODEL);
|
||
|
|
return liveSession;
|
||
|
|
}
|
||
|
|
async function getRecSession() {
|
||
|
|
if (!recSession) recSession = await ort.InferenceSession.create(FACE_REC_MODEL);
|
||
|
|
return recSession;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function detectFace(buffer) {
|
||
|
|
const raw = await sharp(buffer).resize(640, 640).raw().toBuffer({ resolveWithObject: true });
|
||
|
|
const { data, info } = raw;
|
||
|
|
const h = info.height, w = info.width;
|
||
|
|
const floatData = new Float32Array(1 * 3 * h * w);
|
||
|
|
for (let y = 0; y < h; y++) {
|
||
|
|
for (let x = 0; x < w; x++) {
|
||
|
|
const idx = (y * w + x) * 3;
|
||
|
|
floatData[0 * h * w + y * w + x] = data[idx] / 255.0;
|
||
|
|
floatData[1 * h * w + y * w + x] = data[idx + 1] / 255.0;
|
||
|
|
floatData[2 * h * w + y * w + x] = data[idx + 2] / 255.0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const tensor = new ort.Tensor('float32', floatData, [1, 3, h, w]);
|
||
|
|
const sess = await getDetSession();
|
||
|
|
const feeds = {}; feeds[sess.inputNames[0]] = tensor;
|
||
|
|
const out = await sess.run(feeds);
|
||
|
|
const outTensor = out[sess.outputNames[0]];
|
||
|
|
const outData = outTensor.data;
|
||
|
|
const dims = outTensor.dims;
|
||
|
|
const stride = dims[dims.length - 1];
|
||
|
|
let bestScore = 0, bestBox = null;
|
||
|
|
for (let i = 0; i < dims[0]; i++) {
|
||
|
|
const row = Array.from(outData.slice(i * stride, (i + 1) * stride));
|
||
|
|
if (row[2] > bestScore) { bestScore = row[2]; bestBox = row; }
|
||
|
|
}
|
||
|
|
if (!bestBox || bestScore < 0.5) return null;
|
||
|
|
return { box: bestBox, score: bestScore };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function checkLiveness(buffer, box) {
|
||
|
|
const orig = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
|
||
|
|
const ow = orig.info.width, oh = orig.info.height;
|
||
|
|
const x1 = Math.max(0, Math.round(box[3] * ow));
|
||
|
|
const y1 = Math.max(0, Math.round(box[4] * oh));
|
||
|
|
const x2 = Math.min(ow, Math.round(box[5] * ow));
|
||
|
|
const y2 = Math.min(oh, Math.round(box[6] * oh));
|
||
|
|
const faceBuf = await sharp(buffer)
|
||
|
|
.extract({ left: x1, top: y1, width: x2 - x1, height: y2 - y1 })
|
||
|
|
.resize(80, 80)
|
||
|
|
.raw()
|
||
|
|
.toBuffer();
|
||
|
|
const liveFloat = new Float32Array(1 * 3 * 80 * 80);
|
||
|
|
for (let i = 0; i < 80 * 80; i++) {
|
||
|
|
liveFloat[0 * 6400 + i] = faceBuf[i * 3] / 255.0;
|
||
|
|
liveFloat[1 * 6400 + i] = faceBuf[i * 3 + 1] / 255.0;
|
||
|
|
liveFloat[2 * 6400 + i] = faceBuf[i * 3 + 2] / 255.0;
|
||
|
|
}
|
||
|
|
const liveTensor = new ort.Tensor('float32', liveFloat, [1, 3, 80, 80]);
|
||
|
|
const liveSess = await getLiveSession();
|
||
|
|
const liveFeeds = {}; liveFeeds[liveSess.inputNames[0]] = liveTensor;
|
||
|
|
const liveOut = await liveSess.run(liveFeeds);
|
||
|
|
const liveData = liveOut[liveSess.outputNames[0]].data;
|
||
|
|
const realScore = liveData[0];
|
||
|
|
const fakeScore = liveData[1];
|
||
|
|
const score = realScore / (realScore + fakeScore + 1e-6);
|
||
|
|
return { score: parseFloat(score.toFixed(4)), label: score > 0.7 ? 'live' : score > 0.4 ? 'uncertain' : 'spoof' };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getEmbedding(buffer, box) {
|
||
|
|
const orig = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
|
||
|
|
const ow = orig.info.width, oh = orig.info.height;
|
||
|
|
const x1 = Math.max(0, Math.round(box[3] * ow));
|
||
|
|
const y1 = Math.max(0, Math.round(box[4] * oh));
|
||
|
|
const x2 = Math.min(ow, Math.round(box[5] * ow));
|
||
|
|
const y2 = Math.min(oh, Math.round(box[6] * oh));
|
||
|
|
const faceBuf = await sharp(buffer)
|
||
|
|
.extract({ left: x1, top: y1, width: x2 - x1, height: y2 - y1 })
|
||
|
|
.resize(112, 112)
|
||
|
|
.raw()
|
||
|
|
.toBuffer();
|
||
|
|
const floatData = new Float32Array(1 * 3 * 112 * 112);
|
||
|
|
for (let i = 0; i < 112 * 112; i++) {
|
||
|
|
floatData[0 * 12544 + i] = faceBuf[i * 3] / 255.0;
|
||
|
|
floatData[1 * 12544 + i] = faceBuf[i * 3 + 1] / 255.0;
|
||
|
|
floatData[2 * 12544 + i] = faceBuf[i * 3 + 2] / 255.0;
|
||
|
|
}
|
||
|
|
const tensor = new ort.Tensor('float32', floatData, [1, 3, 112, 112]);
|
||
|
|
const sess = await getRecSession();
|
||
|
|
const feeds = {}; feeds[sess.inputNames[0]] = tensor;
|
||
|
|
const out = await sess.run(feeds);
|
||
|
|
return out[sess.outputNames[0]].data;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cosineSimilarity(a, b) {
|
||
|
|
let dot = 0, na = 0, nb = 0;
|
||
|
|
for (let i = 0; i < a.length; i++) {
|
||
|
|
dot += a[i] * b[i];
|
||
|
|
na += a[i] * a[i];
|
||
|
|
nb += b[i] * b[i];
|
||
|
|
}
|
||
|
|
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-6);
|
||
|
|
}
|
||
|
|
|
||
|
|
router.post('/', requireAuth, async (req, res) => {
|
||
|
|
const requestId = genReqId();
|
||
|
|
const start = Date.now();
|
||
|
|
try {
|
||
|
|
const { image_url, image_base64, reference_image_url, reference_image_base64, user_id } = req.body || {};
|
||
|
|
const img = await fetchImage({ image_url, image_base64 });
|
||
|
|
const inputHash = hashInput(img.buffer);
|
||
|
|
|
||
|
|
// Step 1: Detect face
|
||
|
|
const face = await detectFace(img.buffer);
|
||
|
|
if (!face) {
|
||
|
|
return res.status(400).json({ ok: false, error: 'No face detected', request_id: requestId });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 2: Liveness check
|
||
|
|
const liveness = await checkLiveness(img.buffer, face.box);
|
||
|
|
|
||
|
|
// Step 3: Compare with reference if provided
|
||
|
|
let identityMatch = null;
|
||
|
|
if (reference_image_url || reference_image_base64) {
|
||
|
|
const refImg = await fetchImage({ image_url: reference_image_url, image_base64: reference_image_base64 });
|
||
|
|
const refFace = await detectFace(refImg.buffer);
|
||
|
|
if (refFace) {
|
||
|
|
const emb1 = await getEmbedding(img.buffer, face.box);
|
||
|
|
const emb2 = await getEmbedding(refImg.buffer, refFace.box);
|
||
|
|
const sim = cosineSimilarity(emb1, emb2);
|
||
|
|
identityMatch = { similarity: parseFloat(sim.toFixed(4)), match: sim > 0.6 };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 4: Risk scoring
|
||
|
|
let riskScore = 0;
|
||
|
|
if (liveness.label === 'spoof') riskScore += 0.8;
|
||
|
|
else if (liveness.label === 'uncertain') riskScore += 0.4;
|
||
|
|
if (identityMatch && !identityMatch.match) riskScore += 0.6;
|
||
|
|
if (face.score < 0.7) riskScore += 0.2;
|
||
|
|
riskScore = Math.min(1.0, riskScore);
|
||
|
|
|
||
|
|
const confidence = face.score * (liveness.score || 0.5);
|
||
|
|
const authenticated = liveness.label === 'live' && (!identityMatch || identityMatch.match);
|
||
|
|
|
||
|
|
const result = {
|
||
|
|
ok: true,
|
||
|
|
endpoint: 'authenticate',
|
||
|
|
request_id: requestId,
|
||
|
|
user_id: user_id || null,
|
||
|
|
authenticated,
|
||
|
|
face: { detected: true, confidence: parseFloat(face.score.toFixed(4)) },
|
||
|
|
liveness,
|
||
|
|
identity_match: identityMatch,
|
||
|
|
risk_score: parseFloat(riskScore.toFixed(4)),
|
||
|
|
risk_level: riskScore > 0.7 ? 'high' : riskScore > 0.3 ? 'medium' : 'low',
|
||
|
|
inference_time_ms: Date.now() - start,
|
||
|
|
};
|
||
|
|
|
||
|
|
await saveResult('authenticate', requestId, inputHash, result, confidence, { user_id, source: img.source });
|
||
|
|
res.json(result);
|
||
|
|
} catch (e) {
|
||
|
|
console.error('[authenticate]', e);
|
||
|
|
res.status(500).json({ ok: false, error: e.message, request_id: requestId });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
export default router;
|