Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recording exceeded limits #212

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
abebea9
Models for storing exceeded limits in DB
Nov 3, 2020
4eff2e6
Base and Proxy classes to handle different record designs
Nov 3, 2020
b960416
Database handler for recording exceeded limits
Nov 3, 2020
6e19b9f
Cache handler for recording exceeded limits in cache
Nov 3, 2020
0e99373
Model migrations
Nov 3, 2020
9e81281
Seperation of concerns
Nov 3, 2020
c487fd8
Admin panel for exceeded limit records
Nov 3, 2020
f25542b
Using django-ipware for address resolution
Nov 3, 2020
95eb117
Using proxy for recording limits in decorator
Nov 3, 2020
19a0c86
Provide fieldsets for admin panel
Nov 3, 2020
288a402
Fix get_client_cache_key arguments
Nov 3, 2020
244ce0c
Setting config for tests
Nov 5, 2020
05798ba
Re-structure tests
Nov 5, 2020
49f9a28
Test admin panel
Nov 5, 2020
1888236
Migration test
Nov 5, 2020
4c96780
Test helpers
Nov 5, 2020
f3aed44
Testing proxy implementation.
Nov 5, 2020
11a0b58
Test database record handler
Nov 5, 2020
2431e34
Docs for ratelimit record
Nov 5, 2020
808cd0f
Fix installed apps, remove redundant
Nov 5, 2020
78ff799
Merge pull request #1 from mohammadrabetian/attempt-records
Nov 5, 2020
cc0de8b
Substitude (path, f-string) with (urls, format)
Nov 6, 2020
f060d09
Merge branch 'main' into update-ratelimit
Jun 20, 2021
c2306c4
Merge pull request #3 from mohammadrabetian/update-ratelimit
Jun 21, 2021
0818618
Efficient update for access attempts
Jun 21, 2021
9f4c499
Test for more efficient access attempt update
Jun 21, 2021
d39f52c
Merge pull request #4 from mohammadrabetian/efficient-access-attempts…
Jun 21, 2021
0b8a757
Update README.rst
Jul 8, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,47 @@ variable.
:License: Apache Software License 2.0; see LICENSE file
:Issues: https://github.com/jsocol/django-ratelimit/issues
:Documentation: http://django-ratelimit.readthedocs.io/

**Note** This part of the documentation is not included in the above link, so check below.


``Documentation specific to the ratelimit-recorder``
------
``RATELIMIT_RECORD``
--------------------

Whether to record exceeded limits to a cache or database backend. Defaults to ``False``

``RATELIMIT_RECORD_HANDLER``
----------------------------

If you have set ``RATELIMIT_RECORD`` to ``True`` you can also provide an optional handler value
with ``RATELIMIT_RECORD_HANDLER`` setting to define whether the exceeded limits get recorded to cache,
or database backend. There are trade offs, cache backend is fast and doesn't become a bottleneck for performance,
but it isn't as secure as a database backend. Using a database backend could be an expensive,
but more secure way to record your logs depending on your throughput.

Defaults to ``ratelimit.record_handlers.database.DatabaseRecordHandler``

- ``ratelimit.record_handlers.database.DatabaseRecordHandler``

- ``ratelimit.record_handlers.cache.CacheRecordHandler``

You should provide one of the above as a string , e.g.

.. code-block:: python
RATELIMIT_RECORD_HANDLER = "ratelimit.record_handlers.cache.CacheRecordHandler"
``RATELIMIT_ENABLE_ADMIN``
--------------------------

If you want to disable admin panel for ratelimit records you can set this setting to ``False``.
Defaults to ``True``

``RATELIMIT_CACHE_RECORD_TIME``
-------------------------------

When you're recording your logs in cache, you can provide an optional value to this setting to
purge the data in cache automatically.
Can be set to a Python timedelta object, an integer, a callable,
or a string path to a callable which takes no arguments. The integers are interpreted as hours.
Defaults to six days.
6 changes: 4 additions & 2 deletions django_ratelimit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
VERSION = (3, 0, 1)
__version__ = '.'.join(map(str, VERSION))
__version__ = ".".join(map(str, VERSION))

ALL = (None,) # Sentinel value for all HTTP methods.
UNSAFE = ['DELETE', 'PATCH', 'POST', 'PUT']
UNSAFE = ["DELETE", "PATCH", "POST", "PUT"]

default_app_config = "django_ratelimit.apps.RateLimitConfig"
45 changes: 45 additions & 0 deletions django_ratelimit/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from django_ratelimit.conf import settings
from django_ratelimit.models import ExceededLimitRecord


