diff --git a/README.md b/README.md index 05c3229..0120265 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,13 @@ hoptchaPost('/endpoint', payload, onSuccess, onError, function renderCustom(url, | Parameter | Description | Default | |-------------|--------------------------------------------------------------|---------------| +| `key` | Function or string to identify requestor (IP, user ID, etc.) | `ip` | | `threshold` | Number of allowed attempts before CAPTCHA is required | `5` | | `timeout` | Time in seconds to reset attempt count | `300` (5 min) | -| `key` | Function or string to identify requestor (IP, user ID, etc.) | `ip` | - +| `backoff` | Exponentially increase timeout if repeatedly exceeded | `False` | +| `response` | Optional custom response function on CAPTCHA failure | `None` | +| `exempt_if` | Skip protection for trusted users | For staff | +| `methods` | HTTP methods to track (POST, GET, etc.) | POST | --- diff --git a/django_hoptcha/decorators.py b/django_hoptcha/decorators.py index 0208a31..953ced1 100644 --- a/django_hoptcha/decorators.py +++ b/django_hoptcha/decorators.py @@ -25,41 +25,83 @@ SOFTWARE. from functools import wraps from django.core.cache import cache from django.http import JsonResponse + from .validators import verify_token from .settings import GENERATE_URL, PUBLIC_KEY -def get_builtin_key_func(name): - if name == 'ip': - return lambda request: request.META.get('REMOTE_ADDR', 'unknown-ip') - raise ValueError(f"Unknown built-in key: '{name}'") +# Built-in key functions +def get_ip(request): + return request.META.get('REMOTE_ADDR', 'unknown-ip') -def hoptcha_protected(threshold=5, timeout=300, key='ip'): - """ - Decorator that applies CAPTCHA verification after `threshold` failed attempts, - tracked via cache using a customizable key. The `key` can be: - - a string like 'ip' to use a built-in method - - a callable that accepts a Django request and returns a string - """ +def get_user(request): + return str(request.user.id) if request.user.is_authenticated else None - key_func = get_builtin_key_func(key) if isinstance(key, str) else key +def get_user_or_ip(request): + return get_user(request) or get_ip(request) + +BUILTIN_KEYS = { + 'ip': get_ip, + 'user': get_user, + 'user_or_ip': get_user_or_ip, +} + +def hoptcha_protected( + threshold=5, + timeout=300, + key="ip", + methods=["POST"], + response=None, + exempt_if=lambda request: request.user.is_staff or request.user.is_superuser, + backoff=False, +): + """ + Enforces CAPTCHA if request exceeds `threshold`. + + - key: 'ip', 'user', 'user_or_ip', or custom function. + - threshold: # of allowed unauthenticated attempts before requiring CAPTCHA. + - timeout: seconds to keep attempt count in cache. + - backoff: exponentially increase timeout if repeatedly exceeded. + - response: optional custom response function on CAPTCHA failure. + - exempt_if: skip protection for trusted users. + - methods: HTTP methods to track (default: POST). + """ + if isinstance(key, str): + key_func = BUILTIN_KEYS.get(key) + if not key_func: + raise ValueError(f"Unknown key: {key}") + elif callable(key): + key_func = key + else: + raise TypeError("key must be a string or callable") def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): - key_val = key_func(request) - cache_key = f"hoptcha-attempts:{key_val}" + if exempt_if and exempt_if(request): + return view_func(request, *args, **kwargs) + + if request.method not in methods: + return view_func(request, *args, **kwargs) + + user_key = key_func(request) + cache_key = f"hoptcha-attempts:{user_key}" attempts = cache.get(cache_key, 0) if attempts >= threshold: token = request.POST.get("captcha_token") or request.GET.get("captcha_token") if not token or not verify_token(token): - return JsonResponse({ + return response(request) if response else JsonResponse({ "error": "CAPTCHA", "url": GENERATE_URL, "key": PUBLIC_KEY }, status=400) + else: + cache.delete(cache_key) # reset counter if passed + + timeout_val = timeout * (2 ** (attempts - threshold)) if backoff and attempts >= threshold else timeout + cache.set(cache_key, attempts + 1, timeout=timeout_val) - cache.set(cache_key, attempts + 1, timeout=timeout) return view_func(request, *args, **kwargs) + return _wrapped_view return decorator