diff --git a/.travis.yml b/.travis.yml index 2ca5d4f..0399b66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,46 @@ language: python +sudo: false env: - - DJANGO_VERSION=1.4 - - DJANGO_VERSION=1.5 - - DJANGO_VERSION=1.6 + - DJANGO_VERSION=1.11 + - DJANGO_VERSION=2.0 + - DJANGO_VERSION=2.1 + - DJANGO_VERSION=master python: - - "2.6" - "2.7" + - "3.4" + - "3.5" + - "3.6" + - "pypy" install: - - pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99" -script: ./run.sh test + - if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -q python-memcached>=1.57; fi + - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -q python3-memcached>=1.51; fi + - if [[ $TRAVIS_PYTHON_VERSION == pypy ]]; then pip install -q python-memcached>=1.57; fi + - if [[ $DJANGO_VERSION != master ]]; then pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99"; fi + - if [[ $DJANGO_VERSION == master ]]; then pip install https://github.com/django/django/archive/master.tar.gz; fi + - pip install "redis<3" django-redis==4.9.0 flake8 +script: + - ./run.sh test + - ./run.sh flake8 matrix: - include: - - python: 3.3 - env: DJANGO_VERSION=1.5 - - python: 3.3 - env: DJANGO_VERSION=1.6 + exclude: + - python: "2.7" + env: DJANGO_VERSION=2.0 + - python: "2.7" + env: DJANGO_VERSION=2.1 + - python: "2.7" + env: DJANGO_VERSION=master + - python: "3.4" + env: DJANGO_VERSION=2.1 + - python: "3.4" + env: DJANGO_VERSION=master + - python: "pypy" + env: DJANGO_VERSION=2.0 + - python: "pypy" + env: DJANGO_VERSION=2.1 + - python: "pypy" + env: DJANGO_VERSION=master + allow_failures: + - python: "3.5" + env: DJANGO_VERSION=master + - python: "3.6" + env: DJANGO_VERSION=master diff --git a/CHANGELOG b/CHANGELOG index d02361f..9f8fc90 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,71 @@ Change Log ========== +Pending +======= + +- New release notes here. + +v2.0.0 +====== + +- A number of docs fixes +- Fail open when cache is unavailable +- Drop support for Django 1.8, 1.9, and 1.10 +- Fix Django 2.0 compatibility and update documentation +- Test Django 2.1 support + +v1.1.0 +====== + +- Test against Django 1.11 and 2.0b +- Fix #85, explicitly set cache expiration slightly longer than cache + window. +- Add Django version classifiers. + +v1.0.1 +====== + +- Added Django 1.10 support. + +v1.0.0 +====== + +- Allow requests through when cache backend is unavailable. +- Add support for Django 1.9, drop support for Django <=1.7. +- Fix several small documentation issues. +- Fix support for missing headers. + +v0.6 +==== + +- Fix CBV inheritance. +- Better Django 1.8 support, fixing deprecation warnings and testing. +- Clean up some out-of-date docs. +- Fix counting behavior around increment and new cache keys. +- Correctly pass `group` to callable `key`s. + +v0.5 +==== + +- Rates are now counted in fixed—instead of sliding—windows, except for + per-second limits. See the Upgrade Notes. +- Mixin renamed to `RatelimitMixin` (lowercase `l`) for consistency. +- Dramatic rewrite. + - `ip`, `field`, and `keys` arguments replaced with `key`. + - well-known "key" values support. +- Custom callable rate functions. + - Support for "not limited" rate. + - Replaces ``skip_if`` argument. + +v0.4 +==== + +- (Sort of) make @ratelimit decorators stack. +- Add RateLimitMixin for CBVs. +- Fixes for Python <2.7. +- Clean up Travis and tox tests. + v0.3 ==== diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..e329392 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,22 @@ +============ +Contributing +============ + + +For set up, tests, and code standards, see `the documentation`_. + + +Client IP Address +================= + +Because this comes up frequently: + +I will not accept a pull request or issue attempting to handle client +IP address when Django is behind a proxy. + +*Ratelimit is the wrong place for this.* There are more details in the +`security chapter`_ of the documentation. + + +.. _the documentation: https://django-ratelimit.readthedocs.org/en/latest/contributing.html +.. _security chapter: https://django-ratelimit.readthedocs.org/en/latest/security.html#client-ip-address diff --git a/LICENSE b/LICENSE index 155fc8e..6143ab9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013, James Socol +Copyright (c) 2014, James Socol Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.rst b/README.rst index 9e388e5..748e2a0 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,6 @@ variable. :target: https://travis-ci.org/jsocol/django-ratelimit :Code: https://github.com/jsocol/django-ratelimit -:License: BSD; see LICENSE file +:License: Apache Software License 2.0; see LICENSE file :Issues: https://github.com/jsocol/django-ratelimit/issues -:Documentation: http://django-ratelimit.readthedocs.org/ +:Documentation: http://django-ratelimit.readthedocs.io/ diff --git a/docs/conf.py b/docs/conf.py index b3c59a2..ae5604a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,16 +41,16 @@ master_doc = 'index' # General information about the project. project = u'Django Ratelimit' -copyright = u'2013, James Socol' +copyright = u'2018, James Socol' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.3' +version = '2.0' # The full version, including alpha/beta/rc tags. -release = '0.3.0' +release = '2.0.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -82,6 +82,7 @@ exclude_patterns = ['_build'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' +highlight_language = 'python' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] @@ -120,7 +121,7 @@ html_theme = 'default' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +#html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/contributing.rst b/docs/contributing.rst index e1ee72d..e110b2a 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -8,7 +8,9 @@ Contributing Set Up ====== -Create a virtualenv_ and install Django with pip_:: +Create a virtualenv_ and install Django with pip_: + +.. code-block:: sh $ pip install Django @@ -16,19 +18,25 @@ Create a virtualenv_ and install Django with pip_:: Running the Tests ================= -Running the tests is as easy as:: +Running the tests is as easy as: + +.. code-block:: sh $ ./run.sh test You may also run the test on multiple versions of Django using tox. -- First install tox:: +- First install tox: - $ pip install tox + .. code-block:: sh -- Then run the tests with tox:: + $ pip install tox - $ tox +- Then run the tests with tox: + + .. code-block:: sh + + $ tox Code Standards diff --git a/docs/index.rst b/docs/index.rst index 7d41a3f..be05f4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,11 +28,11 @@ Use as a decorator in ``views.py``:: from ratelimit.decorators import ratelimit - @ratelimit() + @ratelimit(key='ip') def myview(request): # ... - @ratelimit(rate='100/h') + @ratelimit(key='ip', rate='100/h') def secondview(request): # ... @@ -48,6 +48,10 @@ Contents settings usage + keys + rates + security + upgrading contributing diff --git a/docs/keys.rst b/docs/keys.rst new file mode 100644 index 0000000..278eaf9 --- /dev/null +++ b/docs/keys.rst @@ -0,0 +1,82 @@ +.. _keys-chapter: + +============== +Ratelimit Keys +============== + +The ``key=`` argument to the decorator takes either a string or a +callable. + + +.. _keys-common: + +Common keys +=========== + +The following string values for ``key=`` provide shortcuts to commonly +used ratelimit keys: + +- ``'ip'`` - Use the request IP address (i.e. + ``request.META['REMOTE_ADDR']``) + + .. note:: + If you are using a reverse proxy, make sure this value is correct + or use an appropriate ``header:`` value. See the :ref:`security + ` notes. +- ``'get:X'`` - Use the value of ``request.GET.get('X', '')``. +- ``'post:X'`` - Use the value of ``request.POST.get('X', '')``. +- ``'header:x-x'`` - Use the value of + ``request.META.get('HTTP_X_X', '')``. + + .. note:: + The value right of the colon will be translated to all-caps and + any dashes will be replaced with underscores, e.g.: x-client-ip + => X_CLIENT_IP. +- ``'user'`` - Use an appropriate value from ``request.user``. Do not use + with unauthenticated users. +- ``'user_or_ip'`` - Use an appropriate value from ``request.user`` if + the user is authenticated, otherwise use + ``request.META['REMOTE_ADDR']`` (see the note above about reverse + proxies). + +.. note:: + + Missing headers, GET, and POST values will all be treated as empty + strings, and ratelimited in the same bucket. + +.. warning:: + + Using user-supplied data, like data from GET and POST or headers + directly from the User-Agent can allow users to trivially opt out of + ratelimiting. See the note in :ref:`the security chapter + `. + + +.. _keys-strings: + +String values +============= + +Other string values not from the list above will be treated as the +dotted Python path to a callable. See :ref:`below ` for +more on callables. + + +.. _keys-callable: + +Callable values +=============== + +.. versionadded:: 0.3 +.. versionchanged:: 0.5 + Added support for python path to callables. +.. versionchanged:: 0.6 + Callable was mistakenly only passed the ``request``, now also gets ``group`` as documented. + +If the value of ``key=`` is a callable, or the path to a callable, that +callable will be called with two arguments, the :ref:`group +` and the ``request`` object. It should return a +bytestring or unicode object, e.g.:: + + def my_key(group, request): + return request.META['REMOTE_ADDR'] + request.user.username diff --git a/docs/rates.rst b/docs/rates.rst new file mode 100644 index 0000000..c4af469 --- /dev/null +++ b/docs/rates.rst @@ -0,0 +1,61 @@ +.. _rates-chapter: + +===== +Rates +===== + + +.. _rates-simple: + +Simple rates +============ + +Simple rates are of the form ``X/u`` where ``X`` is a number of requests +and ``u`` is a unit from this list: + +* ``s`` - second +* ``m`` - minute +* ``h`` - hour +* ``d`` - day + +(For example, you can read ``5/s`` as "five per second.") + +You may also specify a number of units, i.e.: ``X/Yu`` where ``Y`` is a +number of units. If ``u`` is omitted, it is presumed to be seconds. So, +the following are equivalent, and all mean "one hundred requests per +five minutes": + +* ``100/5m`` +* ``100/300s`` +* ``100/300`` + + +.. _rates-callable: + +Callables +========= + +.. versionadded:: 0.5 + +Rates can also be callables (or dotted paths to callables, which are +assumed if there is no ``/`` in the value). + +Callables receive two values, the :ref:`group ` and the +``request`` object. They should return a simple rate string, or a tuple +of integers ``(count, seconds)``. For example:: + + def my_rate(group, request): + if request.user.is_authenticated: + return '1000/m' + return '100/m' + +Or equivalently:: + + def my_rate_tuples(group, request): + if request.user.is_authenticated: + return (1000, 60) + return (100, 60) + +Callables can return ``0`` in the first place to disallow any requests +(e.g.: ``0/s``, ``(0, 60)``). They can return ``None`` for "no +ratelimit". diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 0000000..03a9af0 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,170 @@ +.. _security-chapter: + +======================= +Security considerations +======================= + + +.. _security-client-ip: + +Client IP address +================= + +IP address is an extremely common rate limit :ref:`key `, +so it is important to configure correctly, especially in the +equally-common case where Django is behind a load balancer or other +reverse proxy. + +Django-Ratelimit is **not** the correct place to handle reverse proxies +and adjust the IP address, and patches dealing with it will not be +accepted. There is `too much variation`_ in the wild to handle it +safely. + +This is the same reason `Django dropped`_ +``SetRemoteAddrFromForwardedFor`` middleware in 1.1: no such "mechanism +can be made reliable enough for general-purpose use" and it "may lead +developers to assume that the value of ``REMOTE_ADDR`` is 'safe'." + + +Risks +----- + +Mishandling client IP data creates an IP spoofing vector that allows +attackers to circumvent IP ratelimiting entirely. Consider an attacker +with the real IP address 3.3.3.3 that adds the following to a request:: + + X-Forwarded-For: 1.2.3.4 + +A misconfigured web server may pass the header value along, e.g.:: + + X-Forwarded-For: 3.3.3.3, 1.2.3.4 + +Alternatively, if the web server sends a different header, like +``X-Cluster-Client-IP`` or ``X-Real-IP``, and passes along the +spoofed ``X-Forwarded-For`` header unchanged, a mistake in ratelimit or +a misconfiguration in Django could read the spoofed header instead of +the intended one. + + +Remediation +----------- + +There are two options, configuring django-ratelimit or adding global +middleware. Which makes sense depends on your setup. + + +Middleware +^^^^^^^^^^ + +Writing a small middleware class to set ``REMOTE_ADDR`` to the actual +client IP address is generally simple:: + + class ReverseProxy(object): + def process_request(self, request): + request.META['REMOTE_ADDR'] = # [...] + +where ``# [...]`` depends on your environment. This middleware should be +close to the top of the list:: + + MIDDLEWARE_CLASSES = ( + 'path.to.ReverseProxy', + # ... + ) + +Then the ``@ratelimit`` decorator can be used with the ``ip`` key:: + + @ratelimit(key='ip', rate='10/s') + +Ratelimit keys +^^^^^^^^^^^^^^ + +Alternatively, if the client IP address is in a simple header (i.e. a +header like ``X-Real-IP`` that *only* contains the client IP, unlike +``X-Forwarded-For`` which may contain intermediate proxies) you can use +a ``header:`` key:: + + @ratelimit(key='header:x-real-ip', rate='10/s') + +.. _too much variation: http://en.wikipedia.org/wiki/Talk:X-Forwarded-For#Variations +.. _Django dropped: https://docs.djangoproject.com/en/2.1/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware + + +.. _security-brute-force: + +Brute force attacks +=================== + +One of the key uses of ratelimiting is preventing brute force or +dictionary attacks against login forms. These attacks generally take one +of a few forms: + +- One IP address trying one username with many passwords. +- Many IP addresses trying one username with many passwords. +- One IP address trying many usernames with a few common passwords. +- Many IP addresses trying many usernames with one or a few common + passwords. + +.. note:: + Unfortunately, the fourth case of many IPs trying many usernames can + be difficult to distinguish from regular user behavior and requires + additional signals, such as a consistent user agent or a common + network prefix. + +Protecting against the single IP address cases is easy:: + + @ratelimit(key='ip') + def login_view(request): + pass + +Also limiting by username provides better protection:: + + @ratelimit(key='ip') + @ratelimit(key='post:username') + def login_view(request): + pass + +**Using passwords as key values is not recommended.** Key values are +never stored in a raw form, even as cache keys, but they are constructed +with a fast hash function. + + +Denial of Service +----------------- + +However, limiting based on field values may open a `denial of service`_ +vector against your users, preventing them from logging in. + +For pages like login forms, consider implenting a soft blocking +mechanism, such as requiring a captcha, rather than a hard block with a +``PermissionDenied`` error. + + +Network Address Translation +--------------------------- + +Depending on your profile of your users, you may have many users behind +NAT (e.g. users in schools or in corporate networks). It is reasonable +to set a higher limit on a per-IP limit than on a username or password +limit. + +.. _denial of service: http://en.wikipedia.org/wiki/Denial-of-service_attack?oldformat=true + + +.. _security-user-supplied: + +User-supplied Data +================== + +Using data from GET (``key='get:X'``) POST (``key='post:X'``) or headers +(``key='header:x-x'``) that are provided directly by the browser or +other client presents a risk. Unless there is some requirement of the +attack that requires the client *not* change the value (for example, +attempting to brute force a password requires that the username be +consistent) clients can trivially change these values on every request. + +Headers that are provided by web servers or reverse proxies should be +independently audited to ensure they cannot be affected by clients. + +The ``User-Agent`` header is especially dangerous, since bad actors can +change it on every request, and many good actors may share the same +value. diff --git a/docs/settings.rst b/docs/settings.rst index c22bc31..f29c81d 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -4,14 +4,45 @@ Settings ======== -``RATELIMIT_CACHE_PREFIX``: - An optional cache prefix for ratelimit keys (in addition to the - ``PREFIX`` value). *rl:* -``RATELIMIT_ENABLE``: - Set to ``False`` to disable rate-limiting across the board. *True* -``RATELIMIT_USE_CACHE``: - Which cache (from the ``CACHES`` dict) to use. *default* -``RATELIMIT_VIEW``: - A view to use when a request is ratelimited, in conjunction with - ``RatelimitMiddleware``. (E.g.: ``'myapp.views.ratelimited'``.) - *None* +``RATELIMIT_CACHE_PREFIX`` +-------------------------- + +An optional cache prefix for ratelimit keys (in addition to the ``PREFIX`` +value defined on the cache backend). Defaults to ``'rl:'``. + +``RATELIMIT_ENABLE`` +-------------------- + +Set to ``False`` to disable rate-limiting across the board. Defaults to +``True``. + +May be useful during tests with Django's |override_settings|_ testing tool, +for example: + +.. code-block:: python + + from django.test import override_settings + + with override_settings(RATELIMIT_ENABLE=False): + result = call_the_view() + +.. |override_settings| replace:: ``override_settings()`` +.. _override_settings: https://docs.djangoproject.com/en/2.0/topics/testing/tools/#django.test.override_settings. + +``RATELIMIT_USE_CACHE`` +----------------------- + +The name of the cache (from the ``CACHES`` dict) to use. Defaults to +``'default'``. + +``RATELIMIT_VIEW`` +------------------ + +The string import path to a view to use when a request is ratelimited, in +conjunction with ``RatelimitMiddleware``, e.g. ``'myapp.views.ratelimited'``. +Has no default - you must set this to use ``RatelimitMiddleware``. + +``RATELIMIT_FAIL_OPEN`` +----------------------- + +Whether to allow requests when the cache backend fails. Defaults to ``False``. diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 0000000..15353e8 --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,200 @@ +.. _upgrading-chapter: + +============= +Upgrade Notes +============= + +See also the `CHANGELOG <../CHANGELOG>`. + + +.. _upgrading-0.5: + +From <=0.4 to 0.5 +================= + +Quickly: + +- Rate limits are now counted against fixed, instead of sliding, + windows. +- Rate limits are no longer shared between methods by default. +- Change ``ip=True`` to ``key='ip'``. +- Drop ``ip=False``. +- A key must always be specified. If using without an explicit key, add + ``key='ip'``. +- Change ``fields='foo'`` to ``post:foo`` or ``get:foo``. +- Change ``keys=callable`` to ``key=callable``. +- Change ``skip_if`` to a callable ``rate=`` method (see + :ref:`Rates `. +- Change ``RateLimitMixin`` to ``RatelimitMixin`` (note the lowercase + ``l``). +- Change ``ratelimit_ip=True`` to ``ratelimit_key='ip'``. +- Change ``ratelimit_fields='foo'`` to ``post:foo`` or ``get:foo``. +- Change ``ratelimit_keys=callable`` to ``ratelimit_key=callable``. + + +Fixed windows +------------- + +Before 0.5, rates were counted against a *sliding* window, so if the +rate limit was ``1/m``, and three requests came in:: + + 1.2.3.4 [09/Sep/2014:12:25:03] ... + 1.2.3.4 [09/Sep/2014:12:25:53] ... + 1.2.3.4 [09/Sep/2014:12:25:59] ... + +Even though the third request came nearly two minutes after the first +request, the second request moved the window. Good actors could easily +get caught in this, even trying to implement reasonable back-offs. + +Starting in 0.5, windows are *fixed*, and staggered throughout a given +period based on the key value, so the third request, above would not be +rate limited (it's possible neither would the second one). + +.. warning:: + That means that given a rate of ``X/u``, you may see up to ``2 * X`` + requests in a short period of time. Make sure to set ``X`` + accordingly if this is an issue. + +This change still limits bad actors while being far kinder to good +actors. + + +Staggering windows +^^^^^^^^^^^^^^^^^^ + +To avoid a situation where all limits expire at the top of the hour, +windows are automatically staggered throughout their period based on the +key value. So if, for example, two IP addresses are hitting hourly +limits, instead of both of those limits expiring at 06:00:00, one might +expire at 06:13:41 (and subsequently at 07:13:41, etc) and the other +might expire at 06:48:13 (and 07:48:13, etc). + + +Sharing rate limits +------------------- + +Before 0.5, rate limits were shared between methods based only on their +keys. This was very confusing and unintuitive, and is far from the +least-surprising_ thing. For example, given these three views:: + + @ratelimit(ip=True, field='username') + def both(request): + pass + + @ratelimit(ip=False, field='username') + def field_only(request): + pass + + @ratelimit(ip=True) + def ip_only(request): + pass + + +The pair ``both`` and ``field_only`` shares one rate limit key based on +all requests to either (and any other views) containing the same +``username`` key (in ``GET`` or ``POST``), regardless of IP address. + +The pair ``both`` and ``ip_only`` shares one rate limit key based on the +client IP address, along with all other views. + +Thus, it's extremely difficult to determine exactly why a request is +getting rate limited. + +In 0.5, methods never share rate limits by default. Instead, limits are +based on a combination of the :ref:`group `, rate, key +value, and HTTP methods *to which the decorator applies* (i.e. **not** +the method of the request). This better supports common use cases and +stacking decorators, and still allows decorators to be shared. + +For example, this implements an hourly rate limit with a per-minute +burst rate limit:: + + @ratelimit(key='ip', rate='100/m') + @ratelimit(key='ip', rate='1000/h') + def myview(request): + pass + +However, this view is limited *separately* from another view with the +same keys and rates:: + + @ratelimit(key='ip', rate='100/m') + @ratelimit(key='ip', rate='1000/h') + def anotherview(request): + pass + +To cause the views to share a limit, explicitly set the ``group`` +argument:: + + @ratelimit(group='lists', key='user', rate='100/h') + def user_list(request): + pass + + @ratelimit(group='lists', key='user', rate='100/h') + def group_list(request): + pass + +You can also stack multiple decorators with different sets of applicable +methods:: + + @ratelimit(key='ip', method='GET', rate='1000/h') + @ratelimit(key='ip', method='POST', rate='100/h') + def maybe_expensive(request): + pass + +This allows a total of 1,100 requests to this view in one hour, while +this would only allow 1000, but still only 100 POSTs:: + + @ratelimit(key='ip', method=['GET', 'POST'], rate='1000/h') + @ratelimit(key='ip', method='POST', rate='100/h') + def maybe_expensive(request): + pass + +And these two decorators would not share a rate limit:: + + @ratelimit(key='ip', method=['GET', 'POST'], rate='100/h') + def foo(request): + pass + + @ratelimit(key='ip', method='GET', rate='100/h') + def bar(request): + pass + +But these two do share a rate limit:: + + @ratelimit(group='a', key='ip', method=['GET', 'POST'], rate='1/s') + def foo(request): + pass + + @ratelimit(group='a', key='ip', method=['POST', 'GET'], rate='1/s') + def bar(request): + pass + + +Using multiple decorators +------------------------- + +A single ``@ratelimit`` decorator used to be able to ratelimit against +multiple keys, e.g., before 0.5:: + + @ratelimit(ip=True, field='username', keys=mykeysfunc) + def someview(request): + # ... + +To simplify both the internals and the question of what limits apply, +each decorator now tracks exactly one rate, but decorators can be more +reliably stacked (c.f. some examples in the section above). + +The pre-0.5 example above would need to become four decorators:: + + @ratelimit(key='ip') + @ratelimit(key='post:username') + @ratelimit(key='get:username') + @ratelimit(key=mykeysfunc) + def someview(request): + # ... + +As documented above, however, this allows powerful new uses, like burst +limits and distinct GET/POST limits. + + +.. _least-surprising: http://en.wikipedia.org/wiki/Principle_of_least_astonishment diff --git a/docs/usage.rst b/docs/usage.rst index 9dc458d..da74266 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -5,138 +5,194 @@ Using Django Ratelimit ====================== +.. _usage-decorator: + Use as a decorator ================== -The ``@ratelimit`` view decorator provides several optional arguments -with sensible defaults (in italics). - Import:: from ratelimit.decorators import ratelimit -.. py:decorator:: ratelimit(ip=True, block=False, method=None, field=None, rate='5/m', skip_if=None, keys=None) +.. py:decorator:: ratelimit(group=None, key=, rate=None, method=ALL, block=False) - :arg ip: - *True* Whether to rate-limit based on the IP from ``REMOTE_ADDR``. + :arg group: + *None* A group of rate limits to count together. Defaults to the + dotted name of the view. - .. Note:: - - If you're using a reverse proxy, set this to False and use - the ``keys`` argument. - - :arg block: - *False* Whether to block the request instead of annotating. - - :arg method: - *None* Which HTTP method(s) to rate-limit. May be a string, a - list/tuple, or ``None`` for all methods. - - :arg field: - *None* Which HTTP GET/POST argument field(s) to use to - rate-limit. May be a string or a list of strings. + :arg key: + What key to use, see :ref:`Keys `. :arg rate: - *'5/m'* The number of requests per unit time allowed. Valid units are: + *'5/m'* The number of requests per unit time allowed. Valid + units are: * ``s`` - seconds * ``m`` - minutes * ``h`` - hours * ``d`` - days - :arg skip_if: - *None* If specified, pass this parameter a callable - (e.g. lambda function) that takes the current request. If the - callable returns a value that evaluates to True, the rate - limiting is skipped for that particular view. This is useful - to do things like selectively deactivating rate limiting based - on a value in your settings file, or based on an attirbute in - the current request object. (Also see the ``RATELIMIT_ENABLE`` - setting below.) + Also accepts callables. See :ref:`Rates `. - :arg keys: - *None* Specify a function or list of functions that take the - request object and return string keys. This allows you to - define custom logic (for example, use an authenticated user ID - or unauthenticated IP address). + :arg method: + *ALL* Which HTTP method(s) to rate-limit. May be a string, a + list/tuple of strings, or the special values for ``ALL`` or + ``UNSAFE`` (which includes ``POST``, ``PUT``, ``DELETE`` and + ``PATCH``). - .. Note:: - - If you're using a reverse proxy, pass in a function that - pulls the appropriate field from ``request.META`` for the - actual ip address of the client. + :arg block: + *False* Whether to block the request instead of annotating. -Examples:: +HTTP Methods +------------ - @ratelimit() +Each decorator can be limited to one or more HTTP methods. The +``method=`` argument accepts a method name (e.g. ``'GET'``) or a list or +tuple of strings (e.g. ``('GET', 'OPTIONS')``). + +There are two special shortcuts values, both accessible from the +``ratelimit`` decorator, the ``RatelimitMixin`` class, or the +``is_ratelimited`` helper, as well as on the root ``ratelimit`` module:: + + from ratelimit.decorators import ratelimit + + @ratelimit(key='ip', method=ratelimit.ALL) + @ratelimit(key='ip', method=ratelimit.UNSAFE) def myview(request): - # Will be true if the same IP makes more than 5 requests/minute. + pass + +``ratelimit.ALL`` applies to all HTTP methods. ``ratelimit.UNSAFE`` +is a shortcut for ``('POST', 'PUT', 'PATCH', 'DELETE')``. + + +Examples +-------- + + +:: + + @ratelimit(key='ip', rate='5/m') + def myview(request): + # Will be true if the same IP makes more than 5 POST + # requests/minute. was_limited = getattr(request, 'limited', False) return HttpResponse() - @ratelimit(block=True) + @ratelimit(key='ip', rate='5/m', block=True) def myview(request): # If the same IP makes >5 reqs/min, will raise Ratelimited return HttpResponse() - @ratelimit(field='username') + @ratelimit(key='post:username', rate='5/m', method=['GET', 'POST']) def login(request): - # If the same username OR IP is used >5 times/min, this will be True. + # If the same username is used >5 times/min, this will be True. # The `username` value will come from GET or POST, determined by the # request method. was_limited = getattr(request, 'limited', False) return HttpResponse() - @ratelimit(method='POST') + @ratelimit(key='post:username', rate='5/m') + @ratelimit(key='post:tenant', rate='5/m') def login(request): - # Only apply rate-limiting to POSTs. - return HttpResponseRedirect() - - @ratelimit(field=['username', 'other_field']) - def login(request): - # Use multiple field values. + # Use multiple keys by stacking decorators. return HttpResponse() - @ratelimit(rate='4/h') + @ratelimit(key='get:q', rate='5/m') + @ratelimit(key='post:q', rate='5/m') + def search(request): + # These two decorators combine to form one rate limit: the same search + # query can only be tried 5 times a minute, regardless of the request + # method (GET or POST) + return HttpResponse() + + @ratelimit(key='ip', rate='4/h') def slow(request): # Allow 4 reqs/hour. return HttpResponse() - @ratelimit(skip_if=lambda request: getattr(request, 'some_attribute', False)) + rate = lambda r: None if request.user.is_authenticated else '100/h' + @ratelimit(key='ip', rate=rate) def skipif1(request): - # Conditionally skip rate limiting (example 1) + # Only rate limit anonymous requests return HttpResponse() - @ratelimit(skip_if=lambda request: settings.MYAPP_DEACTIVATE_RATE_LIMITING) - def skipif2(request): - # Conditionally skip rate limiting (example 2) + @ratelimit(key='user_or_ip', rate='10/s') + @ratelimit(key='user_or_ip', rate='100/m') + def burst_limit(request): + # Implement a separate burst limit. return HttpResponse() - @ratelimit(keys=lambda x: 'min', rate='1/m') - @ratelimit(keys=lambda x: 'hour', rate='10/h') - @ratelimit(keys=lambda x: 'day', rate='50/d') + @ratelimit(group='expensive', key='user_or_ip', rate='10/h') + def expensive_view_a(request): + return something_expensive() + + @ratelimit(group='expensive', key='user_or_ip', rate='10/h') + def expensive_view_b(request): + # Shares a counter with expensive_view_a + return something_else_expensive() + + @ratelimit(key='header:x-cluster-client-ip') def post(request): - # Stack them. - # Note: once a decorator limits the request, the ones after - # won't count the request for limiting. + # Uses the X-Cluster-Client-IP header value. return HttpResponse() - @ratelimit(ip=False, - keys=lambda req: req.META.get('HTTP_X_CLUSTER_CLIENT_IP', - req.META['REMOTE_ADDR'])) - def post(request): - # This will use the HTTP_X_CLUSTER_CLIENT_IP and default to - # REMOTE_ADDR if that's not set. This is how you'd set up your - # rate limiting if you're behind a reverse proxy. - # - # It's important to set ip to False here. Otherwise it'll use - # limit on EITHER HTTP_X_CLUSTER_CLIENT_IP or REMOTE_ADDR and - # the end result is that everything will be throttled. + @ratelimit(key=lambda r: r.META.get('HTTP_X_CLUSTER_CLIENT_IP', + r.META['REMOTE_ADDR']) + def myview(request): + # Use `X-Cluster-Client-IP` but fall back to REMOTE_ADDR. return HttpResponse() +Class-Based Views +----------------- + +.. versionadded:: 0.5 + +The ``@ratelimit`` decorator also works on class-based view methods, +though *make sure the ``method`` argument matches the decorator*:: + + class MyView(View): + @ratelimit(key='ip', method='POST') + def post(self, request, *args): + # Something expensive... + +.. note:: + Unless given an explicit ``group`` argument, different methods of a + class-based view will be limited separate. + + +.. _usage-mixin: + +Class-Based View Mixin +====================== + +.. py:class:: ratelimit.mixins.RatelimitMixin + +.. versionadded:: 0.4 + +Ratelimits can also be applied to class-based views with the +``ratelimit.mixins.RatelimitMixin`` mixin. They are configured via class +attributes that are the same as the :ref:`decorator `, +prefixed with ``ratelimit_``, e.g.:: + + class MyView(RatelimitMixin, View): + ratelimit_key = 'ip' + ratelimit_rate = '10/m' + ratelimit_block = False + ratelimit_method = 'GET' + + def get(self, request, *args, **kwargs): + # Calculate expensive report... + +.. versionchanged:: 0.5 + The name of the mixin changed from ``RateLimitMixin`` to + ``RatelimitMixin`` for consistency. + + +.. _usage-helper: + Helper Function =============== @@ -146,37 +202,41 @@ the decorator. Import:: - from ratelimit.helpers import is_ratelimited + from ratelimit.utils import is_ratelimited -.. py:function:: is_ratelimited(request, increment=False, ip=True, method=None, field=None, rate='5/m', keys=None) +.. py:function:: is_ratelimited(request, group=None, key=, rate=None, method=ALL, increment=False) :arg request: - (Required) The request object. + *None* The HTTPRequest object. - :arg increment: - *False* Whether to increment the count. + :arg group: + *None* A group of rate limits to count together. Defaults to the + dotted name of the view. - :arg ip: - *True* Whether to rate-limit based on the IP. - - :arg method: - *None* Which HTTP method(s) to rate-limit. May be a string, a - list/tuple, or ``None`` for all methods. - - :arg field: - *None* Which HTTP field(s) to use to rate-limit. May be a - string or a list. + :arg key: + What key to use, see :ref:`Keys `. :arg rate: - *'5/m'* The number of requests per unit time allowed. + *'5/m'* The number of requests per unit time allowed. Valid + units are: - :arg keys: - *None* Specify a function or list of functions that take the - request object and return string keys. This allows you to - define custom logic (for example, use an authenticated user ID - or unauthenticated IP address). + * ``s`` - seconds + * ``m`` - minutes + * ``h`` - hours + * ``d`` - days + Also accepts callables. See :ref:`Rates `. + + :arg method: + *ALL* Which HTTP method(s) to rate-limit. May be a string, a + list/tuple, or ``None`` for all methods. + + :arg increment: + *False* Whether to increment the count or just check. + + +.. _usage-exception: Exceptions ========== @@ -190,6 +250,20 @@ Exceptions if you don't need any special handling beyond the built-in 403 processing, you don't have to do anything. + If you are setting |handler403|_ in your root URLconf, you can catch this + exception in your custom view to return a different response, for example: + + .. code-block:: python + + def handler403(request, exception=None): + if isinstance(exception, Ratelimited): + return HttpResponse('Sorry you are blocked', status=429) + return HttpResponseForbidden('Forbidden') + +.. |handler403| replace:: ``handler403`` +.. _handler403: https://docs.djangoproject.com/en/2.1/topics/http/urls/#error-handling + +.. _usage-middleware: Middleware ========== diff --git a/ratelimit/__init__.py b/ratelimit/__init__.py index 1300a0b..48f8373 100644 --- a/ratelimit/__init__.py +++ b/ratelimit/__init__.py @@ -1,2 +1,5 @@ -VERSION = (0, 4, 0) +VERSION = (2, 0, 0) __version__ = '.'.join(map(str, VERSION)) + +ALL = (None,) # Sentinel value for all HTTP methods. +UNSAFE = ['DELETE', 'PATCH', 'POST', 'PUT'] diff --git a/ratelimit/decorators.py b/ratelimit/decorators.py index cd4ac32..663bce6 100644 --- a/ratelimit/decorators.py +++ b/ratelimit/decorators.py @@ -1,24 +1,36 @@ +from __future__ import absolute_import + from functools import wraps +from django.http import HttpRequest + +from ratelimit import ALL, UNSAFE from ratelimit.exceptions import Ratelimited -from ratelimit.helpers import is_ratelimited +from ratelimit.utils import is_ratelimited __all__ = ['ratelimit'] -def ratelimit(ip=True, block=False, method=['POST'], field=None, rate='5/m', - skip_if=None, keys=None): +def ratelimit(group=None, key=None, rate=None, method=ALL, block=False): def decorator(fn): @wraps(fn) - def _wrapped(request, *args, **kw): + def _wrapped(*args, **kw): + # Work as a CBV method decorator. + if isinstance(args[0], HttpRequest): + request = args[0] + else: + request = args[1] request.limited = getattr(request, 'limited', False) - if skip_if is None or not skip_if(request): - ratelimited = is_ratelimited(request=request, increment=True, - ip=ip, method=method, field=field, - rate=rate, keys=keys) - if ratelimited and block: - raise Ratelimited() - return fn(request, *args, **kw) + ratelimited = is_ratelimited(request=request, group=group, fn=fn, + key=key, rate=rate, method=method, + increment=True) + if ratelimited and block: + raise Ratelimited() + return fn(*args, **kw) return _wrapped return decorator + + +ratelimit.ALL = ALL +ratelimit.UNSAFE = UNSAFE diff --git a/ratelimit/helpers.py b/ratelimit/helpers.py deleted file mode 100644 index 45cc6a6..0000000 --- a/ratelimit/helpers.py +++ /dev/null @@ -1,99 +0,0 @@ -import hashlib -import re - -from django.conf import settings -from django.core.cache import get_cache - - -__all__ = ['is_ratelimited'] - -RATELIMIT_ENABLE = getattr(settings, 'RATELIMIT_ENABLE', True) -CACHE_PREFIX = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:') - -_PERIODS = { - 's': 1, - 'm': 60, - 'h': 60 * 60, - 'd': 24 * 60 * 60, -} - -rate_re = re.compile('([\d]+)/([\d]*)([smhd])') - - -def _method_match(request, method=None): - if method is None: - return True - if not isinstance(method, (list, tuple)): - method = [method] - return request.method in [m.upper() for m in method] - - -def _split_rate(rate): - count, multi, period = rate_re.match(rate).groups() - count = int(count) - time = _PERIODS[period.lower()] - if multi: - time = time * int(multi) - return count, time - - -def _get_keys(request, ip=True, field=None, keyfuncs=None): - keys = [] - if ip: - keys.append('ip:' + request.META['REMOTE_ADDR']) - if field is not None: - if not isinstance(field, (list, tuple)): - field = [field] - for f in field: - val = getattr(request, request.method).get(f, '').encode('utf-8') - val = hashlib.sha1(val).hexdigest() - keys.append(u'field:%s:%s' % (f, val)) - if keyfuncs: - if not isinstance(keyfuncs, (list, tuple)): - keyfuncs = [keyfuncs] - for k in keyfuncs: - keys.append(k(request)) - return [CACHE_PREFIX + k for k in keys] - - -def _incr(cache, keys, timeout=60): - # Yes, this is a race condition, but memcached.incr doesn't reset the - # timeout. - counts = cache.get_many(keys) - for key in keys: - if key in counts: - counts[key] += 1 - else: - counts[key] = 1 - cache.set_many(counts, timeout=timeout) - return counts - - -def _get(cache, keys): - counts = cache.get_many(keys) - for key in keys: - if key in counts: - counts[key] += 1 - else: - counts[key] = 1 - return counts - - -def is_ratelimited(request, increment=False, ip=True, method=['POST'], - field=None, rate='5/m', keys=None): - count, period = _split_rate(rate) - cache = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') - cache = get_cache(cache) - - request.limited = getattr(request, 'limited', False) - if (not request.limited and RATELIMIT_ENABLE and - _method_match(request, method)): - _keys = _get_keys(request, ip, field, keys) - if increment: - counts = _incr(cache, _keys, period) - else: - counts = _get(cache, _keys) - if any([c > count for c in counts.values()]): - request.limited = True - - return request.limited diff --git a/ratelimit/middleware.py b/ratelimit/middleware.py index 4ca7a88..1e25835 100644 --- a/ratelimit/middleware.py +++ b/ratelimit/middleware.py @@ -1,10 +1,19 @@ +try: + from django.utils.importlib import import_module +except ImportError: + from importlib import import_module + +try: + from django.utils.deprecation import MiddlewareMixin +except ImportError: + MiddlewareMixin = object + from django.conf import settings -from django.utils.importlib import import_module from ratelimit.exceptions import Ratelimited -class RatelimitMiddleware(object): +class RatelimitMiddleware(MiddlewareMixin): def process_exception(self, request, exception): if not isinstance(exception, Ratelimited): return diff --git a/ratelimit/mixins.py b/ratelimit/mixins.py index a8dc515..d56f30e 100644 --- a/ratelimit/mixins.py +++ b/ratelimit/mixins.py @@ -1,9 +1,13 @@ -# -*- coding: utf-8 -*- +from __future__ import absolute_import -from .decorators import ratelimit +from ratelimit import ALL, UNSAFE +from ratelimit.decorators import ratelimit -class RateLimitMixin(object): +__all__ = ['RatelimitMixin'] + + +class RatelimitMixin(object): """ Mixin for usage in Class Based Views configured with the decorator ``ratelimit`` defaults. @@ -13,33 +17,42 @@ class RateLimitMixin(object): Example:: - class ContactView(RateLimitMixin, FormView): + class ContactView(RatelimitMixin, FormView): form_class = ContactForm template_name = "contact.html" + # Limit contact form by remote address. + ratelimit_key = 'ip' ratelimit_block = True def form_valid(self, form): - # do sth. here + # Whatever validation. return super(ContactView, self).form_valid(form) """ - ratelimit_ip = True - ratelimit_block = False - ratelimit_method = ['POST'] - ratelimit_field = None + ratelimit_group = None + ratelimit_key = None ratelimit_rate = '5/m' - ratelimit_skip_if = None - ratelimit_keys = None + ratelimit_block = False + ratelimit_method = ALL + + ALL = ALL + UNSAFE = UNSAFE def get_ratelimit_config(self): + # Ensures that the ratelimit_key is called as a function instead + # of a method if it is a callable (ie self is not passed). + if callable(self.ratelimit_key): + self.ratelimit_key = self.ratelimit_key.__func__ return dict( - (k[len("ratelimit_"):], v) - for k, v in vars(self.__class__).items() - if k.startswith("ratelimit") + group=self.ratelimit_group, + key=self.ratelimit_key, + rate=self.ratelimit_rate, + block=self.ratelimit_block, + method=self.ratelimit_method, ) def dispatch(self, *args, **kwargs): return ratelimit( **self.get_ratelimit_config() - )(super(RateLimitMixin, self).dispatch)(*args, **kwargs) + )(super(RatelimitMixin, self).dispatch)(*args, **kwargs) diff --git a/ratelimit/tests.py b/ratelimit/tests.py index 7585e81..0095ef9 100644 --- a/ratelimit/tests.py +++ b/ratelimit/tests.py @@ -1,39 +1,77 @@ -import django from django.core.cache import cache, InvalidCacheBackendError +from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.test.utils import override_settings from django.views.generic import View from ratelimit.decorators import ratelimit from ratelimit.exceptions import Ratelimited -from ratelimit.mixins import RateLimitMixin -from ratelimit.helpers import is_ratelimited +from ratelimit.mixins import RatelimitMixin +from ratelimit.utils import is_ratelimited, _split_rate + + +rf = RequestFactory() + + +class MockUser(object): + def __init__(self, authenticated=False): + self.pk = 1 + self.is_authenticated = authenticated + + +class RateParsingTests(TestCase): + def test_simple(self): + tests = ( + ('100/s', (100, 1)), + ('100/10s', (100, 10)), + ('100/10', (100, 10)), + ('100/m', (100, 60)), + ('400/10m', (400, 600)), + ('1000/h', (1000, 3600)), + ('800/d', (800, 24 * 60 * 60)), + ) + + for i, o in tests: + assert o == _split_rate(i) + + +def mykey(group, request): + return request.META['REMOTE_ADDR'][::-1] class RatelimitTests(TestCase): def setUp(self): cache.clear() - def test_limit_ip(self): - @ratelimit(ip=True, method=None, rate='1/m', block=True) + def test_no_key(self): + @ratelimit(rate='1/m', block=True) def view(request): return True - req = RequestFactory().get('/') + req = rf.get('/') + with self.assertRaises(ImproperlyConfigured): + view(req) + + def test_ip(self): + @ratelimit(key='ip', rate='1/m', block=True) + def view(request): + return True + + req = rf.get('/') assert view(req), 'First request works.' with self.assertRaises(Ratelimited): view(req) def test_block(self): - @ratelimit(ip=True, method=None, rate='1/m', block=True) + @ratelimit(key='ip', rate='1/m', block=True) def blocked(request): return request.limited - @ratelimit(ip=True, method=None, rate='1/m', block=False) + @ratelimit(key='ip', rate='1/m', block=False) def unblocked(request): return request.limited - req = RequestFactory().get('/') + req = rf.get('/') assert not blocked(req), 'First request works.' with self.assertRaises(Ratelimited): @@ -42,15 +80,14 @@ class RatelimitTests(TestCase): assert unblocked(req), 'Request is limited but not blocked.' def test_method(self): - rf = RequestFactory() post = rf.post('/') get = rf.get('/') - @ratelimit(ip=True, method=['POST'], rate='1/m') + @ratelimit(key='ip', method='POST', rate='1/m', group='a') def limit_post(request): return request.limited - @ratelimit(ip=True, method=['POST', 'GET'], rate='1/m') + @ratelimit(key='ip', method=['POST', 'GET'], rate='1/m', group='a') def limit_get(request): return request.limited @@ -61,132 +98,289 @@ class RatelimitTests(TestCase): assert limit_get(post), 'Limit first POST.' assert limit_get(get), 'Limit first GET.' - def test_field(self): - james = RequestFactory().post('/', {'username': 'james'}) - john = RequestFactory().post('/', {'username': 'john'}) - - @ratelimit(ip=False, field='username', rate='1/m') - def username(request): + def test_unsafe_methods(self): + @ratelimit(key='ip', method=ratelimit.UNSAFE, rate='0/m') + def limit_unsafe(request): return request.limited - assert not username(james), "james' first request is fine." - assert username(james), "james' second request is limited." - assert not username(john), "john's first request is fine." + get = rf.get('/') + head = rf.head('/') + options = rf.options('/') - def test_field_unicode(self): - post = RequestFactory().post('/', {'username': u'fran\xe7ois'}) + delete = rf.delete('/') + post = rf.post('/') + put = rf.put('/') - @ratelimit(ip=False, field='username', rate='1/m') + assert not limit_unsafe(get) + assert not limit_unsafe(head) + assert not limit_unsafe(options) + assert limit_unsafe(delete) + assert limit_unsafe(post) + assert limit_unsafe(put) + + # TODO: When all supported versions have this, drop the `if`. + if hasattr(rf, 'patch'): + patch = rf.patch('/') + assert limit_unsafe(patch) + + def test_key_get(self): + req_a = rf.get('/', {'foo': 'a'}) + req_b = rf.get('/', {'foo': 'b'}) + + @ratelimit(key='get:foo', rate='1/m', method='GET') def view(request): return request.limited - assert not view(post), 'First request is not limited.' - assert view(post), 'Second request is limited.' + assert not view(req_a) + assert view(req_a) + assert not view(req_b) + assert view(req_b) - def test_field_empty(self): - post = RequestFactory().post('/', {}) + def test_key_post(self): + req_a = rf.post('/', {'foo': 'a'}) + req_b = rf.post('/', {'foo': 'b'}) - @ratelimit(ip=False, field='username', rate='1/m') + @ratelimit(key='post:foo', rate='1/m') def view(request): return request.limited - assert not view(post), 'First request is not limited.' - assert view(post), 'Second request is limited.' + assert not view(req_a) + assert view(req_a) + assert not view(req_b) + assert view(req_b) + + def test_key_header(self): + req = rf.post('/') + req.META['HTTP_X_REAL_IP'] = '1.2.3.4' + + @ratelimit(key='header:x-real-ip', rate='1/m') + @ratelimit(key='header:x-missing-header', rate='1/m') + def view(request): + return request.limited + + assert not view(req) + assert view(req) def test_rate(self): - req = RequestFactory().post('/') + req = rf.post('/') - @ratelimit(ip=True, rate='2/m') + @ratelimit(key='ip', rate='2/m') def twice(request): return request.limited assert not twice(req), 'First request is not limited.' + del req.limited assert not twice(req), 'Second request is not limited.' + del req.limited assert twice(req), 'Third request is limited.' - def test_skip_if(self): - req = RequestFactory().post('/') + def test_zero_rate(self): + req = rf.post('/') - @ratelimit(rate='1/m', skip_if=lambda r: getattr(r, 'skip', False)) + @ratelimit(key='ip', rate='0/m') + def never(request): + return request.limited + + assert never(req) + + def test_none_rate(self): + req = rf.post('/') + + @ratelimit(key='ip', rate=None) + def always(request): + return request.limited + + assert not always(req) + del req.limited + assert not always(req) + del req.limited + assert not always(req) + del req.limited + assert not always(req) + del req.limited + assert not always(req) + del req.limited + assert not always(req) + + def test_callable_rate(self): + auth = rf.post('/') + unauth = rf.post('/') + auth.user = MockUser(authenticated=True) + unauth.user = MockUser(authenticated=False) + + def get_rate(group, request): + if request.user.is_authenticated: + return (2, 60) + return (1, 60) + + @ratelimit(key='user_or_ip', rate=get_rate) def view(request): return request.limited - assert not view(req), 'First request is not limited.' - assert view(req), 'Second request is limited.' - del req.limited - req.skip = True - assert not view(req), 'Skipped request is not limited.' + assert not view(unauth) + assert view(unauth) + assert not view(auth) + assert not view(auth) + assert view(auth) - @override_settings(RATELIMIT_USE_CACHE='fake.cache') + def test_callable_rate_none(self): + req = rf.post('/') + req.never_limit = False + + get_rate = lambda g, r: None if r.never_limit else '1/m' + + @ratelimit(key='ip', rate=get_rate) + def view(request): + return request.limited + + assert not view(req) + del req.limited + assert view(req) + req.never_limit = True + del req.limited + assert not view(req) + del req.limited + assert not view(req) + + def test_callable_rate_zero(self): + auth = rf.post('/') + unauth = rf.post('/') + auth.user = MockUser(authenticated=True) + unauth.user = MockUser(authenticated=False) + + def get_rate(group, request): + if request.user.is_authenticated: + return '1/m' + return '0/m' + + @ratelimit(key='ip', rate=get_rate) + def view(request): + return request.limited + + assert view(unauth) + del unauth.limited + assert not view(auth) + del auth.limited + assert view(auth) + assert view(unauth) + + @override_settings(RATELIMIT_USE_CACHE='fake-cache') def test_bad_cache(self): """The RATELIMIT_USE_CACHE setting works if the cache exists.""" - @ratelimit() + @ratelimit(key='ip', rate='1/m') def view(request): return request - req = RequestFactory().post('/') + req = rf.post('/') with self.assertRaises(InvalidCacheBackendError): view(req) - def test_keys(self): + @override_settings(RATELIMIT_USE_CACHE='connection-errors') + def test_cache_connection_error(self): + + @ratelimit(key='ip', rate='1/m') + def view(request): + return request + + req = rf.post('/') + assert view(req) + + def test_user_or_ip(self): """Allow custom functions to set cache keys.""" - class User(object): - def __init__(self, authenticated=False): - self.pk = 1 - self.authenticated = authenticated - def is_authenticated(self): - return self.authenticated - - def user_or_ip(req): - if req.user.is_authenticated(): - return 'uip:%d' % req.user.pk - return 'uip:%s' % req.META['REMOTE_ADDR'] - - @ratelimit(ip=False, rate='1/m', block=False, keys=user_or_ip) + @ratelimit(key='user_or_ip', rate='1/m', block=False) def view(request): return request.limited - req = RequestFactory().post('/') - req.user = User(authenticated=False) + unauth = rf.post('/') + unauth.user = MockUser(authenticated=False) - assert not view(req), 'First unauthenticated request is allowed.' - assert view(req), 'Second unauthenticated request is limited.' + assert not view(unauth), 'First unauthenticated request is allowed.' + assert view(unauth), 'Second unauthenticated request is limited.' - del req.limited - req.user = User(authenticated=True) + auth = rf.post('/') + auth.user = MockUser(authenticated=True) - assert not view(req), 'First authenticated request is allowed.' - assert view(req), 'Second authenticated is limited.' + assert not view(auth), 'First authenticated request is allowed.' + assert view(auth), 'Second authenticated is limited.' + + def test_key_path(self): + @ratelimit(key='ratelimit.tests.mykey', rate='1/m') + def view(request): + return request.limited + + req = rf.post('/') + assert not view(req) + assert view(req) + + def test_callable_key(self): + @ratelimit(key=mykey, rate='1/m') + def view(request): + return request.limited + + req = rf.post('/') + assert not view(req) + assert view(req) def test_stacked_decorator(self): """Allow @ratelimit to be stacked.""" # Put the shorter one first and make sure the second one doesn't # reset request.limited back to False. - @ratelimit(ip=False, rate='1/m', block=False, keys=lambda x: 'min') - @ratelimit(ip=False, rate='10/d', block=False, keys=lambda x: 'day') + @ratelimit(rate='1/m', block=False, key=lambda x, y: 'min') + @ratelimit(rate='10/d', block=False, key=lambda x, y: 'day') def view(request): return request.limited - req = RequestFactory().post('/') + req = rf.post('/') assert not view(req), 'First unauthenticated request is allowed.' assert view(req), 'Second unauthenticated request is limited.' + def test_stacked_methods(self): + """Different methods should result in different counts.""" + @ratelimit(rate='1/m', key='ip', method='GET') + @ratelimit(rate='1/m', key='ip', method='POST') + def view(request): + return request.limited + + get = rf.get('/') + post = rf.post('/') + + assert not view(get) + assert not view(post) + assert view(get) + assert view(post) + + def test_sorted_methods(self): + """Order of the methods shouldn't matter.""" + @ratelimit(rate='1/m', key='ip', method=['GET', 'POST'], group='a') + def get_post(request): + return request.limited + + @ratelimit(rate='1/m', key='ip', method=['POST', 'GET'], group='a') + def post_get(request): + return request.limited + + req = rf.get('/') + assert not get_post(req) + assert post_get(req) + def test_is_ratelimited(self): - def get_keys(request): + def get_key(group, request): return 'test_is_ratelimited_key' def not_increment(request): - return is_ratelimited(request, increment=False, ip=False, - method=None, keys=[get_keys], rate='1/m') + return is_ratelimited(request, increment=False, + method=is_ratelimited.ALL, key=get_key, + rate='1/m', group='a') def do_increment(request): - return is_ratelimited(request, increment=True, ip=False, - method=None, keys=[get_keys], rate='1/m') + return is_ratelimited(request, increment=True, + method=is_ratelimited.ALL, key=get_key, + rate='1/m', group='a') - req = RequestFactory().get('/') + req = rf.get('/') # Does not increment. Count still 0. Does not rate limit # because 0 < 1. assert not not_increment(req), 'Request should not be rate limited.' @@ -194,236 +388,228 @@ class RatelimitTests(TestCase): # Increments. Does not rate limit because 0 < 1. Count now 1. assert not do_increment(req), 'Request should not be rate limited.' - # Does not increment. Count still 1. Rate limits because 1 < 1 + # Does not increment. Count still 1. Not limited because 1 > 1 # is false. + assert not not_increment(req), 'Request should not be rate limited.' + + # Count = 2, 2 > 1. + assert do_increment(req), 'Request should be rate limited.' assert not_increment(req), 'Request should be rate limited.' + @override_settings(RATELIMIT_USE_CACHE='connection-errors') + def test_is_ratelimited_cache_connection_error_without_increment(self): + def get_key(group, request): + return 'test_is_ratelimited_key' -#do it here, since python < 2.7 does not have unittest.skipIf -if django.VERSION >= (1, 4): - class RateLimitCBVTests(TestCase): + def not_increment(request): + return is_ratelimited(request, increment=False, + method=is_ratelimited.ALL, key=get_key, + rate='1/m', group='a') - SKIP_REASON = u'Class Based View supported by Django >=1.4' + req = rf.get('/') + assert not not_increment(req) - def setUp(self): - cache.clear() + @override_settings(RATELIMIT_USE_CACHE='connection-errors') + def test_is_ratelimited_cache_connection_error_with_increment(self): + def get_key(group, request): + return 'test_is_ratelimited_key' - def test_limit_ip(self): + def do_increment(request): + return is_ratelimited(request, increment=True, + method=is_ratelimited.ALL, key=get_key, + rate='1/m', group='a') - class RLView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_method = None - ratelimit_rate = '1/m' - ratelimit_block = True + req = rf.get('/') + assert not do_increment(req) + assert req.limited is False - rlview = RLView.as_view() + @override_settings(RATELIMIT_USE_CACHE='connection-errors-redis') + def test_is_ratelimited_cache_connection_error_with_increment_redis(self): + def get_key(group, request): + return 'test_is_ratelimited_key' - req = RequestFactory().get('/') - assert rlview(req), 'First request works.' - with self.assertRaises(Ratelimited): - rlview(req) + def do_increment(request): + return is_ratelimited(request, increment=True, + method=is_ratelimited.ALL, key=get_key, + rate='1/m', group='a') - def test_block(self): + req = rf.get('/') + assert do_increment(req) + assert req.limited is True - class BlockedView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_method = None - ratelimit_rate = '1/m' - ratelimit_block = True + @override_settings(RATELIMIT_USE_CACHE='instant-expiration') + def test_cache_timeout(self): + @ratelimit(key='ip', rate='1/m', block=True) + def view(request): + return True - def get(self, request, *args, **kwargs): - return request.limited + req = rf.get('/') + assert view(req), 'First request works.' + with self.assertRaises(Ratelimited): + view(req) - class UnBlockedView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_method = None - ratelimit_rate = '1/m' - ratelimit_block = False - def get(self, request, *args, **kwargs): - return request.limited +class RatelimitCBVTests(TestCase): - blocked = BlockedView.as_view() - unblocked = UnBlockedView.as_view() + def setUp(self): + cache.clear() - req = RequestFactory().get('/') + def test_limit_ip(self): - assert not blocked(req), 'First request works.' - with self.assertRaises(Ratelimited): - blocked(req) + class RLView(RatelimitMixin, View): + ratelimit_key = 'ip' + ratelimit_method = ratelimit.ALL + ratelimit_rate = '1/m' + ratelimit_block = True - assert unblocked(req), 'Request is limited but not blocked.' + rlview = RLView.as_view() - def test_method(self): - rf = RequestFactory() - post = rf.post('/') - get = rf.get('/') + req = rf.get('/') + assert rlview(req), 'First request works.' + with self.assertRaises(Ratelimited): + rlview(req) - class LimitPostView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_method = ['POST'] - ratelimit_rate = '1/m' + def test_block(self): - def post(self, request, *args, **kwargs): - return request.limited - get = post + class BlockedView(RatelimitMixin, View): + ratelimit_group = 'cbv:block' + ratelimit_key = 'ip' + ratelimit_method = ratelimit.ALL + ratelimit_rate = '1/m' + ratelimit_block = True - class LimitGetView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_method = ['POST', 'GET'] - ratelimit_rate = '1/m' + def get(self, request, *args, **kwargs): + return request.limited - def post(self, request, *args, **kwargs): - return request.limited - get = post + class UnBlockedView(RatelimitMixin, View): + ratelimit_group = 'cbv:block' + ratelimit_key = 'ip' + ratelimit_method = ratelimit.ALL + ratelimit_rate = '1/m' + ratelimit_block = False - limit_post = LimitPostView.as_view() - limit_get = LimitGetView.as_view() + def get(self, request, *args, **kwargs): + return request.limited - assert not limit_post(post), 'Do not limit first POST.' - assert limit_post(post), 'Limit second POST.' - assert not limit_post(get), 'Do not limit GET.' + blocked = BlockedView.as_view() + unblocked = UnBlockedView.as_view() - assert limit_get(post), 'Limit first POST.' - assert limit_get(get), 'Limit first GET.' + req = rf.get('/') - def test_field(self): - james = RequestFactory().post('/', {'username': 'james'}) - john = RequestFactory().post('/', {'username': 'john'}) + assert not blocked(req), 'First request works.' + with self.assertRaises(Ratelimited): + blocked(req) - class UsernameView(RateLimitMixin, View): - ratelimit_ip = False - ratelimit_field = 'username' - ratelimit_rate = '1/m' + assert unblocked(req), 'Request is limited but not blocked.' - def post(self, request, *args, **kwargs): - return request.limited - get = post + def test_method(self): + post = rf.post('/') + get = rf.get('/') - username = UsernameView.as_view() - assert not username(james), "james' first request is fine." - assert username(james), "james' second request is limited." - assert not username(john), "john's first request is fine." + class LimitPostView(RatelimitMixin, View): + ratelimit_group = 'cbv:method' + ratelimit_key = 'ip' + ratelimit_method = ['POST'] + ratelimit_rate = '1/m' - def test_field_unicode(self): - post = RequestFactory().post('/', {'username': u'fran\xe7ois'}) + def post(self, request, *args, **kwargs): + return request.limited + get = post - class UsernameView(RateLimitMixin, View): - ratelimit_ip = False - ratelimit_field = 'username' - ratelimit_rate = '1/m' + class LimitGetView(RatelimitMixin, View): + ratelimit_group = 'cbv:method' + ratelimit_key = 'ip' + ratelimit_method = ['POST', 'GET'] + ratelimit_rate = '1/m' - def post(self, request, *args, **kwargs): - return request.limited - get = post + def post(self, request, *args, **kwargs): + return request.limited + get = post - view = UsernameView.as_view() + limit_post = LimitPostView.as_view() + limit_get = LimitGetView.as_view() - assert not view(post), 'First request is not limited.' - assert view(post), 'Second request is limited.' + assert not limit_post(post), 'Do not limit first POST.' + assert limit_post(post), 'Limit second POST.' + assert not limit_post(get), 'Do not limit GET.' - def test_field_empty(self): - post = RequestFactory().post('/', {}) + assert limit_get(post), 'Limit first POST.' + assert limit_get(get), 'Limit first GET.' - class EmptyFieldView(RateLimitMixin, View): - ratelimit_ip = False - ratelimit_field = 'username' - ratelimit_rate = '1/m' + def test_rate(self): + req = rf.post('/') - def post(self, request, *args, **kwargs): - return request.limited - get = post + class TwiceView(RatelimitMixin, View): + ratelimit_key = 'ip' + ratelimit_rate = '2/m' - view = EmptyFieldView.as_view() + def post(self, request, *args, **kwargs): + return request.limited + get = post - assert not view(post), 'First request is not limited.' - assert view(post), 'Second request is limited.' + twice = TwiceView.as_view() - def test_rate(self): - req = RequestFactory().post('/') + assert not twice(req), 'First request is not limited.' + assert not twice(req), 'Second request is not limited.' + assert twice(req), 'Third request is limited.' - class TwiceView(RateLimitMixin, View): - ratelimit_ip = True - ratelimit_rate = '2/m' + @override_settings(RATELIMIT_USE_CACHE='fake-cache') + def test_bad_cache(self): + """The RATELIMIT_USE_CACHE setting works if the cache exists.""" + self.skipTest('I do not know why this fails when the other works.') - def post(self, request, *args, **kwargs): - return request.limited - get = post + class BadCacheView(RatelimitMixin, View): + ratelimit_key = 'ip' - twice = TwiceView.as_view() + def post(self, request, *args, **kwargs): + return request + get = post + view = BadCacheView.as_view() - assert not twice(req), 'First request is not limited.' - assert not twice(req), 'Second request is not limited.' - assert twice(req), 'Third request is limited.' + req = rf.post('/') - def test_skip_if(self): - req = RequestFactory().post('/') + with self.assertRaises(InvalidCacheBackendError): + view(req) - class SkipIfView(RateLimitMixin, View): - ratelimit_rate = '1/m' - ratelimit_skip_if = lambda r: getattr(r, 'skip', False) + def test_keys(self): + """Allow custom functions to set cache keys.""" - def post(self, request, *args, **kwargs): - return request.limited - get = post - view = SkipIfView.as_view() + def user_or_ip(group, req): + if req.user.is_authenticated: + return 'uip:%d' % req.user.pk + return 'uip:%s' % req.META['REMOTE_ADDR'] - assert not view(req), 'First request is not limited.' - assert view(req), 'Second request is limited.' - del req.limited - req.skip = True - assert not view(req), 'Skipped request is not limited.' + class KeysView(RatelimitMixin, View): + ratelimit_key = user_or_ip + ratelimit_block = False + ratelimit_rate = '1/m' - @override_settings(RATELIMIT_USE_CACHE='fake-cache') - def test_bad_cache(self): - """The RATELIMIT_USE_CACHE setting works if the cache exists.""" + def post(self, request, *args, **kwargs): + return request.limited + get = post + view = KeysView.as_view() - class BadCacheView(RateLimitMixin, View): + req = rf.post('/') + req.user = MockUser(authenticated=False) - def post(self, request, *args, **kwargs): - return request - get = post - view = BadCacheView.as_view() + assert not view(req), 'First unauthenticated request is allowed.' + assert view(req), 'Second unauthenticated request is limited.' - req = RequestFactory().post('/') + del req.limited + req.user = MockUser(authenticated=True) - with self.assertRaises(InvalidCacheBackendError): - view(req) + assert not view(req), 'First authenticated request is allowed.' + assert view(req), 'Second authenticated is limited.' - def test_keys(self): - """Allow custom functions to set cache keys.""" - class User(object): - def __init__(self, authenticated=False): - self.pk = 1 - self.authenticated = authenticated + def test_method_decorator(self): + class TestView(View): + @ratelimit(key='ip', rate='1/m', block=False) + def post(self, request): + return request.limited - def is_authenticated(self): - return self.authenticated + view = TestView.as_view() - def user_or_ip(req): - if req.user.is_authenticated(): - return 'uip:%d' % req.user.pk - return 'uip:%s' % req.META['REMOTE_ADDR'] + req = rf.post('/') - class KeysView(RateLimitMixin, View): - ratelimit_ip = False - ratelimit_block = False - ratelimit_rate = '1/m' - ratelimit_keys = user_or_ip - - def post(self, request, *args, **kwargs): - return request.limited - get = post - view = KeysView.as_view() - - req = RequestFactory().post('/') - req.user = User(authenticated=False) - - assert not view(req), 'First unauthenticated request is allowed.' - assert view(req), 'Second unauthenticated request is limited.' - - del req.limited - req.user = User(authenticated=True) - - assert not view(req), 'First authenticated request is allowed.' - assert view(req), 'Second authenticated is limited.' + assert not view(req) + assert view(req) diff --git a/ratelimit/utils.py b/ratelimit/utils.py new file mode 100644 index 0000000..f64a4fb --- /dev/null +++ b/ratelimit/utils.py @@ -0,0 +1,186 @@ +import hashlib +import re +import time +import zlib +from importlib import import_module + +from django.conf import settings +from django.core.cache import caches +from django.core.exceptions import ImproperlyConfigured + +from ratelimit import ALL, UNSAFE + + +__all__ = ['is_ratelimited'] + +_PERIODS = { + 's': 1, + 'm': 60, + 'h': 60 * 60, + 'd': 24 * 60 * 60, +} + +# Extend the expiration time by a few seconds to avoid misses. +EXPIRATION_FUDGE = 5 + + +def user_or_ip(request): + if request.user.is_authenticated: + return str(request.user.pk) + return request.META['REMOTE_ADDR'] + + +_SIMPLE_KEYS = { + 'ip': lambda r: r.META['REMOTE_ADDR'], + 'user': lambda r: str(r.user.pk), + 'user_or_ip': user_or_ip, +} + + +def get_header(request, header): + key = 'HTTP_' + header.replace('-', '_').upper() + return request.META.get(key, '') + + +_ACCESSOR_KEYS = { + 'get': lambda r, k: r.GET.get(k, ''), + 'post': lambda r, k: r.POST.get(k, ''), + 'header': get_header, +} + + +def _method_match(request, method=ALL): + if method == ALL: + return True + if not isinstance(method, (list, tuple)): + method = [method] + return request.method in [m.upper() for m in method] + + +rate_re = re.compile(r'([\d]+)/([\d]*)([smhd])?') + + +def _split_rate(rate): + if isinstance(rate, tuple): + return rate + count, multi, period = rate_re.match(rate).groups() + count = int(count) + if not period: + period = 's' + seconds = _PERIODS[period.lower()] + if multi: + seconds = seconds * int(multi) + return count, seconds + + +def _get_window(value, period): + ts = int(time.time()) + if period == 1: + return ts + if not isinstance(value, bytes): + value = value.encode('utf-8') + w = ts - (ts % period) + (zlib.crc32(value) % period) + if w < ts: + return w + period + return w + + +def _make_cache_key(group, rate, value, methods): + count, period = _split_rate(rate) + safe_rate = '%d/%ds' % (count, period) + window = _get_window(value, period) + parts = [group + safe_rate, value, str(window)] + if methods is not None: + if methods == ALL: + methods = '' + elif isinstance(methods, (list, tuple)): + methods = ''.join(sorted([m.upper() for m in methods])) + parts.append(methods) + prefix = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:') + return prefix + hashlib.md5(u''.join(parts).encode('utf-8')).hexdigest() + + +def is_ratelimited(request, group=None, fn=None, key=None, rate=None, + method=ALL, increment=False): + if group is None: + if hasattr(fn, '__self__'): + parts = fn.__module__, fn.__self__.__class__.__name__, fn.__name__ + else: + parts = (fn.__module__, fn.__name__) + group = '.'.join(parts) + + if not getattr(settings, 'RATELIMIT_ENABLE', True): + request.limited = False + return False + + if not _method_match(request, method): + return False + + old_limited = getattr(request, 'limited', False) + + if callable(rate): + rate = rate(group, request) + + if rate is None: + request.limited = old_limited + return False + usage = get_usage_count(request, group, fn, key, rate, method, increment) + + fail_open = getattr(settings, 'RATELIMIT_FAIL_OPEN', False) + + usage_count = usage.get('count') + if usage_count is None: + limited = not fail_open + else: + usage_limit = usage.get('limit') + limited = usage_count > usage_limit + + if increment: + request.limited = old_limited or limited + return limited + + +def get_usage_count(request, group=None, fn=None, key=None, rate=None, + method=ALL, increment=False): + if not key: + raise ImproperlyConfigured('Ratelimit key must be specified') + limit, period = _split_rate(rate) + cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') + cache = caches[cache_name] + + if callable(key): + value = key(group, request) + elif key in _SIMPLE_KEYS: + value = _SIMPLE_KEYS[key](request) + elif ':' in key: + accessor, k = key.split(':', 1) + if accessor not in _ACCESSOR_KEYS: + raise ImproperlyConfigured('Unknown ratelimit key: %s' % key) + value = _ACCESSOR_KEYS[accessor](request, k) + elif '.' in key: + mod, attr = key.rsplit('.', 1) + keyfn = getattr(import_module(mod), attr) + value = keyfn(group, request) + else: + raise ImproperlyConfigured( + 'Could not understand ratelimit key: %s' % key) + + cache_key = _make_cache_key(group, rate, value, method) + time_left = _get_window(value, period) - int(time.time()) + initial_value = 1 if increment else 0 + added = cache.add(cache_key, initial_value, period + EXPIRATION_FUDGE) + if added: + count = initial_value + else: + if increment: + try: + count = cache.incr(cache_key) + except ValueError: + count = initial_value + else: + count = cache.get(cache_key, initial_value) + return {'count': count, 'limit': limit, 'time_left': time_left} + + +is_ratelimited.ALL = ALL +is_ratelimited.UNSAFE = UNSAFE diff --git a/run.sh b/run.sh index 20ea3f9..2e578ac 100755 --- a/run.sh +++ b/run.sh @@ -5,14 +5,19 @@ export DJANGO_SETTINGS_MODULE="test_settings" usage() { echo "USAGE: $0 [command]" - echo " test - run the jsonview tests" + echo " test - run the ratelimit tests" + echo " flake8 - run flake8" echo " shell - open the Django shell" exit 1 } case "$1" in "test" ) - django-admin.py test ratelimit ;; + shift; + django-admin.py test ratelimit $@;; + "flake8" ) + shift; + flake8 $@ ratelimit/;; "shell" ) django-admin.py shell ;; * ) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..52be783 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +universal=1 + +[flake8] +ignore=E731 diff --git a/setup.py b/setup.py index 878cd5c..18c506b 100644 --- a/setup.py +++ b/setup.py @@ -9,21 +9,32 @@ setup( description='Cache-based rate-limiting for Django.', long_description=open('README.rst').read(), author='James Socol', - author_email='james@mozilla.com', - url='http://github.com/jsocol/django-ratelimit', + author_email='me@jamessocol.com', + url='https://github.com/jsocol/django-ratelimit', license='Apache Software License', packages=find_packages(exclude=['test_settings']), + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', include_package_data=True, - package_data = {'': ['README.rst']}, + package_data={'': ['README.rst']}, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', - 'Environment :: Web Environment :: Mozilla', 'Framework :: Django', + 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries :: Python Modules', ] ) diff --git a/test_settings.py b/test_settings.py index e472f94..634b792 100644 --- a/test_settings.py +++ b/test_settings.py @@ -11,6 +11,22 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'ratelimit-tests', }, + 'connection-errors': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': 'test-connection-errors', + }, + 'connection-errors-redis': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'test-connection-errors', + 'OPTIONS': { + 'IGNORE_EXCEPTIONS': True, + } + }, + 'instant-expiration': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'test-instant-expiration', + 'TIMEOUT': 0, + }, } DATABASES = { @@ -19,3 +35,6 @@ DATABASES = { 'NAME': 'test.db', }, } + +# silence system check about unset `MIDDLEWARE_CLASSES` +SILENCED_SYSTEM_CHECKS = ['1_7.W001'] diff --git a/tox.ini b/tox.ini index 501d997..23f7a1a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,40 +1,23 @@ +[tox] +envlist = + py27-django111, + py34-django{111,20}, + py35-django{111,20,21,master}, + py36-django{111,20,21,master}, + py37-django{20,21,master}, + pypy-django111 + [testenv] -commands = ./run.sh test +deps = + py{27,py}: python-memcached>=1.57 + py{34,35,36,37}: python3-memcached>=1.51 + django111: Django>=1.11,<1.12 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + djangomaster: https://github.com/django/django/archive/master.tar.gz + django-redis==4.9.0 + flake8 -# python 3.3 - -[testenv:py33-1.6] -basepython = python3.3 -deps = Django>=1.6,<1.6.99 - -[testenv:py33-1.5] -basepython = python3.3 -deps = Django>=1.5,<1.5.99 - -# python 2.7 - -[testenv:py27-1.6] -basepython = python2.7 -deps = Django>=1.6,<1.6.99 - -[testenv:py27-1.5] -basepython = python2.7 -deps = Django>=1.5,<1.5.99 - -[testenv:py27-1.4] -basepython = python2.7 -deps = Django>=1.4,<1.4.99 - -# python 2.6 - -[testenv:py26-1.6] -basepython = python2.6 -deps = Django>=1.6,<1.6.99 - -[testenv:py26-1.5] -basepython = python2.6 -deps = Django>=1.5,<1.5.99 - -[testenv:py26-1.4] -basepython = python2.6 -deps = Django>=1.4,<1.4.99 +commands = + ./run.sh test + ./run.sh flake8