django-camera-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_camera_kit/__init__.py +1 -0
- django_camera_kit/apps.py +7 -0
- django_camera_kit/kyc/__init__.py +0 -0
- django_camera_kit/kyc/apps.py +8 -0
- django_camera_kit/kyc/conf.py +24 -0
- django_camera_kit/kyc/embeddings.py +39 -0
- django_camera_kit/kyc/migrations/0001_initial.py +85 -0
- django_camera_kit/kyc/migrations/__init__.py +0 -0
- django_camera_kit/kyc/models.py +67 -0
- django_camera_kit/kyc/serializers.py +23 -0
- django_camera_kit/kyc/static/django_camera_kit/kyc/css/kyc_capture.css +59 -0
- django_camera_kit/kyc/static/django_camera_kit/kyc/js/face_kit.js +116 -0
- django_camera_kit/kyc/static/django_camera_kit/kyc/js/kyc_capture.js +347 -0
- django_camera_kit/kyc/static/django_camera_kit/kyc/vendor/face_detection_yunet_2023mar.onnx +0 -0
- django_camera_kit/kyc/templates/django_camera_kit/kyc/widgets/kyc_verification.html +7 -0
- django_camera_kit/kyc/throttling.py +20 -0
- django_camera_kit/kyc/urls.py +9 -0
- django_camera_kit/kyc/views.py +65 -0
- django_camera_kit/kyc/widgets.py +39 -0
- django_camera_kit/static/django_camera_kit/css/camera_kit.css +97 -0
- django_camera_kit/static/django_camera_kit/js/camera_kit.js +213 -0
- django_camera_kit/static/django_camera_kit/js/doc_scan.js +367 -0
- django_camera_kit/static/django_camera_kit/vendor/README.md +6 -0
- django_camera_kit/static/django_camera_kit/vendor/jspdf.umd.min.js +373 -0
- django_camera_kit/static/django_camera_kit/vendor/opencv.js +0 -0
- django_camera_kit/templates/django_camera_kit/widgets/document_scanner.html +6 -0
- django_camera_kit/widgets.py +25 -0
- django_camera_kit-0.1.0.dist-info/METADATA +129 -0
- django_camera_kit-0.1.0.dist-info/RECORD +32 -0
- django_camera_kit-0.1.0.dist-info/WHEEL +5 -0
- django_camera_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_camera_kit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
(function (window, document) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var ALIGN_STREAK = 8; // consecutive frames with a detected face before we start the liveness challenge
|
|
5
|
+
var MOVE_THRESHOLD_RATIO = 0.12; // horizontal head movement required, as a fraction of frame width
|
|
6
|
+
|
|
7
|
+
function getCookie(name) {
|
|
8
|
+
var match = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)");
|
|
9
|
+
return match ? decodeURIComponent(match[2]) : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function init() {
|
|
13
|
+
document.querySelectorAll(".camera-kit-kyc").forEach(function (container) {
|
|
14
|
+
if (container.dataset.camerakitBound) return;
|
|
15
|
+
container.dataset.camerakitBound = "true";
|
|
16
|
+
bindWidget(container);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bindWidget(container) {
|
|
21
|
+
var input = container.querySelector("input[type=hidden]");
|
|
22
|
+
var trigger = container.querySelector(".camera-kit-kyc-trigger");
|
|
23
|
+
var statusEl = container.querySelector(".camera-kit-kyc-status");
|
|
24
|
+
if (!input || !trigger) return;
|
|
25
|
+
|
|
26
|
+
trigger.addEventListener("click", function () {
|
|
27
|
+
openFlow(container, input, statusEl);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openFlow(container, input, statusEl) {
|
|
32
|
+
var verifyUrl = container.dataset.verifyUrl;
|
|
33
|
+
if (!verifyUrl) {
|
|
34
|
+
statusEl.textContent = "django-camera-kit: aucun verify_url configuré sur ce widget.";
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var session = {
|
|
39
|
+
stream: null,
|
|
40
|
+
phase: "align",
|
|
41
|
+
alignStreak: 0,
|
|
42
|
+
challengeStartX: null,
|
|
43
|
+
challengeMaxDelta: 0,
|
|
44
|
+
idBlob: null,
|
|
45
|
+
selfieBlob: null,
|
|
46
|
+
livenessPassed: false,
|
|
47
|
+
running: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
var overlay = buildOverlay();
|
|
51
|
+
document.body.appendChild(overlay.root);
|
|
52
|
+
|
|
53
|
+
FaceKit.ready(); // kick off model loading in the background, in parallel with camera permission
|
|
54
|
+
|
|
55
|
+
enterIdStep(session, overlay);
|
|
56
|
+
|
|
57
|
+
overlay.captureIdBtn.addEventListener("click", function () {
|
|
58
|
+
captureId(session, overlay);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
overlay.restartBtn.addEventListener("click", function () {
|
|
62
|
+
enterIdStep(session, overlay);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
overlay.manualSelfieBtn.addEventListener("click", function () {
|
|
66
|
+
captureSelfie(session, overlay, false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
overlay.retakeSelfieBtn.addEventListener("click", function () {
|
|
70
|
+
enterSelfieStep(session, overlay);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
overlay.sendBtn.addEventListener("click", function () {
|
|
74
|
+
submit(session, overlay, input, verifyUrl, statusEl);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
overlay.closeBtn.addEventListener("click", function () {
|
|
78
|
+
closeOverlay(session, overlay);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
overlay.cancelBtns.forEach(function (btn) {
|
|
82
|
+
btn.addEventListener("click", function () {
|
|
83
|
+
closeOverlay(session, overlay);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function enterIdStep(session, overlay) {
|
|
89
|
+
overlay.idVideoWrapper.appendChild(overlay.video);
|
|
90
|
+
showStep(overlay, "id");
|
|
91
|
+
switchCamera(session, overlay, "environment");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function enterSelfieStep(session, overlay) {
|
|
95
|
+
overlay.selfieVideoWrapper.appendChild(overlay.video);
|
|
96
|
+
session.phase = "align";
|
|
97
|
+
session.alignStreak = 0;
|
|
98
|
+
session.challengeStartX = null;
|
|
99
|
+
session.challengeMaxDelta = 0;
|
|
100
|
+
showStep(overlay, "selfie");
|
|
101
|
+
switchCamera(session, overlay, "user").then(function () {
|
|
102
|
+
trackSelfie(session, overlay);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function switchCamera(session, overlay, facingMode) {
|
|
107
|
+
CameraKit.stopStream(session.stream);
|
|
108
|
+
return CameraKit.openStream({ video: { facingMode: { ideal: facingMode } }, audio: false })
|
|
109
|
+
.then(function (stream) {
|
|
110
|
+
session.stream = stream;
|
|
111
|
+
overlay.video.srcObject = stream;
|
|
112
|
+
return overlay.video.play();
|
|
113
|
+
})
|
|
114
|
+
.catch(function (err) {
|
|
115
|
+
overlay.status.textContent = "Impossible d'accéder à la caméra : " + err.message;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function captureId(session, overlay) {
|
|
120
|
+
var frame = CameraKit.captureFrame(overlay.video);
|
|
121
|
+
CameraKit.canvasToBlob(frame).then(function (blob) {
|
|
122
|
+
session.idBlob = blob;
|
|
123
|
+
overlay.idPreview.src = URL.createObjectURL(blob);
|
|
124
|
+
enterSelfieStep(session, overlay);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function trackSelfie(session, overlay) {
|
|
129
|
+
if (!session.running || session.phase === "done") return;
|
|
130
|
+
|
|
131
|
+
if (overlay.video.readyState === overlay.video.HAVE_ENOUGH_DATA) {
|
|
132
|
+
var frame = CameraKit.captureFrame(overlay.video);
|
|
133
|
+
var detection = FaceKit.detectFace(frame);
|
|
134
|
+
updateMotionState(session, overlay, frame, detection);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
window.requestAnimationFrame(function () {
|
|
138
|
+
trackSelfie(session, overlay);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Liveness challenge: track how far the detected face's horizontal
|
|
143
|
+
// center moves once the user is asked to turn their head, instead of a
|
|
144
|
+
// blink. YuNet only returns 5 point landmarks (eye/nose/mouth centers),
|
|
145
|
+
// not full eyelid contours, so a real eye-aspect-ratio blink signal
|
|
146
|
+
// isn't available — head movement is a robust substitute that doesn't
|
|
147
|
+
// depend on eye-region precision at all.
|
|
148
|
+
function updateMotionState(session, overlay, frame, detection) {
|
|
149
|
+
if (!detection) {
|
|
150
|
+
overlay.hint.textContent = "Aucun visage détecté — centrez votre visage dans le cadre.";
|
|
151
|
+
session.alignStreak = 0;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (session.phase === "align") {
|
|
156
|
+
overlay.hint.textContent = "Restez immobile, centrez votre visage…";
|
|
157
|
+
session.alignStreak++;
|
|
158
|
+
if (session.alignStreak >= ALIGN_STREAK) {
|
|
159
|
+
session.phase = "challenge";
|
|
160
|
+
session.challengeStartX = detection.centerX;
|
|
161
|
+
session.challengeMaxDelta = 0;
|
|
162
|
+
overlay.hint.textContent = "Tournez légèrement la tête, à gauche ou à droite.";
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (session.phase === "challenge") {
|
|
168
|
+
var delta = Math.abs(detection.centerX - session.challengeStartX);
|
|
169
|
+
session.challengeMaxDelta = Math.max(session.challengeMaxDelta, delta);
|
|
170
|
+
|
|
171
|
+
if (session.challengeMaxDelta >= frame.width * MOVE_THRESHOLD_RATIO) {
|
|
172
|
+
session.phase = "done";
|
|
173
|
+
finishChallenge(session, overlay, frame, true);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function finishChallenge(session, overlay, frame, livenessPassed) {
|
|
179
|
+
session.livenessPassed = livenessPassed;
|
|
180
|
+
CameraKit.canvasToBlob(frame).then(function (blob) {
|
|
181
|
+
session.selfieBlob = blob;
|
|
182
|
+
overlay.selfiePreview.src = URL.createObjectURL(blob);
|
|
183
|
+
showStep(overlay, "review");
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function captureSelfie(session, overlay, livenessPassed) {
|
|
188
|
+
session.phase = "done";
|
|
189
|
+
var frame = CameraKit.captureFrame(overlay.video);
|
|
190
|
+
finishChallenge(session, overlay, frame, livenessPassed);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function submit(session, overlay, input, verifyUrl, statusEl) {
|
|
194
|
+
if (!session.idBlob || !session.selfieBlob) return;
|
|
195
|
+
|
|
196
|
+
overlay.sendBtn.disabled = true;
|
|
197
|
+
overlay.reviewStatus.textContent = "Vérification en cours…";
|
|
198
|
+
|
|
199
|
+
var formData = new FormData();
|
|
200
|
+
formData.append("id_document", session.idBlob, "id_document.jpg");
|
|
201
|
+
formData.append("selfie", session.selfieBlob, "selfie.jpg");
|
|
202
|
+
formData.append("liveness_passed", session.livenessPassed ? "true" : "false");
|
|
203
|
+
|
|
204
|
+
var csrfToken = getCookie("csrftoken");
|
|
205
|
+
|
|
206
|
+
fetch(verifyUrl, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: csrfToken ? { "X-CSRFToken": csrfToken } : {},
|
|
209
|
+
credentials: "same-origin",
|
|
210
|
+
body: formData,
|
|
211
|
+
})
|
|
212
|
+
.then(function (response) {
|
|
213
|
+
return response.json().then(function (data) {
|
|
214
|
+
return { ok: response.ok, data: data };
|
|
215
|
+
});
|
|
216
|
+
})
|
|
217
|
+
.then(function (result) {
|
|
218
|
+
overlay.sendBtn.disabled = false;
|
|
219
|
+
showResult(session, overlay, input, statusEl, result);
|
|
220
|
+
})
|
|
221
|
+
.catch(function (err) {
|
|
222
|
+
overlay.sendBtn.disabled = false;
|
|
223
|
+
overlay.reviewStatus.textContent = "Erreur réseau : " + err.message;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function showResult(session, overlay, input, statusEl, result) {
|
|
228
|
+
if (!result.ok) {
|
|
229
|
+
overlay.resultText.textContent = "Erreur : impossible de traiter la vérification.";
|
|
230
|
+
showStep(overlay, "result");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
var data = result.data;
|
|
235
|
+
if (data.status === "verified") {
|
|
236
|
+
overlay.resultText.textContent = "Identité vérifiée.";
|
|
237
|
+
input.value = data.id;
|
|
238
|
+
statusEl.textContent = "Vérifié";
|
|
239
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
240
|
+
} else {
|
|
241
|
+
overlay.resultText.textContent = "Vérification refusée — réessayez.";
|
|
242
|
+
statusEl.textContent = "Non vérifié";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
showStep(overlay, "result");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function closeOverlay(session, overlay) {
|
|
249
|
+
session.running = false;
|
|
250
|
+
CameraKit.stopStream(session.stream);
|
|
251
|
+
overlay.root.remove();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function showStep(overlay, step) {
|
|
255
|
+
overlay.root.querySelectorAll("[data-step]").forEach(function (el) {
|
|
256
|
+
el.hidden = el.dataset.step !== step;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildOverlay() {
|
|
261
|
+
var root = document.createElement("div");
|
|
262
|
+
root.className = "camera-kit-overlay";
|
|
263
|
+
root.innerHTML = [
|
|
264
|
+
'<div class="camera-kit-modal">',
|
|
265
|
+
' <div class="camera-kit-status"></div>',
|
|
266
|
+
|
|
267
|
+
' <div data-step="id" class="camera-kit-step">',
|
|
268
|
+
' <div class="camera-kit-video-wrapper camera-kit-kyc-id-wrapper">',
|
|
269
|
+
' <div class="camera-kit-kyc-id-guide"></div>',
|
|
270
|
+
" </div>",
|
|
271
|
+
' <div class="camera-kit-actions">',
|
|
272
|
+
' <button type="button" class="btn btn-secondary camera-kit-cancel">Annuler</button>',
|
|
273
|
+
' <button type="button" class="btn btn-primary camera-kit-capture-id">Capturer la pièce d’identité</button>',
|
|
274
|
+
" </div>",
|
|
275
|
+
" </div>",
|
|
276
|
+
|
|
277
|
+
' <div data-step="selfie" class="camera-kit-step" hidden>',
|
|
278
|
+
' <div class="camera-kit-video-wrapper camera-kit-kyc-selfie-wrapper">',
|
|
279
|
+
' <div class="camera-kit-kyc-face-guide"></div>',
|
|
280
|
+
" </div>",
|
|
281
|
+
' <p class="camera-kit-kyc-hint"></p>',
|
|
282
|
+
' <div class="camera-kit-actions">',
|
|
283
|
+
' <button type="button" class="btn btn-secondary camera-kit-cancel">Annuler</button>',
|
|
284
|
+
' <button type="button" class="btn btn-link camera-kit-kyc-manual-selfie">Ça ne marche pas, capturer manuellement</button>',
|
|
285
|
+
" </div>",
|
|
286
|
+
" </div>",
|
|
287
|
+
|
|
288
|
+
' <div data-step="review" class="camera-kit-step" hidden>',
|
|
289
|
+
' <div class="camera-kit-kyc-review">',
|
|
290
|
+
' <img class="camera-kit-kyc-id-preview" alt="Pièce d’identité capturée">',
|
|
291
|
+
' <img class="camera-kit-kyc-selfie-preview" alt="Selfie capturé">',
|
|
292
|
+
" </div>",
|
|
293
|
+
' <p class="camera-kit-kyc-review-status"></p>',
|
|
294
|
+
' <div class="camera-kit-actions">',
|
|
295
|
+
' <button type="button" class="btn btn-link camera-kit-kyc-restart">Recommencer tout</button>',
|
|
296
|
+
' <button type="button" class="btn btn-secondary camera-kit-kyc-retake-selfie">Recommencer le selfie</button>',
|
|
297
|
+
' <button type="button" class="btn btn-success camera-kit-kyc-send">Envoyer</button>',
|
|
298
|
+
" </div>",
|
|
299
|
+
" </div>",
|
|
300
|
+
|
|
301
|
+
' <div data-step="result" class="camera-kit-step" hidden>',
|
|
302
|
+
' <p class="camera-kit-kyc-result"></p>',
|
|
303
|
+
' <div class="camera-kit-actions">',
|
|
304
|
+
' <button type="button" class="btn btn-primary camera-kit-kyc-close">Fermer</button>',
|
|
305
|
+
" </div>",
|
|
306
|
+
" </div>",
|
|
307
|
+
"</div>",
|
|
308
|
+
].join("\n");
|
|
309
|
+
|
|
310
|
+
// A single <video> element, moved between the id/selfie step wrappers
|
|
311
|
+
// by enterIdStep()/enterSelfieStep() — not two separate elements, so
|
|
312
|
+
// the live stream is always attached to whichever one is visible.
|
|
313
|
+
var video = document.createElement("video");
|
|
314
|
+
video.autoplay = true;
|
|
315
|
+
video.playsInline = true;
|
|
316
|
+
video.muted = true;
|
|
317
|
+
|
|
318
|
+
var idVideoWrapper = root.querySelector(".camera-kit-kyc-id-wrapper");
|
|
319
|
+
idVideoWrapper.appendChild(video);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
root: root,
|
|
323
|
+
status: root.querySelector(".camera-kit-status"),
|
|
324
|
+
video: video,
|
|
325
|
+
idVideoWrapper: idVideoWrapper,
|
|
326
|
+
selfieVideoWrapper: root.querySelector(".camera-kit-kyc-selfie-wrapper"),
|
|
327
|
+
hint: root.querySelector(".camera-kit-kyc-hint"),
|
|
328
|
+
captureIdBtn: root.querySelector(".camera-kit-capture-id"),
|
|
329
|
+
restartBtn: root.querySelector(".camera-kit-kyc-restart"),
|
|
330
|
+
manualSelfieBtn: root.querySelector(".camera-kit-kyc-manual-selfie"),
|
|
331
|
+
retakeSelfieBtn: root.querySelector(".camera-kit-kyc-retake-selfie"),
|
|
332
|
+
idPreview: root.querySelector(".camera-kit-kyc-id-preview"),
|
|
333
|
+
selfiePreview: root.querySelector(".camera-kit-kyc-selfie-preview"),
|
|
334
|
+
reviewStatus: root.querySelector(".camera-kit-kyc-review-status"),
|
|
335
|
+
sendBtn: root.querySelector(".camera-kit-kyc-send"),
|
|
336
|
+
resultText: root.querySelector(".camera-kit-kyc-result"),
|
|
337
|
+
closeBtn: root.querySelector(".camera-kit-kyc-close"),
|
|
338
|
+
cancelBtns: Array.prototype.slice.call(root.querySelectorAll(".camera-kit-cancel")),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (document.readyState === "loading") {
|
|
343
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
344
|
+
} else {
|
|
345
|
+
init();
|
|
346
|
+
}
|
|
347
|
+
})(window, document);
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div class="camera-kit-kyc" data-verify-url="{{ widget.verify_url|default:'' }}">
|
|
2
|
+
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
|
|
3
|
+
<button type="button" class="btn btn-outline-primary camera-kit-kyc-trigger">
|
|
4
|
+
Vérifier mon identité
|
|
5
|
+
</button>
|
|
6
|
+
<span class="camera-kit-kyc-status"></span>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from rest_framework.throttling import UserRateThrottle
|
|
2
|
+
|
|
3
|
+
from . import conf
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KYCVerifyThrottle(UserRateThrottle):
|
|
7
|
+
"""Rate-limits KYCVerifyView per authenticated user.
|
|
8
|
+
|
|
9
|
+
Each request runs a full face-embedding inference pass — without a
|
|
10
|
+
limit, the endpoint is a cheap resource-exhaustion and brute-force
|
|
11
|
+
probing vector. Reads from DJANGO_CAMERA_KIT_KYC.THROTTLE_RATE
|
|
12
|
+
(see conf.py) instead of DRF's global DEFAULT_THROTTLE_RATES, so this
|
|
13
|
+
reusable app doesn't require the consuming project to configure DRF
|
|
14
|
+
throttle scopes globally.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
scope = "camera_kit_kyc_verify"
|
|
18
|
+
|
|
19
|
+
def get_rate(self):
|
|
20
|
+
return conf.get_setting("THROTTLE_RATE")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
from rest_framework.permissions import IsAuthenticated
|
|
4
|
+
from rest_framework.response import Response
|
|
5
|
+
from rest_framework.views import APIView
|
|
6
|
+
|
|
7
|
+
from . import conf
|
|
8
|
+
from .embeddings import compare_embeddings, extract_face_embedding
|
|
9
|
+
from .models import KYCVerification
|
|
10
|
+
from .serializers import KYCSubmissionSerializer, KYCVerificationSerializer
|
|
11
|
+
from .throttling import KYCVerifyThrottle
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_image_bgr(django_file):
|
|
15
|
+
data = np.frombuffer(django_file.read(), dtype=np.uint8)
|
|
16
|
+
django_file.seek(0)
|
|
17
|
+
return cv2.imdecode(data, cv2.IMREAD_COLOR)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KYCVerifyView(APIView):
|
|
21
|
+
"""Accepts a selfie and an ID document photo, extracts a face embedding
|
|
22
|
+
from each server-side, and compares them. Matching only ever happens
|
|
23
|
+
here — no client-supplied embedding or score is ever trusted.
|
|
24
|
+
|
|
25
|
+
Requires authentication: a verification is meaningless without knowing
|
|
26
|
+
whose identity it claims to confirm, and the endpoint runs a full face-
|
|
27
|
+
embedding inference pass per request, so it also needs to be rate
|
|
28
|
+
limited (KYCVerifyThrottle) rather than open to anonymous callers.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
permission_classes = [IsAuthenticated]
|
|
32
|
+
throttle_classes = [KYCVerifyThrottle]
|
|
33
|
+
|
|
34
|
+
def post(self, request):
|
|
35
|
+
submission = KYCSubmissionSerializer(data=request.data)
|
|
36
|
+
submission.is_valid(raise_exception=True)
|
|
37
|
+
|
|
38
|
+
verification = KYCVerification.objects.create(
|
|
39
|
+
user=request.user,
|
|
40
|
+
selfie=submission.validated_data["selfie"],
|
|
41
|
+
id_document=submission.validated_data["id_document"],
|
|
42
|
+
liveness_passed=submission.validated_data["liveness_passed"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
selfie_embedding = extract_face_embedding(_read_image_bgr(verification.selfie))
|
|
46
|
+
id_embedding = extract_face_embedding(_read_image_bgr(verification.id_document))
|
|
47
|
+
|
|
48
|
+
if selfie_embedding is None or id_embedding is None:
|
|
49
|
+
verification.status = KYCVerification.Status.REJECTED
|
|
50
|
+
verification.save(update_fields=["status"])
|
|
51
|
+
return Response(KYCVerificationSerializer(verification).data)
|
|
52
|
+
|
|
53
|
+
score = compare_embeddings(selfie_embedding, id_embedding)
|
|
54
|
+
verification.selfie_embedding = selfie_embedding
|
|
55
|
+
verification.id_face_embedding = id_embedding
|
|
56
|
+
verification.match_score = score
|
|
57
|
+
verification.status = (
|
|
58
|
+
KYCVerification.Status.VERIFIED
|
|
59
|
+
if score >= conf.get_setting("MATCH_THRESHOLD")
|
|
60
|
+
and verification.liveness_passed
|
|
61
|
+
else KYCVerification.Status.REJECTED
|
|
62
|
+
)
|
|
63
|
+
verification.save()
|
|
64
|
+
|
|
65
|
+
return Response(KYCVerificationSerializer(verification).data)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.urls import NoReverseMatch, reverse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class KYCVerificationWidget(forms.HiddenInput):
|
|
6
|
+
"""Hidden field populated with a KYCVerification id once the user
|
|
7
|
+
completes the live capture + server-side verification flow.
|
|
8
|
+
|
|
9
|
+
Renders a "Vérifier mon identité" button next to the hidden input —
|
|
10
|
+
unlike DocumentScannerWidget, there is deliberately no file-upload
|
|
11
|
+
fallback: allowing a raw upload here would defeat the whole point of
|
|
12
|
+
the liveness challenge (anyone could submit a stolen photo).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
template_name = "django_camera_kit/kyc/widgets/kyc_verification.html"
|
|
16
|
+
|
|
17
|
+
class Media:
|
|
18
|
+
css = {"all": ("django_camera_kit/kyc/css/kyc_capture.css",)}
|
|
19
|
+
js = (
|
|
20
|
+
"django_camera_kit/vendor/opencv.js",
|
|
21
|
+
"django_camera_kit/js/camera_kit.js",
|
|
22
|
+
"django_camera_kit/kyc/js/face_kit.js",
|
|
23
|
+
"django_camera_kit/kyc/js/kyc_capture.js",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def __init__(self, attrs=None, verify_url=None):
|
|
27
|
+
super().__init__(attrs)
|
|
28
|
+
self.verify_url = verify_url
|
|
29
|
+
|
|
30
|
+
def get_context(self, name, value, attrs):
|
|
31
|
+
context = super().get_context(name, value, attrs)
|
|
32
|
+
context["widget"]["verify_url"] = self.verify_url or self._default_verify_url()
|
|
33
|
+
return context
|
|
34
|
+
|
|
35
|
+
def _default_verify_url(self):
|
|
36
|
+
try:
|
|
37
|
+
return reverse("camera_kit_kyc:verify")
|
|
38
|
+
except NoReverseMatch:
|
|
39
|
+
return None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
.camera-kit-scanner {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 0.5rem;
|
|
5
|
+
flex-wrap: wrap;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Once doc_scan.js has bound the widget, the button is the only way in —
|
|
9
|
+
the native file input stays in the DOM (still submits the scanned file)
|
|
10
|
+
but is hidden. If JS never runs, this class is never added and the
|
|
11
|
+
native picker remains visible as the fallback. */
|
|
12
|
+
.camera-kit-scanner.camera-kit-js-ready input[type="file"] {
|
|
13
|
+
display: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.camera-kit-overlay {
|
|
17
|
+
position: fixed;
|
|
18
|
+
inset: 0;
|
|
19
|
+
background: rgba(0, 0, 0, 0.85);
|
|
20
|
+
z-index: 1050;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
padding: 1rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.camera-kit-modal {
|
|
28
|
+
background: #fff;
|
|
29
|
+
border-radius: 0.5rem;
|
|
30
|
+
max-width: 640px;
|
|
31
|
+
width: 100%;
|
|
32
|
+
max-height: 90vh;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.camera-kit-status {
|
|
39
|
+
padding: 0.5rem 1rem;
|
|
40
|
+
color: #b02a37;
|
|
41
|
+
font-size: 0.875rem;
|
|
42
|
+
min-height: 1.5rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.camera-kit-video-wrapper,
|
|
46
|
+
.camera-kit-adjust-wrapper {
|
|
47
|
+
position: relative;
|
|
48
|
+
background: #000;
|
|
49
|
+
line-height: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.camera-kit-video-wrapper video,
|
|
53
|
+
.camera-kit-video-wrapper canvas,
|
|
54
|
+
.camera-kit-adjust-wrapper canvas {
|
|
55
|
+
width: 100%;
|
|
56
|
+
height: auto;
|
|
57
|
+
display: block;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.camera-kit-video-wrapper canvas {
|
|
61
|
+
position: absolute;
|
|
62
|
+
top: 0;
|
|
63
|
+
left: 0;
|
|
64
|
+
pointer-events: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.camera-kit-handle {
|
|
68
|
+
position: absolute;
|
|
69
|
+
width: 24px;
|
|
70
|
+
height: 24px;
|
|
71
|
+
border-radius: 50%;
|
|
72
|
+
background: #00723d;
|
|
73
|
+
border: 2px solid #fff;
|
|
74
|
+
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
|
|
75
|
+
touch-action: none;
|
|
76
|
+
cursor: grab;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.camera-kit-actions {
|
|
80
|
+
display: flex;
|
|
81
|
+
justify-content: flex-end;
|
|
82
|
+
gap: 0.5rem;
|
|
83
|
+
padding: 0.75rem 1rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.camera-kit-page-strip {
|
|
87
|
+
display: flex;
|
|
88
|
+
gap: 0.5rem;
|
|
89
|
+
padding: 1rem;
|
|
90
|
+
overflow-x: auto;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.camera-kit-thumb {
|
|
94
|
+
height: 96px;
|
|
95
|
+
border-radius: 0.25rem;
|
|
96
|
+
border: 1px solid #dee2e6;
|
|
97
|
+
}
|