class ExceededLimitRecordAdmin(admin.ModelAdmin):
list_display = (
"blocked_at",
"last_blocked_at",
"ip_address",
"user_agent",
"username",
"path_info",
"access_attempt_failures",
)

list_filter = ["blocked_at", "path_info", "last_blocked_at"]

fieldsets = (
(None, {"fields": ("path_info", "access_attempt_failures")}),
(_("Meta Data"), {"fields": ("user_agent", "ip_address")}),
)

search_fields = ["ip_address", "username", "user_agent", "path_info"]

date_hierarchy = "last_blocked_at"

readonly_fields = [
"user_agent",
"ip_address",
"username",
"path_info",
"blocked_at",
"access_attempt_failures",
"last_blocked_at",
]

def has_add_permission(self, request):
return False


if settings.RATELIMIT_ENABLE_ADMIN:
admin.site.register(ExceededLimitRecord, ExceededLimitRecordAdmin)
10 changes: 10 additions & 0 deletions django_ratelimit/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from __future__ import unicode_literals

from django.apps import AppConfig


class RateLimitConfig(AppConfig):
name = "django_ratelimit"

def ready(self):
import django_ratelimit.signals
22 changes: 22 additions & 0 deletions django_ratelimit/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.conf import settings

# if True, records exceeded limits and attempts
settings.RATELIMIT_RECORD = getattr(settings, "RATELIMIT_RECORD", False)

# timeout time to store records in cache
# six days by default
SIX_DAYS = 6 * 24
settings.RATELIMIT_CACHE_RECORD_TIME = getattr(
settings, "RATELIMIT_CACHE_RECORD_TIME", SIX_DAYS
)

# register an admin panel for records
settings.RATELIMIT_ENABLE_ADMIN = getattr(settings, "RATELIMIT_ENABLE_ADMIN", True)

# cache or database record handler
# database by default
settings.RATELIMIT_RECORD_HANDLER = getattr(
settings,
"RATELIMIT_RECORD_HANDLER",
"django_ratelimit.record_handlers.database.DatabaseRecordHandler",
)
24 changes: 17 additions & 7 deletions django_ratelimit/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,36 @@
from functools import wraps

from django_ratelimit import ALL, UNSAFE
from django_ratelimit.exceptions import Ratelimited
from django_ratelimit.core import is_ratelimited
from django_ratelimit.exceptions import Ratelimited
from django_ratelimit.record_handlers.proxy import RateLimitRecordProxy


__all__ = ['ratelimit']
__all__ = ["ratelimit"]


def ratelimit(group=None, key=None, rate=None, method=ALL, block=False):
def decorator(fn):
@wraps(fn)
def _wrapped(request, *args, **kw):
old_limited = getattr(request, 'limited', False)
ratelimited = is_ratelimited(request=request, group=group, fn=fn,
key=key, rate=rate, method=method,
increment=True)
old_limited = getattr(request, "limited", False)
ratelimited = is_ratelimited(
request=request,
group=group,
fn=fn,
key=key,
rate=rate,
method=method,
increment=True,
)
request.limited = ratelimited or old_limited
if ratelimited:
RateLimitRecordProxy.exceeded_limit_record(request=request)
if ratelimited and block:
raise Ratelimited()
return fn(request, *args, **kw)

return _wrapped

return decorator


Expand Down
30 changes: 30 additions & 0 deletions django_ratelimit/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 2.2 on 2020-11-03 11:51

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='ExceededLimitRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_agent', models.CharField(max_length=255, verbose_name='User Agent')),
('ip_address', models.GenericIPAddressField(db_index=True, null=True, verbose_name='IP Address')),
('username', models.CharField(db_index=True, max_length=255, null=True, verbose_name='Username')),
('path_info', models.CharField(max_length=255, verbose_name='Path')),
('blocked_at', models.DateTimeField(auto_now_add=True, verbose_name='Blocked At')),
('access_attempt_failures', models.PositiveIntegerField(verbose_name='Access Attempt Failure Count')),
],
options={
'verbose_name': 'exceeded limit record',
'verbose_name_plural': 'exceeded limit records',
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2020-11-03 17:40

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("django_ratelimit", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="exceededlimitrecord",
name="last_blocked_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last Blocked At"),
),
]
Empty file.
30 changes: 29 additions & 1 deletion django_ratelimit/models.py
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
# This module intentionally left blank.
from django.db import models
from django.utils.translation import gettext_lazy as _


