flask-Humanify 0.1.3.2__py3-none-any.whl → 0.2.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.
- flask_humanify/__init__.py +1 -1
- flask_humanify/datasets/ai_dogs.pkl +0 -0
- flask_humanify/datasets/animals.pkl +0 -0
- flask_humanify/datasets/characters.pkl +0 -0
- flask_humanify/features/rate_limiter.py +1 -1
- flask_humanify/humanify.py +393 -16
- flask_humanify/memory_server.py +836 -0
- flask_humanify/secret_key.bin +0 -0
- flask_humanify/templates/access_denied.html +32 -25
- flask_humanify/templates/audio_challenge.html +208 -0
- flask_humanify/templates/grid_challenge.html +232 -0
- flask_humanify/templates/{oneclick_captcha.html → one_click_challenge.html} +48 -45
- flask_humanify/templates/rate_limited.html +32 -25
- flask_humanify/utils.py +422 -2
- {flask_humanify-0.1.3.2.dist-info → flask_humanify-0.2.0.dist-info}/METADATA +14 -4
- flask_humanify-0.2.0.dist-info/RECORD +20 -0
- flask_humanify/ipset.py +0 -315
- flask_humanify-0.1.3.2.dist-info/RECORD +0 -14
- {flask_humanify-0.1.3.2.dist-info → flask_humanify-0.2.0.dist-info}/WHEEL +0 -0
- {flask_humanify-0.1.3.2.dist-info → flask_humanify-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {flask_humanify-0.1.3.2.dist-info → flask_humanify-0.2.0.dist-info}/top_level.txt +0 -0
flask_humanify/__init__.py
CHANGED
Binary file
|
Binary file
|
Binary file
|
flask_humanify/humanify.py
CHANGED
@@ -1,11 +1,33 @@
|
|
1
1
|
from dataclasses import dataclass
|
2
2
|
import logging
|
3
|
+
import random
|
3
4
|
from typing import List, Optional
|
4
5
|
|
5
6
|
from werkzeug.wrappers import Response
|
6
|
-
from flask import
|
7
|
-
|
8
|
-
|
7
|
+
from flask import (
|
8
|
+
Blueprint,
|
9
|
+
request,
|
10
|
+
render_template,
|
11
|
+
redirect,
|
12
|
+
url_for,
|
13
|
+
current_app,
|
14
|
+
g,
|
15
|
+
abort,
|
16
|
+
)
|
17
|
+
from .memory_server import MemoryClient, ensure_server_running
|
18
|
+
from .utils import (
|
19
|
+
get_client_ip,
|
20
|
+
get_return_url,
|
21
|
+
validate_clearance_token,
|
22
|
+
generate_user_hash,
|
23
|
+
manipulate_image_bytes,
|
24
|
+
image_bytes_to_data_url,
|
25
|
+
generate_captcha_token,
|
26
|
+
validate_captcha_token,
|
27
|
+
generate_clearance_token,
|
28
|
+
combine_audio_files,
|
29
|
+
audio_bytes_to_data_url,
|
30
|
+
)
|
9
31
|
|
10
32
|
|
11
33
|
VPN_PROVIDERS = [
|
@@ -19,6 +41,26 @@ VPN_PROVIDERS = [
|
|
19
41
|
"Mullvad",
|
20
42
|
]
|
21
43
|
|
44
|
+
IMAGE_CAPTCHA_MAPPING = {
|
45
|
+
"grid": {
|
46
|
+
"num_correct": (2, 3),
|
47
|
+
"num_images": 9,
|
48
|
+
"preview_image": False,
|
49
|
+
"hardness_range": (1, 4),
|
50
|
+
},
|
51
|
+
"one_click": {
|
52
|
+
"num_correct": 1,
|
53
|
+
"num_images": 6,
|
54
|
+
"preview_image": True,
|
55
|
+
"hardness_range": (1, 2),
|
56
|
+
},
|
57
|
+
}
|
58
|
+
|
59
|
+
AUDIO_CAPTCHA_CONFIG = {
|
60
|
+
"num_chars": 6,
|
61
|
+
"language": "en",
|
62
|
+
}
|
63
|
+
|
22
64
|
logger = logging.getLogger(__name__)
|
23
65
|
|
24
66
|
|
@@ -84,8 +126,12 @@ class Humanify:
|
|
84
126
|
Protect against bots and DDoS attacks.
|
85
127
|
"""
|
86
128
|
|
87
|
-
def __init__(
|
129
|
+
def __init__(
|
130
|
+
self, app=None, challenge_type: str = "one_click", captcha_dataset: str = "ai_dogs"
|
131
|
+
):
|
88
132
|
self.app = app
|
133
|
+
self.challenge_type = challenge_type
|
134
|
+
self.captcha_dataset = captcha_dataset
|
89
135
|
if app is not None:
|
90
136
|
self.init_app(app)
|
91
137
|
|
@@ -95,12 +141,15 @@ class Humanify:
|
|
95
141
|
"""
|
96
142
|
self.app = app
|
97
143
|
|
98
|
-
ensure_server_running(
|
99
|
-
|
100
|
-
|
144
|
+
ensure_server_running(
|
145
|
+
image_dataset=self.captcha_dataset,
|
146
|
+
)
|
147
|
+
self.memory_client = MemoryClient()
|
148
|
+
self.memory_client.connect()
|
149
|
+
self._secret_key = self.memory_client.get_secret_key()
|
101
150
|
|
102
151
|
self.blueprint = Blueprint(
|
103
|
-
"humanify", __name__, template_folder="templates", static_folder=
|
152
|
+
"humanify", __name__, template_folder="templates", static_folder=None
|
104
153
|
)
|
105
154
|
self._register_routes()
|
106
155
|
app.register_blueprint(self.blueprint)
|
@@ -108,6 +157,34 @@ class Humanify:
|
|
108
157
|
def _register_routes(self) -> None:
|
109
158
|
"""Register the humanify routes."""
|
110
159
|
|
160
|
+
@self.blueprint.route("/humanify/challenge", methods=["GET"])
|
161
|
+
def challenge():
|
162
|
+
"""
|
163
|
+
Challenge route.
|
164
|
+
"""
|
165
|
+
return self._render_challenge()
|
166
|
+
|
167
|
+
@self.blueprint.route("/humanify/audio_challenge", methods=["GET"])
|
168
|
+
def audio_challenge():
|
169
|
+
"""
|
170
|
+
Audio challenge route.
|
171
|
+
"""
|
172
|
+
return self._render_challenge(is_audio=True)
|
173
|
+
|
174
|
+
@self.blueprint.route("/humanify/verify", methods=["POST"])
|
175
|
+
def verify():
|
176
|
+
"""
|
177
|
+
Verify route.
|
178
|
+
"""
|
179
|
+
return self._verify_captcha()
|
180
|
+
|
181
|
+
@self.blueprint.route("/humanify/verify_audio", methods=["POST"])
|
182
|
+
def verify_audio():
|
183
|
+
"""
|
184
|
+
Verify audio route.
|
185
|
+
"""
|
186
|
+
return self._verify_audio_captcha()
|
187
|
+
|
111
188
|
@self.blueprint.route("/humanify/access_denied", methods=["GET"])
|
112
189
|
def access_denied():
|
113
190
|
"""
|
@@ -121,7 +198,7 @@ class Humanify:
|
|
121
198
|
{"Cache-Control": "public, max-age=15552000"},
|
122
199
|
)
|
123
200
|
|
124
|
-
def register_middleware(self, action: str = "
|
201
|
+
def register_middleware(self, action: str = "challenge"):
|
125
202
|
"""
|
126
203
|
Register the middleware.
|
127
204
|
"""
|
@@ -133,26 +210,326 @@ class Humanify:
|
|
133
210
|
"""
|
134
211
|
Before request hook.
|
135
212
|
"""
|
136
|
-
if request.endpoint
|
213
|
+
if request.endpoint and request.endpoint.startswith("humanify."):
|
137
214
|
return
|
138
215
|
|
139
216
|
if self.is_bot:
|
217
|
+
if action == "challenge":
|
218
|
+
return self.challenge()
|
140
219
|
if action == "deny_access":
|
141
220
|
return self.deny_access()
|
142
221
|
|
143
222
|
@property
|
144
|
-
def
|
223
|
+
def client_ip(self) -> Optional[str]:
|
224
|
+
"""Get the client IP address."""
|
225
|
+
if hasattr(g, "humanify_client_ip"):
|
226
|
+
return g.humanify_client_ip
|
227
|
+
|
228
|
+
client_ip = get_client_ip(request)
|
229
|
+
g.humanify_client_ip = client_ip
|
230
|
+
return client_ip
|
231
|
+
|
232
|
+
@property
|
233
|
+
def check_result(self) -> HumanifyResult:
|
145
234
|
"""
|
146
235
|
Check if the IP is a bot.
|
147
236
|
"""
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
237
|
+
if self.client_ip is None:
|
238
|
+
return HumanifyResult(ip=self.client_ip, is_invalid_ip=True)
|
239
|
+
|
240
|
+
if hasattr(g, "humanify_ip_groups"):
|
241
|
+
humanify_ip_groups = g.humanify_ip_groups
|
242
|
+
if isinstance(humanify_ip_groups, list):
|
243
|
+
return HumanifyResult.from_ip_groups(self.client_ip, humanify_ip_groups)
|
244
|
+
|
245
|
+
ip_groups = self.memory_client.lookup_ip(self.client_ip)
|
246
|
+
g.humanify_ip_groups = ip_groups
|
247
|
+
return HumanifyResult.from_ip_groups(self.client_ip, ip_groups)
|
248
|
+
|
249
|
+
@property
|
250
|
+
def has_valid_clearance_token(self) -> bool:
|
251
|
+
"""Check if the current client has a valid clearance token."""
|
252
|
+
return validate_clearance_token(
|
253
|
+
request.cookies.get("clearance_token", ""),
|
254
|
+
self._secret_key,
|
255
|
+
generate_user_hash(
|
256
|
+
self.client_ip or "127.0.0.1",
|
257
|
+
request.user_agent.string or "",
|
258
|
+
),
|
259
|
+
)
|
260
|
+
|
261
|
+
@property
|
262
|
+
def is_bot(self) -> bool:
|
263
|
+
"""Check if the current client is a bot."""
|
264
|
+
return not self.has_valid_clearance_token and self.check_result.is_bot
|
153
265
|
|
154
266
|
def deny_access(self) -> Response:
|
155
267
|
"""
|
156
268
|
Redirect to the access denied page.
|
157
269
|
"""
|
158
270
|
return redirect(url_for("humanify.access_denied", return_url=request.full_path))
|
271
|
+
|
272
|
+
def challenge(self) -> Response:
|
273
|
+
"""
|
274
|
+
Challenge the client.
|
275
|
+
"""
|
276
|
+
return redirect(url_for("humanify.challenge", return_url=request.full_path))
|
277
|
+
|
278
|
+
def _render_challenge(self, is_audio: bool = False) -> Response:
|
279
|
+
return_url = get_return_url(request)
|
280
|
+
if self.has_valid_clearance_token:
|
281
|
+
return redirect(return_url)
|
282
|
+
|
283
|
+
error = request.args.get("error", None)
|
284
|
+
if error not in [
|
285
|
+
"Invalid captcha token",
|
286
|
+
"Wrong selection. Try again.",
|
287
|
+
"Wrong response. Try again.",
|
288
|
+
]:
|
289
|
+
error = None
|
290
|
+
|
291
|
+
if is_audio:
|
292
|
+
return self._render_audio_challenge(return_url, error)
|
293
|
+
|
294
|
+
if self.challenge_type in ["grid", "one_click"]:
|
295
|
+
return self._render_image_challenge(return_url, error)
|
296
|
+
|
297
|
+
abort(404, "Invalid challenge type")
|
298
|
+
|
299
|
+
def _render_image_challenge(
|
300
|
+
self, return_url: str, error: Optional[str]
|
301
|
+
) -> Response:
|
302
|
+
"""
|
303
|
+
Render the image challenge.
|
304
|
+
"""
|
305
|
+
|
306
|
+
captcha_config = IMAGE_CAPTCHA_MAPPING[self.challenge_type]
|
307
|
+
use_preview_image = captcha_config["preview_image"]
|
308
|
+
|
309
|
+
images_bytes, correct_indexes, subject = self.memory_client.get_captcha_images(
|
310
|
+
num_correct=captcha_config["num_correct"],
|
311
|
+
num_images=captcha_config["num_images"],
|
312
|
+
preview_image=use_preview_image,
|
313
|
+
dataset_name=self.captcha_dataset,
|
314
|
+
)
|
315
|
+
|
316
|
+
if not images_bytes:
|
317
|
+
abort(500, "Could not load captcha images")
|
318
|
+
|
319
|
+
processed_images = []
|
320
|
+
for i, img_bytes in enumerate(images_bytes):
|
321
|
+
try:
|
322
|
+
hardness = random.randint(
|
323
|
+
captcha_config["hardness_range"][0],
|
324
|
+
captcha_config["hardness_range"][1],
|
325
|
+
)
|
326
|
+
distorted = manipulate_image_bytes(
|
327
|
+
img_bytes,
|
328
|
+
is_small=not (i == 0 and use_preview_image),
|
329
|
+
hardness=hardness,
|
330
|
+
)
|
331
|
+
processed_images.append(image_bytes_to_data_url(distorted))
|
332
|
+
except Exception as e:
|
333
|
+
current_app.logger.error(f"Error processing image: {e}")
|
334
|
+
processed_images.append(
|
335
|
+
(
|
336
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
337
|
+
"CAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
338
|
+
)
|
339
|
+
)
|
340
|
+
|
341
|
+
preview_image = None
|
342
|
+
if use_preview_image:
|
343
|
+
preview_image = processed_images[0]
|
344
|
+
processed_images = processed_images[1:]
|
345
|
+
|
346
|
+
user_hash = generate_user_hash(
|
347
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
348
|
+
)
|
349
|
+
captcha_data = generate_captcha_token(
|
350
|
+
user_hash, correct_indexes, self._secret_key
|
351
|
+
)
|
352
|
+
|
353
|
+
return Response(
|
354
|
+
render_template(
|
355
|
+
f"{self.challenge_type}_challenge.html",
|
356
|
+
images=processed_images,
|
357
|
+
preview_image=preview_image,
|
358
|
+
subject=subject,
|
359
|
+
captcha_data=captcha_data,
|
360
|
+
return_url=return_url or "/",
|
361
|
+
error=error,
|
362
|
+
audio_challenge_available=True,
|
363
|
+
),
|
364
|
+
mimetype="text/html",
|
365
|
+
)
|
366
|
+
|
367
|
+
def _render_audio_challenge(
|
368
|
+
self, return_url: str, error: Optional[str]
|
369
|
+
) -> Response:
|
370
|
+
"""
|
371
|
+
Render the audio challenge.
|
372
|
+
"""
|
373
|
+
num_chars = AUDIO_CAPTCHA_CONFIG["num_chars"]
|
374
|
+
language = AUDIO_CAPTCHA_CONFIG["language"]
|
375
|
+
|
376
|
+
audio_files, correct_chars = self.memory_client.get_captcha_audio(
|
377
|
+
num_chars=num_chars, language=language
|
378
|
+
)
|
379
|
+
|
380
|
+
if not audio_files:
|
381
|
+
abort(500, "Could not load captcha audio")
|
382
|
+
|
383
|
+
combined_audio = combine_audio_files(audio_files)
|
384
|
+
if not combined_audio:
|
385
|
+
abort(500, "Could not process audio files")
|
386
|
+
|
387
|
+
audio_data_url = audio_bytes_to_data_url(combined_audio, "mp3")
|
388
|
+
|
389
|
+
user_hash = generate_user_hash(
|
390
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
391
|
+
)
|
392
|
+
captcha_data = generate_captcha_token(
|
393
|
+
user_hash, correct_chars, self._secret_key
|
394
|
+
)
|
395
|
+
|
396
|
+
return Response(
|
397
|
+
render_template(
|
398
|
+
"audio_challenge.html",
|
399
|
+
audio_file=audio_data_url,
|
400
|
+
captcha_data=captcha_data,
|
401
|
+
return_url=return_url or "/",
|
402
|
+
error=error,
|
403
|
+
image_challenge_available=True,
|
404
|
+
),
|
405
|
+
mimetype="text/html",
|
406
|
+
)
|
407
|
+
|
408
|
+
def _verify_captcha(self) -> Response:
|
409
|
+
"""Verify the captcha solution."""
|
410
|
+
return_url = get_return_url(request)
|
411
|
+
if self.has_valid_clearance_token:
|
412
|
+
return redirect(return_url)
|
413
|
+
|
414
|
+
captcha_data = request.form.get("captcha_data", "")
|
415
|
+
if not captcha_data:
|
416
|
+
return redirect(
|
417
|
+
url_for(
|
418
|
+
"humanify.challenge",
|
419
|
+
error="Invalid captcha token",
|
420
|
+
return_url=return_url,
|
421
|
+
)
|
422
|
+
)
|
423
|
+
|
424
|
+
user_hash = generate_user_hash(
|
425
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
426
|
+
)
|
427
|
+
decrypted_data = validate_captcha_token(
|
428
|
+
captcha_data, self._secret_key, user_hash
|
429
|
+
)
|
430
|
+
|
431
|
+
if decrypted_data is None:
|
432
|
+
return redirect(
|
433
|
+
url_for(
|
434
|
+
"humanify.challenge",
|
435
|
+
error="Invalid captcha token",
|
436
|
+
return_url=return_url,
|
437
|
+
)
|
438
|
+
)
|
439
|
+
|
440
|
+
verify_functions = {
|
441
|
+
"grid": self._verify_image_captcha,
|
442
|
+
"one_click": self._verify_image_captcha,
|
443
|
+
}
|
444
|
+
|
445
|
+
verify_function = verify_functions[self.challenge_type]
|
446
|
+
if not verify_function(decrypted_data):
|
447
|
+
return redirect(
|
448
|
+
url_for(
|
449
|
+
"humanify.challenge",
|
450
|
+
error="Wrong selection. Try again.",
|
451
|
+
return_url=return_url,
|
452
|
+
)
|
453
|
+
)
|
454
|
+
|
455
|
+
clearance_token = generate_clearance_token(user_hash, self._secret_key)
|
456
|
+
|
457
|
+
response = redirect(return_url or "/")
|
458
|
+
response.set_cookie(
|
459
|
+
"clearance_token",
|
460
|
+
clearance_token,
|
461
|
+
max_age=14400,
|
462
|
+
httponly=True,
|
463
|
+
samesite="Strict",
|
464
|
+
)
|
465
|
+
|
466
|
+
return response
|
467
|
+
|
468
|
+
def _verify_audio_captcha(self) -> Response:
|
469
|
+
"""Verify the audio captcha solution."""
|
470
|
+
return_url = get_return_url(request)
|
471
|
+
if self.has_valid_clearance_token:
|
472
|
+
return redirect(return_url)
|
473
|
+
|
474
|
+
captcha_data = request.form.get("captcha_data", "")
|
475
|
+
if not captcha_data:
|
476
|
+
return redirect(
|
477
|
+
url_for(
|
478
|
+
"humanify.audio_challenge",
|
479
|
+
error="Invalid captcha token",
|
480
|
+
return_url=return_url,
|
481
|
+
)
|
482
|
+
)
|
483
|
+
|
484
|
+
user_hash = generate_user_hash(
|
485
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
486
|
+
)
|
487
|
+
correct_chars = validate_captcha_token(
|
488
|
+
captcha_data, self._secret_key, user_hash, valid_lengths=[197]
|
489
|
+
)
|
490
|
+
|
491
|
+
if correct_chars is None:
|
492
|
+
return redirect(
|
493
|
+
url_for(
|
494
|
+
"humanify.audio_challenge",
|
495
|
+
error="Invalid captcha token",
|
496
|
+
return_url=return_url,
|
497
|
+
)
|
498
|
+
)
|
499
|
+
|
500
|
+
audio_response = request.form.get("audio_response", "").lower().strip()
|
501
|
+
if not audio_response or audio_response != correct_chars:
|
502
|
+
return redirect(
|
503
|
+
url_for(
|
504
|
+
"humanify.audio_challenge",
|
505
|
+
error="Wrong response. Try again.",
|
506
|
+
return_url=return_url,
|
507
|
+
)
|
508
|
+
)
|
509
|
+
|
510
|
+
clearance_token = generate_clearance_token(user_hash, self._secret_key)
|
511
|
+
|
512
|
+
response = redirect(return_url or "/")
|
513
|
+
response.set_cookie(
|
514
|
+
"clearance_token",
|
515
|
+
clearance_token,
|
516
|
+
max_age=14400,
|
517
|
+
httponly=True,
|
518
|
+
samesite="Strict",
|
519
|
+
)
|
520
|
+
|
521
|
+
return response
|
522
|
+
|
523
|
+
def _verify_image_captcha(self, decrypted_data: str) -> bool:
|
524
|
+
"""Verify the image captcha."""
|
525
|
+
captcha_config = IMAGE_CAPTCHA_MAPPING[self.challenge_type]
|
526
|
+
|
527
|
+
selected_indexes = []
|
528
|
+
for i in range(1, captcha_config["num_images"] + 1):
|
529
|
+
if request.form.get(str(i), None) == "1":
|
530
|
+
selected_indexes.append(str(i - 1))
|
531
|
+
|
532
|
+
selected_str = "".join(sorted(selected_indexes))
|
533
|
+
correct_str = "".join(sorted(list(decrypted_data)))
|
534
|
+
|
535
|
+
return selected_str == correct_str
|