linux-face-unlock 1.0.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.
- face_unlock/__init__.py +1 -0
- face_unlock/authenticate.py +363 -0
- face_unlock/camera.py +116 -0
- face_unlock/cli.py +175 -0
- face_unlock/config.py +194 -0
- face_unlock/dialog.py +83 -0
- face_unlock/enroll.py +125 -0
- face_unlock/face_engine.py +117 -0
- face_unlock/pam.py +174 -0
- face_unlock/session.py +133 -0
- face_unlock/storage.py +195 -0
- face_unlock/test.py +224 -0
- linux_face_unlock-1.0.0.dist-info/METADATA +361 -0
- linux_face_unlock-1.0.0.dist-info/RECORD +18 -0
- linux_face_unlock-1.0.0.dist-info/WHEEL +5 -0
- linux_face_unlock-1.0.0.dist-info/entry_points.txt +5 -0
- linux_face_unlock-1.0.0.dist-info/licenses/LICENSE +201 -0
- linux_face_unlock-1.0.0.dist-info/top_level.txt +1 -0
face_unlock/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import syslog
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
from . import config as cfgmod
|
|
11
|
+
from . import camera as cam
|
|
12
|
+
from . import face_engine as fe
|
|
13
|
+
from . import storage as store
|
|
14
|
+
from . import session as sessmod
|
|
15
|
+
from . import dialog as dlgmod
|
|
16
|
+
|
|
17
|
+
_RATE_LIMIT_DIR = "/etc/face-unlock/ratelimit"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _log(username, success, reason=""):
|
|
21
|
+
priority = syslog.LOG_AUTH | (syslog.LOG_INFO if success else syslog.LOG_WARNING)
|
|
22
|
+
msg = f"face-unlock[PAM]: user={username} {'granted' if success else 'denied'}"
|
|
23
|
+
if reason:
|
|
24
|
+
msg += f" ({reason})"
|
|
25
|
+
syslog.syslog(priority, msg)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rate_lock_path(username):
|
|
29
|
+
return os.path.join(_RATE_LIMIT_DIR, username + ".lock")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_rate_limit(username, max_attempts=5, lockout_minutes=5):
|
|
33
|
+
try:
|
|
34
|
+
store.validate_username(username)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return False
|
|
37
|
+
if max_attempts <= 0:
|
|
38
|
+
return True
|
|
39
|
+
now = time.time()
|
|
40
|
+
window = lockout_minutes * 60
|
|
41
|
+
fpath = os.path.join(_RATE_LIMIT_DIR, username)
|
|
42
|
+
lock_path = _rate_lock_path(username)
|
|
43
|
+
try:
|
|
44
|
+
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
45
|
+
except IOError:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
49
|
+
try:
|
|
50
|
+
if not os.path.exists(fpath):
|
|
51
|
+
return True
|
|
52
|
+
try:
|
|
53
|
+
with open(fpath, "r") as f:
|
|
54
|
+
timestamps = [float(line.strip()) for line in f if line.strip()]
|
|
55
|
+
except (IOError, ValueError):
|
|
56
|
+
return False
|
|
57
|
+
timestamps = [t for t in timestamps if now - t < window]
|
|
58
|
+
tmp = fpath + ".tmp"
|
|
59
|
+
try:
|
|
60
|
+
with open(tmp, "w") as f:
|
|
61
|
+
for t in timestamps:
|
|
62
|
+
f.write(f"{t}\n")
|
|
63
|
+
f.flush()
|
|
64
|
+
os.fsync(f.fileno())
|
|
65
|
+
os.rename(tmp, fpath)
|
|
66
|
+
except IOError:
|
|
67
|
+
return False
|
|
68
|
+
return len(timestamps) < max_attempts
|
|
69
|
+
finally:
|
|
70
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
71
|
+
os.close(lock_fd)
|
|
72
|
+
except Exception:
|
|
73
|
+
os.close(lock_fd)
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _record_failed_attempt(username):
|
|
78
|
+
try:
|
|
79
|
+
store.validate_username(username)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return
|
|
82
|
+
d = _RATE_LIMIT_DIR
|
|
83
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
84
|
+
fpath = os.path.join(d, username)
|
|
85
|
+
lock_path = _rate_lock_path(username)
|
|
86
|
+
try:
|
|
87
|
+
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
88
|
+
except IOError:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
92
|
+
try:
|
|
93
|
+
tmp = fpath + ".tmp"
|
|
94
|
+
try:
|
|
95
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
96
|
+
with os.fdopen(fd, "a") as f:
|
|
97
|
+
f.write(f"{time.time()}\n")
|
|
98
|
+
f.flush()
|
|
99
|
+
os.fsync(f.fileno())
|
|
100
|
+
os.rename(tmp, fpath)
|
|
101
|
+
except IOError:
|
|
102
|
+
pass
|
|
103
|
+
finally:
|
|
104
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
105
|
+
os.close(lock_fd)
|
|
106
|
+
except Exception:
|
|
107
|
+
os.close(lock_fd)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _clear_rate_limit(username):
|
|
111
|
+
try:
|
|
112
|
+
store.validate_username(username)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return
|
|
115
|
+
fpath = os.path.join(_RATE_LIMIT_DIR, username)
|
|
116
|
+
try:
|
|
117
|
+
if os.path.exists(fpath):
|
|
118
|
+
os.remove(fpath)
|
|
119
|
+
except IOError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _try_retries(cap, encodings, retries, timeout, auto_timeout, tolerance,
|
|
124
|
+
use_cnn, num_jitters):
|
|
125
|
+
for attempt in range(retries):
|
|
126
|
+
cap_timeout = auto_timeout if (attempt == 0 and auto_timeout > 0) else timeout
|
|
127
|
+
frame = cam.capture_frame(cap, timeout=cap_timeout)
|
|
128
|
+
if frame is None:
|
|
129
|
+
continue
|
|
130
|
+
encoding, _ = fe.encode_single_face(frame, use_cnn=use_cnn,
|
|
131
|
+
num_jitters=num_jitters)
|
|
132
|
+
if encoding is None:
|
|
133
|
+
continue
|
|
134
|
+
match, _ = fe.compare_encodings(encodings, encoding, tolerance=tolerance)
|
|
135
|
+
if match:
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def authenticate_user(username: str, device_path: Optional[str] = None, timeout: int = 5, retries: int = 3,
|
|
141
|
+
tolerance: float = 0.55, use_cnn: bool = False, num_jitters: int = 5,
|
|
142
|
+
require_blinks: int = 1) -> bool:
|
|
143
|
+
cfg = cfgmod.get_config()
|
|
144
|
+
|
|
145
|
+
max_attempts = cfgmod.get_max_attempts(cfg)
|
|
146
|
+
lockout_minutes = cfgmod.get_lockout_minutes(cfg)
|
|
147
|
+
|
|
148
|
+
if not _check_rate_limit(username, max_attempts, lockout_minutes):
|
|
149
|
+
_log(username, False, "rate limited")
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
if not store.user_exists(username):
|
|
153
|
+
_log(username, False, "user not found")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
encodings = store.load_encodings(username)
|
|
157
|
+
if not encodings:
|
|
158
|
+
_log(username, False, "no encodings")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
if not device_path:
|
|
162
|
+
device_path = cfgmod.get_device_path(cfg)
|
|
163
|
+
|
|
164
|
+
cap = cam.open_camera(device_path)
|
|
165
|
+
if cap is None:
|
|
166
|
+
_log(username, False, "camera unavailable")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
time.sleep(0.3)
|
|
170
|
+
|
|
171
|
+
liveness_timeout = cfgmod.get_liveness_timeout(cfg)
|
|
172
|
+
auto_timeout = cfgmod.get_auto_capture_timeout(cfg)
|
|
173
|
+
show_dialog = cfgmod.get_show_auth_dialog(cfg)
|
|
174
|
+
|
|
175
|
+
winname = "Face Unlock - Look at the camera"
|
|
176
|
+
gui = cam.has_gui()
|
|
177
|
+
if gui:
|
|
178
|
+
cam.gui_named_window(winname, cv2.WINDOW_NORMAL)
|
|
179
|
+
cam.gui_resize_window(winname, 640, 480)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
liveness_ok = require_blinks <= 0
|
|
183
|
+
blink_count = 0
|
|
184
|
+
in_blink = False
|
|
185
|
+
liveness_start = time.time()
|
|
186
|
+
|
|
187
|
+
while not liveness_ok:
|
|
188
|
+
frame = cam.capture_frame(cap, timeout=0.5)
|
|
189
|
+
if frame is None:
|
|
190
|
+
if time.time() - liveness_start >= liveness_timeout:
|
|
191
|
+
break
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
ear, locations = fe.compute_ear(frame, use_cnn=use_cnn)
|
|
195
|
+
if ear is not None:
|
|
196
|
+
if ear < fe.EAR_THRESHOLD:
|
|
197
|
+
if not in_blink:
|
|
198
|
+
in_blink = True
|
|
199
|
+
else:
|
|
200
|
+
if in_blink:
|
|
201
|
+
blink_count += 1
|
|
202
|
+
in_blink = False
|
|
203
|
+
if blink_count >= require_blinks:
|
|
204
|
+
liveness_ok = True
|
|
205
|
+
|
|
206
|
+
if gui:
|
|
207
|
+
display = frame.copy()
|
|
208
|
+
for (top, right, bottom, left) in locations:
|
|
209
|
+
cv2.rectangle(display, (left, top), (right, bottom), (0, 255, 0), 2)
|
|
210
|
+
remaining = max(0, int(liveness_timeout - (time.time() - liveness_start)))
|
|
211
|
+
text = f"Blink: {blink_count}/{require_blinks} Timeout: {remaining}s"
|
|
212
|
+
cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
|
|
213
|
+
if ear is not None:
|
|
214
|
+
cv2.putText(display, "Face detected", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
|
215
|
+
else:
|
|
216
|
+
cv2.putText(display, "No face detected", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
|
217
|
+
cam.gui_imshow(winname, display)
|
|
218
|
+
if cam.gui_wait_key(1) == 27:
|
|
219
|
+
cam.gui_destroy_all_windows()
|
|
220
|
+
_log(username, False, "cancelled by user")
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
if time.time() - liveness_start >= liveness_timeout:
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
if not liveness_ok:
|
|
227
|
+
if gui:
|
|
228
|
+
display = np.zeros((480, 640, 3), dtype=np.uint8)
|
|
229
|
+
cv2.putText(display, "Liveness check failed", (100, 240),
|
|
230
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
|
|
231
|
+
cam.gui_imshow(winname, display)
|
|
232
|
+
cam.gui_wait_key(1000)
|
|
233
|
+
cam.gui_destroy_all_windows()
|
|
234
|
+
_log(username, False, "liveness check failed")
|
|
235
|
+
_record_failed_attempt(username)
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
match_result = False
|
|
239
|
+
for attempt in range(retries):
|
|
240
|
+
cap_timeout = auto_timeout if (attempt == 0 and auto_timeout > 0) else timeout
|
|
241
|
+
frame = cam.capture_frame(cap, timeout=cap_timeout)
|
|
242
|
+
if frame is None:
|
|
243
|
+
if gui:
|
|
244
|
+
display = np.zeros((480, 640, 3), dtype=np.uint8)
|
|
245
|
+
cv2.putText(display, "Camera timeout", (150, 240),
|
|
246
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
|
|
247
|
+
cam.gui_imshow(winname, display)
|
|
248
|
+
cam.gui_wait_key(500)
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
encoding, face_locations = fe.encode_single_face(frame, use_cnn=use_cnn,
|
|
252
|
+
num_jitters=num_jitters)
|
|
253
|
+
if gui:
|
|
254
|
+
display = frame.copy()
|
|
255
|
+
if face_locations:
|
|
256
|
+
for (top, right, bottom, left) in face_locations:
|
|
257
|
+
cv2.rectangle(display, (left, top), (right, bottom), (0, 255, 0), 2)
|
|
258
|
+
if encoding is not None:
|
|
259
|
+
text = f"Scanning... ({attempt+1}/{retries})"
|
|
260
|
+
cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
|
261
|
+
else:
|
|
262
|
+
text = f"No face detected ({attempt+1}/{retries})"
|
|
263
|
+
cv2.putText(display, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
264
|
+
cam.gui_imshow(winname, display)
|
|
265
|
+
if cam.gui_wait_key(1) == 27:
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
if encoding is not None:
|
|
269
|
+
match, _ = fe.compare_encodings(encodings, encoding, tolerance=tolerance)
|
|
270
|
+
if match:
|
|
271
|
+
match_result = True
|
|
272
|
+
if gui:
|
|
273
|
+
display = frame.copy()
|
|
274
|
+
cv2.putText(display, "Match! Access granted", (120, 240),
|
|
275
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
|
|
276
|
+
cam.gui_imshow(winname, display)
|
|
277
|
+
cam.gui_wait_key(1500)
|
|
278
|
+
break
|
|
279
|
+
if gui:
|
|
280
|
+
display = frame.copy()
|
|
281
|
+
cv2.putText(display, "No match, retrying...", (120, 240),
|
|
282
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
283
|
+
cam.gui_imshow(winname, display)
|
|
284
|
+
cam.gui_wait_key(500)
|
|
285
|
+
|
|
286
|
+
if gui:
|
|
287
|
+
cam.gui_destroy_all_windows()
|
|
288
|
+
|
|
289
|
+
if match_result:
|
|
290
|
+
_clear_rate_limit(username)
|
|
291
|
+
_log(username, True)
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
if show_dialog:
|
|
295
|
+
session_info = sessmod.detect_session(username)
|
|
296
|
+
if session_info:
|
|
297
|
+
dlgmod.notify_failure(username, session_info)
|
|
298
|
+
choice = dlgmod.show_auth_choice(
|
|
299
|
+
username, session_info,
|
|
300
|
+
dialog_timeout=cfgmod.get_dialog_timeout(cfg),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if choice == "face" and _try_retries(cap, encodings, retries,
|
|
304
|
+
timeout, auto_timeout, tolerance,
|
|
305
|
+
use_cnn, num_jitters):
|
|
306
|
+
_clear_rate_limit(username)
|
|
307
|
+
_log(username, True)
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
if choice == "password":
|
|
311
|
+
_log(username, False, "user chose password")
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
_log(username, False, "no match after all retries")
|
|
315
|
+
_record_failed_attempt(username)
|
|
316
|
+
return False
|
|
317
|
+
finally:
|
|
318
|
+
cam.release_camera(cap)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main():
|
|
322
|
+
username = os.environ.get("PAM_USER", "")
|
|
323
|
+
if not username:
|
|
324
|
+
if len(sys.argv) > 1:
|
|
325
|
+
username = sys.argv[1]
|
|
326
|
+
else:
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
session_info = sessmod.detect_session(username)
|
|
330
|
+
if session_info:
|
|
331
|
+
for key in ("DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"):
|
|
332
|
+
env_key = key
|
|
333
|
+
if key == "XAUTHORITY":
|
|
334
|
+
val = session_info.get("xauthority", "")
|
|
335
|
+
elif key == "WAYLAND_DISPLAY":
|
|
336
|
+
val = session_info.get("wayland_display", "")
|
|
337
|
+
elif key == "XDG_RUNTIME_DIR":
|
|
338
|
+
val = session_info.get("xdg_runtime_dir", "")
|
|
339
|
+
else:
|
|
340
|
+
val = session_info.get("display", "")
|
|
341
|
+
if val and env_key not in os.environ:
|
|
342
|
+
os.environ[env_key] = val
|
|
343
|
+
|
|
344
|
+
cfg = cfgmod.get_config()
|
|
345
|
+
|
|
346
|
+
if cfgmod.is_disabled(cfg):
|
|
347
|
+
sys.exit(1)
|
|
348
|
+
|
|
349
|
+
result = authenticate_user(
|
|
350
|
+
username=username,
|
|
351
|
+
timeout=cfgmod.get_timeout(cfg),
|
|
352
|
+
retries=cfgmod.get_retries(cfg),
|
|
353
|
+
tolerance=cfgmod.get_tolerance(cfg),
|
|
354
|
+
use_cnn=cfgmod.use_cnn(cfg),
|
|
355
|
+
num_jitters=cfgmod.get_num_jitters(cfg),
|
|
356
|
+
require_blinks=cfgmod.get_liveness_blinks(cfg),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
sys.exit(0 if result else 1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
main()
|
face_unlock/camera.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def detect_cameras():
|
|
8
|
+
devices = []
|
|
9
|
+
for i in range(10):
|
|
10
|
+
path = f"/dev/video{i}"
|
|
11
|
+
if os.path.exists(path):
|
|
12
|
+
devices.append(path)
|
|
13
|
+
return devices
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def open_camera(device_path=None, width=None, height=None):
|
|
17
|
+
from . import config as cfgmod
|
|
18
|
+
|
|
19
|
+
if width is None or height is None:
|
|
20
|
+
cfg = cfgmod.get_config()
|
|
21
|
+
if width is None:
|
|
22
|
+
width = cfgmod.get_frame_width(cfg)
|
|
23
|
+
if height is None:
|
|
24
|
+
height = cfgmod.get_frame_height(cfg)
|
|
25
|
+
|
|
26
|
+
if device_path:
|
|
27
|
+
try:
|
|
28
|
+
index = int(os.path.basename(device_path).replace("video", ""))
|
|
29
|
+
except (ValueError, IndexError):
|
|
30
|
+
index = 0
|
|
31
|
+
cap = cv2.VideoCapture(index)
|
|
32
|
+
if not cap.isOpened():
|
|
33
|
+
return None
|
|
34
|
+
else:
|
|
35
|
+
available = detect_cameras()
|
|
36
|
+
if not available:
|
|
37
|
+
return None
|
|
38
|
+
cap = None
|
|
39
|
+
for dev in available:
|
|
40
|
+
try:
|
|
41
|
+
idx = int(os.path.basename(dev).replace("video", ""))
|
|
42
|
+
except (ValueError, IndexError):
|
|
43
|
+
continue
|
|
44
|
+
cap = cv2.VideoCapture(idx)
|
|
45
|
+
if cap is not None and cap.isOpened():
|
|
46
|
+
break
|
|
47
|
+
cap = None
|
|
48
|
+
if cap is None:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
52
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
53
|
+
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
54
|
+
|
|
55
|
+
return cap
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_frame(cap, timeout=5):
|
|
59
|
+
start = time.time()
|
|
60
|
+
while time.time() - start < timeout:
|
|
61
|
+
ret, frame = cap.read()
|
|
62
|
+
if ret and frame is not None and frame.size > 0:
|
|
63
|
+
return frame
|
|
64
|
+
time.sleep(0.1)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def release_camera(cap):
|
|
69
|
+
if cap is not None:
|
|
70
|
+
cap.release()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def gui_named_window(winname, flags=cv2.WINDOW_NORMAL):
|
|
74
|
+
try:
|
|
75
|
+
cv2.namedWindow(winname, flags)
|
|
76
|
+
except cv2.error:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def gui_resize_window(winname, width, height):
|
|
81
|
+
try:
|
|
82
|
+
cv2.resizeWindow(winname, width, height)
|
|
83
|
+
except cv2.error:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gui_imshow(winname, mat):
|
|
88
|
+
try:
|
|
89
|
+
cv2.imshow(winname, mat)
|
|
90
|
+
except cv2.error:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def gui_wait_key(delay=1):
|
|
95
|
+
try:
|
|
96
|
+
return cv2.waitKey(delay) & 0xFF
|
|
97
|
+
except cv2.error:
|
|
98
|
+
return 255
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def gui_destroy_all_windows():
|
|
102
|
+
try:
|
|
103
|
+
cv2.destroyAllWindows()
|
|
104
|
+
except cv2.error:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def has_gui():
|
|
109
|
+
if not os.environ.get("DISPLAY"):
|
|
110
|
+
return False
|
|
111
|
+
try:
|
|
112
|
+
cv2.namedWindow("__test", cv2.WINDOW_NORMAL)
|
|
113
|
+
cv2.destroyWindow("__test")
|
|
114
|
+
return True
|
|
115
|
+
except cv2.error:
|
|
116
|
+
return False
|
face_unlock/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import config as cfgmod
|
|
7
|
+
from . import storage as store
|
|
8
|
+
from . import enroll
|
|
9
|
+
from . import authenticate
|
|
10
|
+
from . import test as testmod
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_enroll(args):
|
|
14
|
+
if os.geteuid() != 0:
|
|
15
|
+
print("ERROR: Must be root. Use sudo.", file=sys.stderr)
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
success = enroll.enroll_user(
|
|
18
|
+
username=args.username,
|
|
19
|
+
device_path=args.device,
|
|
20
|
+
use_cnn=args.cnn,
|
|
21
|
+
num_jitters=args.jitters,
|
|
22
|
+
num_samples=args.samples,
|
|
23
|
+
)
|
|
24
|
+
sys.exit(0 if success else 1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_list(args):
|
|
28
|
+
users = store.list_users()
|
|
29
|
+
if not users:
|
|
30
|
+
print("No users enrolled.")
|
|
31
|
+
return
|
|
32
|
+
for u in users:
|
|
33
|
+
try:
|
|
34
|
+
count = store.encoding_count(u)
|
|
35
|
+
print(f"{u}: {count} encoding(s)")
|
|
36
|
+
except PermissionError:
|
|
37
|
+
print(f"{u}: (cannot read encodings, try sudo)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_remove(args):
|
|
41
|
+
if os.geteuid() != 0:
|
|
42
|
+
print("ERROR: Must be root. Use sudo.", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
if not store.user_exists(args.username):
|
|
45
|
+
print(f"User '{args.username}' not found.")
|
|
46
|
+
return
|
|
47
|
+
store.remove_encodings(args.username)
|
|
48
|
+
print(f"Removed encodings for '{args.username}'.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_test(args):
|
|
52
|
+
testmod.test_all()
|
|
53
|
+
if args.user:
|
|
54
|
+
testmod.test_recognition(
|
|
55
|
+
args.user, args.device, args.cnn, timeout=args.timeout)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_config(args):
|
|
59
|
+
cfg_path = cfgmod.CONFIG_PATH
|
|
60
|
+
if not os.path.exists(cfg_path):
|
|
61
|
+
print(f"Config file not found at {cfg_path}")
|
|
62
|
+
return
|
|
63
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
64
|
+
try:
|
|
65
|
+
subprocess.run([editor, cfg_path])
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
print(f"ERROR: Editor '{editor}' not found. Set EDITOR env var.", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_enable(args):
|
|
72
|
+
if os.geteuid() != 0:
|
|
73
|
+
print("ERROR: Must be root. Use sudo.", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
from . import pam
|
|
76
|
+
results = pam.enable_all()
|
|
77
|
+
for path, status in results.items():
|
|
78
|
+
print(f" {path}: {status}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_disable(args):
|
|
82
|
+
if os.geteuid() != 0:
|
|
83
|
+
print("ERROR: Must be root. Use sudo.", file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
from . import pam
|
|
86
|
+
results = pam.disable_all()
|
|
87
|
+
for path, status in results.items():
|
|
88
|
+
print(f" {path}: {status}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_default(args):
|
|
92
|
+
if os.geteuid() != 0:
|
|
93
|
+
print("ERROR: Must be root. Use sudo.", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
from . import pam
|
|
96
|
+
mode = args.mode
|
|
97
|
+
if mode == "on":
|
|
98
|
+
results = pam.set_default_all("on")
|
|
99
|
+
elif mode == "off":
|
|
100
|
+
results = pam.set_default_all("off")
|
|
101
|
+
else:
|
|
102
|
+
print(f"ERROR: Invalid mode '{mode}'. Use 'on' or 'off'.", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
for path, status in results.items():
|
|
105
|
+
print(f" {path}: {status}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_status(args):
|
|
109
|
+
from . import pam
|
|
110
|
+
results = pam.status_all()
|
|
111
|
+
for path, info in results.items():
|
|
112
|
+
if not info["exists"]:
|
|
113
|
+
print(f" {path}: not found")
|
|
114
|
+
elif not info["enabled"]:
|
|
115
|
+
print(f" {path}: disabled")
|
|
116
|
+
else:
|
|
117
|
+
print(f" {path}: enabled (default={info['default']})")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
parser = argparse.ArgumentParser(description="Face-Unlock management CLI")
|
|
122
|
+
sub = parser.add_subparsers(dest="command")
|
|
123
|
+
|
|
124
|
+
p_enroll = sub.add_parser("enroll", help="Enroll a user")
|
|
125
|
+
p_enroll.add_argument("username", help="Username to enroll")
|
|
126
|
+
p_enroll.add_argument("--device", "-d", help="Camera device path")
|
|
127
|
+
p_enroll.add_argument("--cnn", action="store_true", help="Use CNN model")
|
|
128
|
+
p_enroll.add_argument("--samples", "-n", type=int, default=5)
|
|
129
|
+
p_enroll.add_argument("--jitters", "-j", type=int, default=5)
|
|
130
|
+
|
|
131
|
+
sub.add_parser("list", help="List enrolled users")
|
|
132
|
+
|
|
133
|
+
p_remove = sub.add_parser("remove", help="Remove a user's encodings")
|
|
134
|
+
p_remove.add_argument("username", help="Username to remove")
|
|
135
|
+
|
|
136
|
+
p_test = sub.add_parser("test", help="Test face unlock setup")
|
|
137
|
+
p_test.add_argument("--user", "-u", help="Test against enrolled user")
|
|
138
|
+
p_test.add_argument("--device", "-d", help="Camera device path")
|
|
139
|
+
p_test.add_argument("--cnn", action="store_true", help="Use CNN model")
|
|
140
|
+
p_test.add_argument("--timeout", "-t", type=int, default=10)
|
|
141
|
+
|
|
142
|
+
sub.add_parser("config", help="Edit configuration")
|
|
143
|
+
|
|
144
|
+
p_enable = sub.add_parser("enable", help="Enable face-unlock in PAM")
|
|
145
|
+
p_enable.set_defaults(func=cmd_enable)
|
|
146
|
+
|
|
147
|
+
p_disable = sub.add_parser("disable", help="Disable face-unlock in PAM")
|
|
148
|
+
p_disable.set_defaults(func=cmd_disable)
|
|
149
|
+
|
|
150
|
+
p_default = sub.add_parser("default", help="Set face-unlock as default auth")
|
|
151
|
+
p_default.add_argument("mode", choices=["on", "off"], help="on=face first, off=password first")
|
|
152
|
+
p_default.set_defaults(func=cmd_default)
|
|
153
|
+
|
|
154
|
+
sub.add_parser("status", help="Show face-unlock PAM status").set_defaults(func=cmd_status)
|
|
155
|
+
|
|
156
|
+
args = parser.parse_args()
|
|
157
|
+
|
|
158
|
+
if hasattr(args, "func"):
|
|
159
|
+
args.func(args)
|
|
160
|
+
elif args.command == "enroll":
|
|
161
|
+
cmd_enroll(args)
|
|
162
|
+
elif args.command == "list":
|
|
163
|
+
cmd_list(args)
|
|
164
|
+
elif args.command == "remove":
|
|
165
|
+
cmd_remove(args)
|
|
166
|
+
elif args.command == "test":
|
|
167
|
+
cmd_test(args)
|
|
168
|
+
elif args.command == "config":
|
|
169
|
+
cmd_config(args)
|
|
170
|
+
else:
|
|
171
|
+
parser.print_help()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
main()
|