class BaseModel(models.Model):
user_agent = models.CharField(_("User Agent"), max_length=255)
ip_address = models.GenericIPAddressField(_("IP Address"), null=True, db_index=True)
username = models.CharField(_("Username"), max_length=255, null=True, db_index=True)
path_info = models.CharField(_("Path"), max_length=255)
blocked_at = models.DateTimeField(_("Blocked At"), auto_now_add=True)

class Meta:
abstract = True
app_label = "django_ratelimit"
ordering = ["-blocked_at"]


class ExceededLimitRecord(BaseModel):
access_attempt_failures = models.PositiveIntegerField(
_("Access Attempt Failure Count")
)
last_blocked_at = models.DateTimeField(_("Last Blocked At"), auto_now=True)

def __str__(self):
return "{}".format(self.access_attempt_failures)

class Meta:
verbose_name = _("exceeded limit record")
verbose_name_plural = _("exceeded limit records")
Empty file.
20 changes: 20 additions & 0 deletions django_ratelimit/record_handlers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod


class AbstractRateLimitRecordHandler(ABC):
@abstractmethod
def exceeded_limit_record(self, request) -> None:
"""
Checks and creates a record of exceeded limits if needed.

This is a virtual method that needs an implementation in the handler subclass
if the ``settings.RATELIMIT_RECORD`` flag is set to ``True``.
"""
raise NotImplementedError("exceeded_limit_record should be implemented")


class RateLimitRecordHandler(AbstractRateLimitRecordHandler):
def exceeded_limit_record(self, request) -> None:
"""
Default bare handler
"""
47 changes: 47 additions & 0 deletions django_ratelimit/record_handlers/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from logging import getLogger

from django_ratelimit.record_handlers.base import AbstractRateLimitRecordHandler
from django_ratelimit.record_handlers.helpers import (
get_cache,
get_cache_timeout,
get_client_cache_key,
get_client_ip_address,
get_client_user_agent,
get_client_username,
)

log = getLogger(__name__)


class CacheRecordHandler(AbstractRateLimitRecordHandler):
"""
Handler implementation for limit exceeded records in cache
"""

def __init__(self) -> None:
self.cache = get_cache()
self.cache_timeout = get_cache_timeout()

def exceeded_limit_record(self, request):
"""
When rate limit for api is exceeded, save attempt record in cache,
and if it already exists update access attempt failures.
"""

if request is None:
log.error(
"DRL: CacheRecordHandler.exceeded_limit_record does not function without a request."
)
return

username = get_client_username(request)
ip_address = get_client_ip_address(request)
user_agent = get_client_user_agent(request)

cache_key = get_client_cache_key(username, ip_address, user_agent)
attempts = self.cache.get(cache_key, default=dict()).get("attempts", 0)
self.cache.set(
cache_key,
{"attempts": attempts + 1, "ip_address": ip_address},
self.cache_timeout,
)
53 changes: 53 additions & 0 deletions django_ratelimit/record_handlers/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from logging import getLogger

from django.db.models import F
from django_ratelimit.models import ExceededLimitRecord
from django_ratelimit.record_handlers.base import AbstractRateLimitRecordHandler
from django_ratelimit.record_handlers.helpers import (
get_client_ip_address,
get_client_path_info,
get_client_user_agent,
get_client_username,
)

log = getLogger(__name__)


class DatabaseRecordHandler(AbstractRateLimitRecordHandler):
"""
Handler implementation for limit exceeded records in database.
"""

def exceeded_limit_record(self, request) -> None:
"""
When rate limit for api is exceeded, save attempt record in database,
and if it already exists update access attempt failures.
"""
if request is None:
log.error(
"DRL: DatabaseRecordHandler.exceeded_limit_record does not function without a request."
)
return
client_ip_address = get_client_ip_address(request)
username = get_client_username(request)
path_info = get_client_path_info(request)
user_agent = get_client_user_agent(request)

try:
limit_record = ExceededLimitRecord.objects.only("id").get(
user_agent=user_agent,
ip_address=client_ip_address,
username=username,
path_info=path_info,
)
limit_record.access_attempt_failures = F("access_attempt_failures") + 1
limit_record.save()

except ExceededLimitRecord.DoesNotExist:
ExceededLimitRecord.objects.create(
user_agent=user_agent,
ip_address=client_ip_address,
username=username,
path_info=path_info,
access_attempt_failures=1,
)
Loading