flask-Humanify 0.1.4__py3-none-any.whl → 0.2.1__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/datasets/keys.pkl +0 -0
- flask_humanify/features/rate_limiter.py +1 -1
- flask_humanify/humanify.py +559 -20
- flask_humanify/memory_server.py +838 -0
- 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} +4 -9
- flask_humanify/utils.py +488 -2
- {flask_humanify-0.1.4.dist-info → flask_humanify-0.2.1.dist-info}/METADATA +42 -4
- flask_humanify-0.2.1.dist-info/RECORD +20 -0
- flask_humanify/ipset.py +0 -315
- flask_humanify-0.1.4.dist-info/RECORD +0 -14
- {flask_humanify-0.1.4.dist-info → flask_humanify-0.2.1.dist-info}/WHEEL +0 -0
- {flask_humanify-0.1.4.dist-info → flask_humanify-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {flask_humanify-0.1.4.dist-info → flask_humanify-0.2.1.dist-info}/top_level.txt +0 -0
flask_humanify/__init__.py
CHANGED
Binary file
|
Binary file
|
Binary file
|
Binary file
|
flask_humanify/humanify.py
CHANGED
@@ -1,11 +1,35 @@
|
|
1
1
|
from dataclasses import dataclass
|
2
2
|
import logging
|
3
|
-
|
3
|
+
import random
|
4
|
+
from typing import List, Optional, Union, Dict, Any, Pattern
|
5
|
+
import re
|
6
|
+
import fnmatch
|
4
7
|
|
5
8
|
from werkzeug.wrappers import Response
|
6
|
-
from flask import
|
7
|
-
|
8
|
-
|
9
|
+
from flask import (
|
10
|
+
Blueprint,
|
11
|
+
request,
|
12
|
+
render_template,
|
13
|
+
redirect,
|
14
|
+
url_for,
|
15
|
+
current_app,
|
16
|
+
g,
|
17
|
+
abort,
|
18
|
+
)
|
19
|
+
from .memory_server import MemoryClient, ensure_server_running
|
20
|
+
from .utils import (
|
21
|
+
get_client_ip,
|
22
|
+
get_return_url,
|
23
|
+
validate_clearance_token,
|
24
|
+
generate_user_hash,
|
25
|
+
manipulate_image_bytes,
|
26
|
+
image_bytes_to_data_url,
|
27
|
+
generate_captcha_token,
|
28
|
+
validate_captcha_token,
|
29
|
+
generate_clearance_token,
|
30
|
+
combine_audio_files,
|
31
|
+
audio_bytes_to_data_url,
|
32
|
+
)
|
9
33
|
|
10
34
|
|
11
35
|
VPN_PROVIDERS = [
|
@@ -19,6 +43,26 @@ VPN_PROVIDERS = [
|
|
19
43
|
"Mullvad",
|
20
44
|
]
|
21
45
|
|
46
|
+
IMAGE_CAPTCHA_MAPPING = {
|
47
|
+
"grid": {
|
48
|
+
"num_correct": (2, 3),
|
49
|
+
"num_images": 9,
|
50
|
+
"preview_image": False,
|
51
|
+
"hardness_range": (1, 3),
|
52
|
+
},
|
53
|
+
"one_click": {
|
54
|
+
"num_correct": 1,
|
55
|
+
"num_images": 6,
|
56
|
+
"preview_image": True,
|
57
|
+
"hardness_range": (1, 3),
|
58
|
+
},
|
59
|
+
}
|
60
|
+
|
61
|
+
AUDIO_CAPTCHA_CONFIG = {
|
62
|
+
"num_chars": 6,
|
63
|
+
"language": "en",
|
64
|
+
}
|
65
|
+
|
22
66
|
logger = logging.getLogger(__name__)
|
23
67
|
|
24
68
|
|
@@ -84,8 +128,17 @@ class Humanify:
|
|
84
128
|
Protect against bots and DDoS attacks.
|
85
129
|
"""
|
86
130
|
|
87
|
-
def __init__(
|
131
|
+
def __init__(
|
132
|
+
self,
|
133
|
+
app=None,
|
134
|
+
challenge_type: str = "one_click",
|
135
|
+
image_dataset: Optional[str] = "ai_dogs",
|
136
|
+
audio_dataset: Optional[str] = None,
|
137
|
+
):
|
88
138
|
self.app = app
|
139
|
+
self.challenge_type = challenge_type
|
140
|
+
self.image_dataset = image_dataset
|
141
|
+
self.audio_dataset = audio_dataset
|
89
142
|
if app is not None:
|
90
143
|
self.init_app(app)
|
91
144
|
|
@@ -95,9 +148,13 @@ class Humanify:
|
|
95
148
|
"""
|
96
149
|
self.app = app
|
97
150
|
|
98
|
-
ensure_server_running(
|
99
|
-
|
100
|
-
|
151
|
+
ensure_server_running(
|
152
|
+
image_dataset=self.image_dataset,
|
153
|
+
audio_dataset=self.audio_dataset,
|
154
|
+
)
|
155
|
+
self.memory_client = MemoryClient()
|
156
|
+
self.memory_client.connect()
|
157
|
+
self._secret_key = self.memory_client.get_secret_key()
|
101
158
|
|
102
159
|
self.blueprint = Blueprint(
|
103
160
|
"humanify", __name__, template_folder="templates", static_folder=None
|
@@ -108,6 +165,48 @@ class Humanify:
|
|
108
165
|
def _register_routes(self) -> None:
|
109
166
|
"""Register the humanify routes."""
|
110
167
|
|
168
|
+
@self.blueprint.route("/humanify/challenge", methods=["GET"])
|
169
|
+
def challenge():
|
170
|
+
"""
|
171
|
+
Challenge route.
|
172
|
+
"""
|
173
|
+
if self.image_dataset is None:
|
174
|
+
return self._render_challenge(is_audio=True)
|
175
|
+
|
176
|
+
return self._render_challenge()
|
177
|
+
|
178
|
+
@self.blueprint.route("/humanify/audio_challenge", methods=["GET"])
|
179
|
+
def audio_challenge():
|
180
|
+
"""
|
181
|
+
Audio challenge route.
|
182
|
+
"""
|
183
|
+
if self.audio_dataset is None:
|
184
|
+
return redirect(
|
185
|
+
url_for("humanify.challenge", return_url=request.full_path)
|
186
|
+
)
|
187
|
+
|
188
|
+
return self._render_challenge(is_audio=True)
|
189
|
+
|
190
|
+
@self.blueprint.route("/humanify/verify", methods=["POST"])
|
191
|
+
def verify():
|
192
|
+
"""
|
193
|
+
Verify route.
|
194
|
+
"""
|
195
|
+
if self.image_dataset is None:
|
196
|
+
abort(404)
|
197
|
+
|
198
|
+
return self._verify_captcha()
|
199
|
+
|
200
|
+
@self.blueprint.route("/humanify/verify_audio", methods=["POST"])
|
201
|
+
def verify_audio():
|
202
|
+
"""
|
203
|
+
Verify audio route.
|
204
|
+
"""
|
205
|
+
if self.audio_dataset is None:
|
206
|
+
abort(404)
|
207
|
+
|
208
|
+
return self._verify_audio_captcha()
|
209
|
+
|
111
210
|
@self.blueprint.route("/humanify/access_denied", methods=["GET"])
|
112
211
|
def access_denied():
|
113
212
|
"""
|
@@ -121,38 +220,478 @@ class Humanify:
|
|
121
220
|
{"Cache-Control": "public, max-age=15552000"},
|
122
221
|
)
|
123
222
|
|
124
|
-
def register_middleware(
|
125
|
-
|
126
|
-
|
223
|
+
def register_middleware(
|
224
|
+
self,
|
225
|
+
action: str = "challenge",
|
226
|
+
endpoint_patterns: Union[str, List[str], None] = None,
|
227
|
+
url_patterns: Union[str, List[str], None] = None,
|
228
|
+
exclude_patterns: Union[str, List[str], None] = None,
|
229
|
+
request_filters: Optional[Dict[str, Any]] = None,
|
230
|
+
):
|
127
231
|
"""
|
232
|
+
Register the middleware with advanced filtering options.
|
128
233
|
|
234
|
+
Args:
|
235
|
+
action: The action to take when a bot is detected ('challenge' or 'deny_access')
|
236
|
+
endpoint_patterns: Endpoint patterns to match (regex or glob patterns)
|
237
|
+
url_patterns: URL patterns to match (regex or glob patterns)
|
238
|
+
exclude_patterns: Patterns to exclude from protection (regex or glob patterns)
|
239
|
+
request_filters: Dict of request attributes and values to filter by
|
240
|
+
"""
|
129
241
|
self.app = self.app or current_app
|
130
242
|
|
243
|
+
if isinstance(endpoint_patterns, str):
|
244
|
+
endpoint_patterns = [endpoint_patterns]
|
245
|
+
if isinstance(url_patterns, str):
|
246
|
+
url_patterns = [url_patterns]
|
247
|
+
if isinstance(exclude_patterns, str):
|
248
|
+
exclude_patterns = [exclude_patterns]
|
249
|
+
|
250
|
+
compiled_endpoint_patterns = (
|
251
|
+
self._compile_patterns(endpoint_patterns) if endpoint_patterns else None
|
252
|
+
)
|
253
|
+
compiled_url_patterns = (
|
254
|
+
self._compile_patterns(url_patterns) if url_patterns else None
|
255
|
+
)
|
256
|
+
compiled_exclude_patterns = (
|
257
|
+
self._compile_patterns(exclude_patterns) if exclude_patterns else None
|
258
|
+
)
|
259
|
+
|
131
260
|
@self.app.before_request
|
132
261
|
def before_request():
|
133
262
|
"""
|
134
|
-
Before request hook.
|
263
|
+
Before request hook with advanced filtering.
|
135
264
|
"""
|
136
|
-
if request.endpoint
|
265
|
+
if request.endpoint and request.endpoint.startswith("humanify."):
|
266
|
+
return
|
267
|
+
|
268
|
+
current_endpoint = request.endpoint or ""
|
269
|
+
current_path = request.path
|
270
|
+
|
271
|
+
if compiled_exclude_patterns and self._matches_any_pattern(
|
272
|
+
current_endpoint, current_path, compiled_exclude_patterns
|
273
|
+
):
|
137
274
|
return
|
138
275
|
|
139
|
-
|
276
|
+
patterns_specified = (
|
277
|
+
compiled_endpoint_patterns is not None
|
278
|
+
or compiled_url_patterns is not None
|
279
|
+
)
|
280
|
+
|
281
|
+
matches_endpoint = not patterns_specified or (
|
282
|
+
compiled_endpoint_patterns
|
283
|
+
and self._matches_any_pattern(
|
284
|
+
current_endpoint, None, compiled_endpoint_patterns
|
285
|
+
)
|
286
|
+
)
|
287
|
+
|
288
|
+
matches_url = not patterns_specified or (
|
289
|
+
compiled_url_patterns
|
290
|
+
and self._matches_any_pattern(None, current_path, compiled_url_patterns)
|
291
|
+
)
|
292
|
+
|
293
|
+
matches_request_filters = (
|
294
|
+
not request_filters or self._matches_request_filters(request_filters)
|
295
|
+
)
|
296
|
+
|
297
|
+
if (
|
298
|
+
(matches_endpoint or matches_url)
|
299
|
+
and matches_request_filters
|
300
|
+
and self.is_bot
|
301
|
+
):
|
302
|
+
if action == "challenge":
|
303
|
+
return self.challenge()
|
140
304
|
if action == "deny_access":
|
141
305
|
return self.deny_access()
|
142
306
|
|
307
|
+
def _compile_patterns(self, patterns):
|
308
|
+
"""
|
309
|
+
Compile a list of patterns into regex patterns.
|
310
|
+
Handles glob patterns like * and ? by converting them to regex.
|
311
|
+
"""
|
312
|
+
compiled = []
|
313
|
+
for pattern in patterns:
|
314
|
+
if pattern is None:
|
315
|
+
continue
|
316
|
+
|
317
|
+
if "*" in pattern or "?" in pattern:
|
318
|
+
regex_pattern = fnmatch.translate(pattern)
|
319
|
+
compiled.append(re.compile(regex_pattern))
|
320
|
+
else:
|
321
|
+
try:
|
322
|
+
compiled.append(re.compile(pattern))
|
323
|
+
except re.error:
|
324
|
+
compiled.append(re.compile(re.escape(pattern)))
|
325
|
+
|
326
|
+
return compiled
|
327
|
+
|
328
|
+
def _matches_any_pattern(
|
329
|
+
self,
|
330
|
+
endpoint: Optional[str],
|
331
|
+
path: Optional[str],
|
332
|
+
compiled_patterns: List[Pattern],
|
333
|
+
):
|
334
|
+
"""
|
335
|
+
Check if the current endpoint or path matches any of the compiled patterns.
|
336
|
+
"""
|
337
|
+
for pattern in compiled_patterns:
|
338
|
+
if endpoint is not None and pattern.search(endpoint):
|
339
|
+
return True
|
340
|
+
if path is not None and pattern.search(path):
|
341
|
+
return True
|
342
|
+
return False
|
343
|
+
|
344
|
+
def _matches_request_filters(self, request_filters: Dict[str, Any]) -> bool:
|
345
|
+
"""
|
346
|
+
Check if the current request matches all the specified filters.
|
347
|
+
Filters can target any attribute of the request object or its nested properties.
|
348
|
+
"""
|
349
|
+
for key, value in request_filters.items():
|
350
|
+
parts = key.split(".")
|
351
|
+
obj = request
|
352
|
+
|
353
|
+
for part in parts[:-1]:
|
354
|
+
if hasattr(obj, part):
|
355
|
+
obj = getattr(obj, part)
|
356
|
+
elif isinstance(obj, dict) and part in obj:
|
357
|
+
obj = obj[part]
|
358
|
+
else:
|
359
|
+
return False
|
360
|
+
|
361
|
+
final_attr = parts[-1]
|
362
|
+
|
363
|
+
if hasattr(obj, final_attr):
|
364
|
+
attr_value = getattr(obj, final_attr)
|
365
|
+
elif isinstance(obj, dict) and final_attr in obj:
|
366
|
+
attr_value = obj[final_attr]
|
367
|
+
else:
|
368
|
+
return False
|
369
|
+
|
370
|
+
if isinstance(value, str) and value.startswith("regex:"):
|
371
|
+
regex_pattern = value[6:]
|
372
|
+
try:
|
373
|
+
if not re.search(regex_pattern, str(attr_value)):
|
374
|
+
return False
|
375
|
+
except (re.error, TypeError):
|
376
|
+
return False
|
377
|
+
elif isinstance(value, list):
|
378
|
+
if attr_value not in value:
|
379
|
+
return False
|
380
|
+
elif attr_value != value:
|
381
|
+
return False
|
382
|
+
|
383
|
+
return True
|
384
|
+
|
385
|
+
@property
|
386
|
+
def client_ip(self) -> Optional[str]:
|
387
|
+
"""Get the client IP address."""
|
388
|
+
if hasattr(g, "humanify_client_ip"):
|
389
|
+
return g.humanify_client_ip
|
390
|
+
|
391
|
+
client_ip = get_client_ip(request)
|
392
|
+
g.humanify_client_ip = client_ip
|
393
|
+
return client_ip
|
394
|
+
|
143
395
|
@property
|
144
|
-
def
|
396
|
+
def check_result(self) -> HumanifyResult:
|
145
397
|
"""
|
146
398
|
Check if the IP is a bot.
|
147
399
|
"""
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
400
|
+
if self.client_ip is None:
|
401
|
+
return HumanifyResult(ip=self.client_ip, is_invalid_ip=True)
|
402
|
+
|
403
|
+
if hasattr(g, "humanify_ip_groups"):
|
404
|
+
humanify_ip_groups = g.humanify_ip_groups
|
405
|
+
if isinstance(humanify_ip_groups, list):
|
406
|
+
return HumanifyResult.from_ip_groups(self.client_ip, humanify_ip_groups)
|
407
|
+
|
408
|
+
ip_groups = self.memory_client.lookup_ip(self.client_ip)
|
409
|
+
g.humanify_ip_groups = ip_groups
|
410
|
+
return HumanifyResult.from_ip_groups(self.client_ip, ip_groups)
|
411
|
+
|
412
|
+
@property
|
413
|
+
def has_valid_clearance_token(self) -> bool:
|
414
|
+
"""Check if the current client has a valid clearance token."""
|
415
|
+
return validate_clearance_token(
|
416
|
+
request.cookies.get("clearance_token", ""),
|
417
|
+
self._secret_key,
|
418
|
+
generate_user_hash(
|
419
|
+
self.client_ip or "127.0.0.1",
|
420
|
+
request.user_agent.string or "",
|
421
|
+
),
|
422
|
+
)
|
423
|
+
|
424
|
+
@property
|
425
|
+
def is_bot(self) -> bool:
|
426
|
+
"""Check if the current client is a bot."""
|
427
|
+
return not self.has_valid_clearance_token and self.check_result.is_bot
|
153
428
|
|
154
429
|
def deny_access(self) -> Response:
|
155
430
|
"""
|
156
431
|
Redirect to the access denied page.
|
157
432
|
"""
|
158
433
|
return redirect(url_for("humanify.access_denied", return_url=request.full_path))
|
434
|
+
|
435
|
+
def challenge(self) -> Response:
|
436
|
+
"""
|
437
|
+
Challenge the client.
|
438
|
+
"""
|
439
|
+
return redirect(url_for("humanify.challenge", return_url=request.full_path))
|
440
|
+
|
441
|
+
def _render_challenge(self, is_audio: bool = False) -> Response:
|
442
|
+
return_url = get_return_url(request)
|
443
|
+
if self.has_valid_clearance_token:
|
444
|
+
return redirect(return_url)
|
445
|
+
|
446
|
+
error = request.args.get("error", None)
|
447
|
+
if error not in [
|
448
|
+
"Invalid captcha token",
|
449
|
+
"Wrong selection. Try again.",
|
450
|
+
"Wrong response. Try again.",
|
451
|
+
]:
|
452
|
+
error = None
|
453
|
+
|
454
|
+
if is_audio:
|
455
|
+
return self._render_audio_challenge(return_url, error)
|
456
|
+
|
457
|
+
if self.challenge_type in ["grid", "one_click"]:
|
458
|
+
return self._render_image_challenge(return_url, error)
|
459
|
+
|
460
|
+
abort(404, "Invalid challenge type")
|
461
|
+
|
462
|
+
def _render_image_challenge(
|
463
|
+
self, return_url: str, error: Optional[str]
|
464
|
+
) -> Response:
|
465
|
+
"""
|
466
|
+
Render the image challenge.
|
467
|
+
"""
|
468
|
+
|
469
|
+
captcha_config = IMAGE_CAPTCHA_MAPPING[self.challenge_type]
|
470
|
+
use_preview_image = captcha_config["preview_image"]
|
471
|
+
|
472
|
+
images_bytes, correct_indexes, subject = self.memory_client.get_captcha_images(
|
473
|
+
num_correct=captcha_config["num_correct"],
|
474
|
+
num_images=captcha_config["num_images"],
|
475
|
+
preview_image=use_preview_image,
|
476
|
+
dataset_name=self.image_dataset,
|
477
|
+
)
|
478
|
+
|
479
|
+
if not images_bytes:
|
480
|
+
abort(500, "Could not load captcha images")
|
481
|
+
|
482
|
+
processed_images = []
|
483
|
+
for i, img_bytes in enumerate(images_bytes):
|
484
|
+
try:
|
485
|
+
distorted_img_bytes = manipulate_image_bytes(
|
486
|
+
img_bytes,
|
487
|
+
is_small=not (i == 0 and use_preview_image),
|
488
|
+
hardness=random.randint(
|
489
|
+
captcha_config["hardness_range"][0],
|
490
|
+
captcha_config["hardness_range"][1],
|
491
|
+
),
|
492
|
+
)
|
493
|
+
processed_images.append(image_bytes_to_data_url(distorted_img_bytes))
|
494
|
+
except Exception as e:
|
495
|
+
current_app.logger.error(f"Error processing image: {e}")
|
496
|
+
processed_images.append(
|
497
|
+
(
|
498
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
499
|
+
"CAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
500
|
+
)
|
501
|
+
)
|
502
|
+
|
503
|
+
preview_image = None
|
504
|
+
if use_preview_image:
|
505
|
+
preview_image = processed_images[0]
|
506
|
+
processed_images = processed_images[1:]
|
507
|
+
|
508
|
+
user_hash = generate_user_hash(
|
509
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
510
|
+
)
|
511
|
+
captcha_data = generate_captcha_token(
|
512
|
+
user_hash, correct_indexes, self._secret_key
|
513
|
+
)
|
514
|
+
|
515
|
+
return Response(
|
516
|
+
render_template(
|
517
|
+
f"{self.challenge_type}_challenge.html",
|
518
|
+
images=processed_images,
|
519
|
+
preview_image=preview_image,
|
520
|
+
subject=subject,
|
521
|
+
captcha_data=captcha_data,
|
522
|
+
return_url=return_url or "/",
|
523
|
+
error=error,
|
524
|
+
audio_challenge_available=self.audio_dataset is not None,
|
525
|
+
),
|
526
|
+
mimetype="text/html",
|
527
|
+
)
|
528
|
+
|
529
|
+
def _render_audio_challenge(
|
530
|
+
self, return_url: str, error: Optional[str]
|
531
|
+
) -> Response:
|
532
|
+
"""
|
533
|
+
Render the audio challenge.
|
534
|
+
"""
|
535
|
+
num_chars = AUDIO_CAPTCHA_CONFIG["num_chars"]
|
536
|
+
language = AUDIO_CAPTCHA_CONFIG["language"]
|
537
|
+
|
538
|
+
audio_files, correct_chars = self.memory_client.get_captcha_audio(
|
539
|
+
num_chars=num_chars, language=language
|
540
|
+
)
|
541
|
+
|
542
|
+
if not audio_files:
|
543
|
+
abort(500, "Could not load captcha audio")
|
544
|
+
|
545
|
+
combined_audio = combine_audio_files(audio_files)
|
546
|
+
if not combined_audio:
|
547
|
+
abort(500, "Could not process audio files")
|
548
|
+
|
549
|
+
audio_data_url = audio_bytes_to_data_url(combined_audio, "mp3")
|
550
|
+
|
551
|
+
user_hash = generate_user_hash(
|
552
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
553
|
+
)
|
554
|
+
captcha_data = generate_captcha_token(
|
555
|
+
user_hash, correct_chars, self._secret_key
|
556
|
+
)
|
557
|
+
|
558
|
+
return Response(
|
559
|
+
render_template(
|
560
|
+
"audio_challenge.html",
|
561
|
+
audio_file=audio_data_url,
|
562
|
+
captcha_data=captcha_data,
|
563
|
+
return_url=return_url or "/",
|
564
|
+
error=error,
|
565
|
+
image_challenge_available=self.image_dataset is not None,
|
566
|
+
),
|
567
|
+
mimetype="text/html",
|
568
|
+
)
|
569
|
+
|
570
|
+
def _verify_captcha(self) -> Response:
|
571
|
+
"""Verify the captcha solution."""
|
572
|
+
return_url = get_return_url(request)
|
573
|
+
if self.has_valid_clearance_token:
|
574
|
+
return redirect(return_url)
|
575
|
+
|
576
|
+
captcha_data = request.form.get("captcha_data", "")
|
577
|
+
if not captcha_data:
|
578
|
+
return redirect(
|
579
|
+
url_for(
|
580
|
+
"humanify.challenge",
|
581
|
+
error="Invalid captcha token",
|
582
|
+
return_url=return_url,
|
583
|
+
)
|
584
|
+
)
|
585
|
+
|
586
|
+
user_hash = generate_user_hash(
|
587
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
588
|
+
)
|
589
|
+
decrypted_data = validate_captcha_token(
|
590
|
+
captcha_data, self._secret_key, user_hash
|
591
|
+
)
|
592
|
+
|
593
|
+
if decrypted_data is None:
|
594
|
+
return redirect(
|
595
|
+
url_for(
|
596
|
+
"humanify.challenge",
|
597
|
+
error="Invalid captcha token",
|
598
|
+
return_url=return_url,
|
599
|
+
)
|
600
|
+
)
|
601
|
+
|
602
|
+
verify_functions = {
|
603
|
+
"grid": self._verify_image_captcha,
|
604
|
+
"one_click": self._verify_image_captcha,
|
605
|
+
}
|
606
|
+
|
607
|
+
verify_function = verify_functions[self.challenge_type]
|
608
|
+
if not verify_function(decrypted_data):
|
609
|
+
return redirect(
|
610
|
+
url_for(
|
611
|
+
"humanify.challenge",
|
612
|
+
error="Wrong selection. Try again.",
|
613
|
+
return_url=return_url,
|
614
|
+
)
|
615
|
+
)
|
616
|
+
|
617
|
+
clearance_token = generate_clearance_token(user_hash, self._secret_key)
|
618
|
+
|
619
|
+
response = redirect(return_url or "/")
|
620
|
+
response.set_cookie(
|
621
|
+
"clearance_token",
|
622
|
+
clearance_token,
|
623
|
+
max_age=14400,
|
624
|
+
httponly=True,
|
625
|
+
samesite="Strict",
|
626
|
+
)
|
627
|
+
|
628
|
+
return response
|
629
|
+
|
630
|
+
def _verify_audio_captcha(self) -> Response:
|
631
|
+
"""Verify the audio captcha solution."""
|
632
|
+
return_url = get_return_url(request)
|
633
|
+
if self.has_valid_clearance_token:
|
634
|
+
return redirect(return_url)
|
635
|
+
|
636
|
+
captcha_data = request.form.get("captcha_data", "")
|
637
|
+
if not captcha_data:
|
638
|
+
return redirect(
|
639
|
+
url_for(
|
640
|
+
"humanify.audio_challenge",
|
641
|
+
error="Invalid captcha token",
|
642
|
+
return_url=return_url,
|
643
|
+
)
|
644
|
+
)
|
645
|
+
|
646
|
+
user_hash = generate_user_hash(
|
647
|
+
self.client_ip or "127.0.0.1", request.user_agent.string or ""
|
648
|
+
)
|
649
|
+
correct_chars = validate_captcha_token(
|
650
|
+
captcha_data, self._secret_key, user_hash, valid_lengths=[197]
|
651
|
+
)
|
652
|
+
|
653
|
+
if correct_chars is None:
|
654
|
+
return redirect(
|
655
|
+
url_for(
|
656
|
+
"humanify.audio_challenge",
|
657
|
+
error="Invalid captcha token",
|
658
|
+
return_url=return_url,
|
659
|
+
)
|
660
|
+
)
|
661
|
+
|
662
|
+
audio_response = request.form.get("audio_response", "").lower().strip()
|
663
|
+
if not audio_response or audio_response != correct_chars:
|
664
|
+
return redirect(
|
665
|
+
url_for(
|
666
|
+
"humanify.audio_challenge",
|
667
|
+
error="Wrong response. Try again.",
|
668
|
+
return_url=return_url,
|
669
|
+
)
|
670
|
+
)
|
671
|
+
|
672
|
+
clearance_token = generate_clearance_token(user_hash, self._secret_key)
|
673
|
+
|
674
|
+
response = redirect(return_url or "/")
|
675
|
+
response.set_cookie(
|
676
|
+
"clearance_token",
|
677
|
+
clearance_token,
|
678
|
+
max_age=14400,
|
679
|
+
httponly=True,
|
680
|
+
samesite="Strict",
|
681
|
+
)
|
682
|
+
|
683
|
+
return response
|
684
|
+
|
685
|
+
def _verify_image_captcha(self, decrypted_data: str) -> bool:
|
686
|
+
"""Verify the image captcha."""
|
687
|
+
captcha_config = IMAGE_CAPTCHA_MAPPING[self.challenge_type]
|
688
|
+
|
689
|
+
selected_indexes = []
|
690
|
+
for i in range(1, captcha_config["num_images"] + 1):
|
691
|
+
if request.form.get(str(i), None) == "1":
|
692
|
+
selected_indexes.append(str(i - 1))
|
693
|
+
|
694
|
+
selected_str = "".join(sorted(selected_indexes))
|
695
|
+
correct_str = "".join(sorted(list(decrypted_data)))
|
696
|
+
|
697
|
+
return selected_str == correct_str
|