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.
@@ -4,7 +4,7 @@ Flask-Humanify
4
4
  A Flask extension that protects against bots and DDoS attacks.
5
5
  """
6
6
 
7
- __version__ = "0.1.3.2"
7
+ __version__ = "0.2.0"
8
8
 
9
9
  from . import utils
10
10
  from .humanify import Humanify
Binary file
Binary file
Binary file
@@ -11,7 +11,7 @@ class RateLimiter:
11
11
  Rate limiter.
12
12
  """
13
13
 
14
- def __init__(self, app=None, max_requests: int = 2, time_window: int = 10):
14
+ def __init__(self, app=None, max_requests: int = 10, time_window: int = 10):
15
15
  """
16
16
  Initialize the rate limiter.
17
17
  """
@@ -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 Blueprint, request, render_template, redirect, url_for, current_app
7
- from .ipset import IPSetClient, ensure_server_running
8
- from .utils import get_client_ip, get_return_url
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__(self, app=None):
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
- self.ipset_client = IPSetClient()
100
- self.ipset_client.connect()
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="static"
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 = "deny_access"):
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 in ["humanify.rate_limited", "humanify.access_denied"]:
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 is_bot(self) -> HumanifyResult:
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
- ip = get_client_ip(request)
149
- if ip is None:
150
- return HumanifyResult(ip=ip, is_invalid_ip=True)
151
- ip_groups = self.ipset_client.lookup_ip(ip)
152
- return HumanifyResult.from_ip_groups(ip, ip_groups)
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