├── .gitignore ├── .travis.yml ├── LICENCE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── reference.rst └── usage.rst ├── ratelimitbackend ├── __init__.py ├── admin.py ├── backends.py ├── exceptions.py ├── forms.py ├── middleware.py ├── models.py └── views.py ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── backends.py ├── forms.py ├── models.py ├── templates │ ├── custom_login.html │ └── token_only_login.html ├── test_backends.py └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | __pycache__ 3 | dist 4 | django_ratelimit_backend.egg-info 5 | .tox 6 | docs/_build 7 | build 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3.6 3 | sudo: false 4 | env: 5 | - TOXENV=py27-django111 6 | - TOXENV=py34-django111 7 | - TOXENV=py35-django111 8 | - TOXENV=py36-django111 9 | - TOXENV=py34-django20 10 | - TOXENV=py35-django20 11 | - TOXENV=py36-django20 12 | - TOXENV=py35-django21 13 | - TOXENV=py36-django21 14 | - TOXENV=docs 15 | - TOXENV=lint 16 | install: 17 | - pip install tox 18 | script: 19 | - tox -e $TOXENV 20 | addons: 21 | apt: 22 | sources: 23 | - deadsnakes 24 | packages: 25 | - python3.5 # https://github.com/travis-ci/travis-ci/issues/4794 26 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2010, Bruno Renié and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Bruno Renié nor the names of his contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENCE 2 | recursive-include docs * 3 | recursive-exclude docs/_build * 4 | recursive-exclude tests * 5 | global-exclude *.pyc 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django-ratelimit-backend 2 | ------------------------ 3 | 4 | .. image:: https://api.travis-ci.org/brutasse/django-ratelimit-backend.png 5 | :alt: Build Status 6 | :target: https://travis-ci.org/brutasse/django-ratelimit-backend 7 | 8 | Rate-limit your login attempts at the authentication backend level. Login 9 | attempts are stored in the cache for 5 minutes and IPs with more than 30 10 | failed login attempts in the last 5 minutes are blocked. 11 | 12 | The numbers (30 attempts, 5 minutes) as well as the blocking strategy can be 13 | customized. 14 | 15 | * Authors: Bruno Renié and `contributors`_ 16 | 17 | .. _contributors: https://github.com/brutasse/django-ratelimit-backend/contributors 18 | 19 | * Licence: BSD 20 | 21 | * Compatibility: Django 1.8 and greater 22 | 23 | * Documentation: https://django-ratelimit-backend.readthedocs.io 24 | 25 | * Code: https://github.com/brutasse/django-ratelimit-backend 26 | 27 | Credits 28 | ------- 29 | 30 | * Simon Willison for his `ratelimitcache`_ idea 31 | 32 | .. _ratelimitcache: http://blog.simonwillison.net/post/57956846132/ratelimitcache 33 | 34 | Hacking 35 | ------- 36 | 37 | :: 38 | 39 | git clone https://brutasse@github.com/brutasse/django-ratelimit-backend.git 40 | 41 | Hack and run the tests:: 42 | 43 | python setup.py test 44 | 45 | To run the tests for all supported Python and Django versions:: 46 | 47 | pip install tox 48 | tox 49 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-ratelimit-backend.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-ratelimit-backend.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-ratelimit-backend" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-ratelimit-backend" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-ratelimit-backend documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Oct 18 13:27:32 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import datetime 15 | 16 | import sphinx_rtd_theme 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'django-ratelimit-backend' 46 | copyright = u'2011-{0}, Bruno Renié'.format(datetime.datetime.today().year) 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = '2.0' 54 | # The full version, including alpha/beta/rc tags. 55 | release = '2.0' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = 'sphinx_rtd_theme' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | #html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_domain_indices = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 155 | #html_show_sphinx = True 156 | 157 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 158 | #html_show_copyright = True 159 | 160 | # If true, an OpenSearch description file will be output, and all pages will 161 | # contain a tag referring to it. The value of this option must be the 162 | # base URL from which the finished HTML is served. 163 | #html_use_opensearch = '' 164 | 165 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 166 | #html_file_suffix = None 167 | 168 | # Output file base name for HTML help builder. 169 | htmlhelp_basename = 'django-ratelimit-backenddoc' 170 | 171 | 172 | # -- Options for LaTeX output -------------------------------------------------- 173 | 174 | # The paper size ('letter' or 'a4'). 175 | #latex_paper_size = 'letter' 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #latex_font_size = '10pt' 179 | 180 | # Grouping the document tree into LaTeX files. List of tuples 181 | # (source start file, target name, title, author, documentclass [howto/manual]). 182 | latex_documents = [ 183 | ('index', 'django-ratelimit-backend.tex', u'django-ratelimit-backend Documentation', 184 | u'Bruno Renié', 'manual'), 185 | ] 186 | 187 | # The name of an image file (relative to this directory) to place at the top of 188 | # the title page. 189 | #latex_logo = None 190 | 191 | # For "manual" documents, if this is true, then toplevel headings are parts, 192 | # not chapters. 193 | #latex_use_parts = False 194 | 195 | # If true, show page references after internal links. 196 | #latex_show_pagerefs = False 197 | 198 | # If true, show URL addresses after external links. 199 | #latex_show_urls = False 200 | 201 | # Additional stuff for the LaTeX preamble. 202 | #latex_preamble = '' 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'django-ratelimit-backend', u'django-ratelimit-backend Documentation', 217 | [u'Bruno Renié'], 1) 218 | ] 219 | 220 | DIRECTORIES = ( 221 | ('', 'make html'), 222 | ) 223 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django-ratelimit-backend 2 | ======================== 3 | 4 | Django-ratelimit-backend is an app that allows rate-limiting of login attempts 5 | at the authentication backend level. Login attempts are stored in the cache so 6 | you need a properly configured cache setup. 7 | 8 | By default, it blocks any IP that has more than 30 failed login attempts in 9 | the past 5 minutes. The IP can still browse your site, only login attempts are 10 | blocked. 11 | 12 | .. note:: 13 | 14 | If you use a custom authentication backend, there is an additional 15 | configuration step. Check the :ref:`custom backends ` 16 | section. 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | usage 22 | reference 23 | 24 | Get involved, submit issues and pull requests on the `code repository`_! 25 | 26 | .. _code repository: https://github.com/brutasse/django-ratelimit-backend 27 | 28 | Changes 29 | ------- 30 | 31 | * **2.0** (2018-08-27): 32 | 33 | * Add support for Django 2.0 and 2.1, and drop support for Django < 1.11. 34 | 35 | * **1.2** (2017-09-13): 36 | 37 | * Add ``no_username`` attribute on authentication backend for token-based 38 | authentication (Jody McIntyre). 39 | 40 | * Fix Travis build for Python 3.3 (Jody McIntyre). 41 | 42 | * **1.1.1** (2017-03-30): 43 | 44 | * Run tests on Python 3.6. 45 | 46 | * Run without warnings on supported Django versions. 47 | 48 | * **1.1** (2017-03-16): 49 | 50 | * Exclude tests from being installed from the wheel file. 51 | 52 | * Add support for Django 1.10 and 1.11. 53 | 54 | * **1.0** (2015-07-10): 55 | 56 | * Silence warnings with Django 1.8. 57 | 58 | * **0.6.4** (2015-03-31): 59 | 60 | * Only set the redirect field to the value of ``request.get_full_path()`` if 61 | the field does not already have a value. Patch by Michael Blatherwick. 62 | 63 | * **0.6.3** (2015-02-12): 64 | 65 | * Add ``RatelimitMixin.get_ip``. 66 | 67 | * **0.6.2** (2014-07-28): 68 | 69 | * Django 1.7 support. Patch by Mathieu Agopian. 70 | 71 | * **0.6.1** (2014-01-21): 72 | 73 | * Removed calls to deprecated ``check_test_cookie()``. 74 | 75 | * **0.6** (2013-04-18): 76 | 77 | The ``RatelimitBackend`` now allows arbitrary ``kwargs`` for authentication, 78 | not just ``username`` and ``password``. Patch by Trey Hunner. 79 | 80 | * **0.5** (2013-02-14): 81 | 82 | * Python 3 compatibility. 83 | 84 | * The backend now issues a warning (``warnings.warn()``) instead of a logging 85 | call when no request is passed to the backend. This is because such cases 86 | are developer errors so a warning is more appropriate. 87 | 88 | * **0.4** (2013-01-20): 89 | 90 | * Automatically re-register models which have been registered in 91 | Django's default admin site instance. There is no need to register 92 | 3rd-party models anymore. 93 | 94 | * Fixed a couple of deprecation warnings. 95 | 96 | * **0.3** (2012-11-22): 97 | 98 | * Removed the part where the admin login form looked up a User object 99 | when an email was used to login. This brings support for Django 1.5's 100 | swappable user models. 101 | 102 | * **0.2** (2012-07-31): 103 | 104 | * Added a logging call when a user reaches its rate-limit. 105 | 106 | * **0.1**: 107 | 108 | * Initial version. 109 | 110 | Indices and tables 111 | ================== 112 | 113 | * :ref:`genindex` 114 | * :ref:`modindex` 115 | * :ref:`search` 116 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | --------- 3 | 4 | .. _backends: 5 | 6 | Authentication backends 7 | ``````````````````````` 8 | 9 | .. module:: ratelimitbackend.backends 10 | :synopsis: Backend classes for enabling rate-limiting. 11 | 12 | .. class:: RateLimitMixin 13 | 14 | This is where the rate-limiting logic is implemented. Failed login 15 | attempts are cached for 5 minutes and when the threshold is reached, the 16 | remote IP is blocked whether its attempts are valid or not. 17 | 18 | .. attribute:: RateLimitMixin.cache_prefix 19 | 20 | The prefix to use for cache keys. Defaults to ``'ratelimitbackend-'`` 21 | 22 | .. attribute:: RateLimitMixin.minutes 23 | 24 | Number of minutes after which login attempts are not taken into account. 25 | Defaults to ``5``. 26 | 27 | .. attribute:: RateLimitMixin.requests 28 | 29 | Number of login attempts to allow during ``minutes``. Defaults to ``30``. 30 | 31 | .. method:: RateLimitMixin.authenticate(username, password, request) 32 | 33 | Tries to ``authenticate(username, password)`` on the parent backend and 34 | use the request for rate-limiting. 35 | 36 | .. method:: RateLimitMixin.get_counters(request) 37 | 38 | Fetches the previous failed login attempts from the cache. There is one 39 | cache key per minute slot. 40 | 41 | .. method:: RateLimitMixin.keys_to_check(request) 42 | 43 | Returns the list of keys to try to fetch from the cache for previous login 44 | attempts. For a 5-minute limit, this returns the 5 relevant cache keys. 45 | 46 | .. method:: RateLimitMixin.get_cache_key(request) 47 | 48 | Returns the cache key for the current time. This is the key to increment 49 | if the login attempt has failed. 50 | 51 | .. method:: RateLimitMixin.key(request, dt) 52 | 53 | Derives a cache key from the request and a datetime object. The datetime 54 | object can be present (for the current request) or past (for the previous 55 | cache keys). 56 | 57 | .. method:: RateLimitMixin.get_ip(request) 58 | 59 | Extracts the client IP address from the request. By defaults the IP is 60 | read from request.META['REMOTE_ADDR'] but you can override this if you 61 | have a proxy that uses a custom header such as ``X-Forwarded-For``. 62 | 63 | .. method:: RateLimitMixin.cache_incr(key) 64 | 65 | Performs an increment operation on ``key``. The implementation is **not** 66 | atomic. If you have a cache backend that supports atomic increment 67 | operations, you're advised to override this method. 68 | 69 | .. method:: RateLimitMixin.expire_after() 70 | 71 | Returns the cache timeout for keys. 72 | 73 | .. class:: RateLimitModelBackend 74 | 75 | A rate-limited version of ``django.contrib.auth.backends.ModelBackend``. 76 | 77 | This is a subclass of ``django.contrib.auth.backends.ModelBackend`` that 78 | adds rate-limiting. If you have custom backends, make sure they inherit 79 | from this instead of the default ``ModelBackend``. 80 | 81 | If your backend has nothing to do with Django’s auth system, use 82 | ``RateLimitMixin`` to inject the rate-limiting functionality in your 83 | backend. 84 | 85 | Exceptions 86 | `````````` 87 | 88 | .. module:: ratelimitbackend.exceptions 89 | :synopsis: Exceptions thrown when the limit is reached. 90 | 91 | .. class:: RateLimitException 92 | 93 | The exception thrown when a user reaches the limits. 94 | 95 | .. attribute:: RateLimitException.counts 96 | 97 | A dictionnary containing the cache keys for every minute and the 98 | corresponding failed login attempts. 99 | 100 | Example: 101 | 102 | .. code-block:: python 103 | 104 | { 105 | 'ratelimitbackend-127.0.0.1-201110181448': 12, 106 | 'ratelimitbackend-127.0.0.1-201110181449': 18, 107 | } 108 | 109 | Admin 110 | ````` 111 | 112 | .. module:: ratelimitbackend.admin 113 | :synopsis: The admin site with rate limits. 114 | 115 | .. class:: RateLimitAdminSite 116 | 117 | Rate-limited version of the default Django admin site. If you use the 118 | default admin site (``django.contrib.admin.site``), it won’t be 119 | rate-limited. 120 | 121 | If you have a custom admin site (inheriting from ``AdminSite``), you need to 122 | make it inherit from ``ratelimitbackend.RateLimitAdminSite``, replacing: 123 | 124 | .. code-block:: python 125 | 126 | from django.contrib import admin 127 | 128 | class AdminSite(admin.AdminSite): 129 | pass 130 | site = AdminSite() 131 | 132 | with: 133 | 134 | .. code-block:: python 135 | 136 | from ratelimitbackend import admin 137 | 138 | class AdminSite(admin.RateLimitAdminSite): 139 | pass 140 | site = AdminSite() 141 | 142 | Make sure your calls to ``admin.site.register`` reference the correct admin 143 | site. 144 | 145 | .. method:: RateLimitAdminSite.login(request, extra_context=None) 146 | 147 | This method calls django-ratelimit-backend's version of the login view. 148 | 149 | .. _middleware: 150 | 151 | Middleware 152 | `````````` 153 | 154 | .. module:: ratelimitbackend.middleware 155 | 156 | .. class:: RateLimitMiddleware 157 | 158 | This middleware catches ``RateLimitException`` and returns a 403 instead, 159 | with a ``'text/plain'`` mimetype. Use your custom middleware if you need a 160 | different behaviour. 161 | 162 | Views 163 | ````` 164 | 165 | .. module:: ratelimitbackend.views 166 | 167 | .. function:: login(request[, template_name, redirect_field_name, authentication_form]) 168 | 169 | This function uses a custom authentication form and passes it the request 170 | object. The external API is the same as `Django's login view`_. 171 | 172 | .. _Django's login view: https://docs.djangoproject.com/en/dev/topics/auth/#django.contrib.auth.views.login 173 | 174 | Forms 175 | ````` 176 | 177 | .. module:: ratelimitbackend.forms 178 | 179 | .. class:: AuthenticationForm 180 | 181 | A subclass of `Django's authentication form`_ that passes the request 182 | object to the ``authenticate()`` function, hence to the authentication 183 | backend. 184 | 185 | .. _Django's authentication form: https://docs.djangoproject.com/en/dev/topics/auth/#django.contrib.auth.forms.AuthenticationForm 186 | 187 | Logging 188 | ``````` 189 | 190 | Failed attempts are logged using a logger named ``'ratelimitbackend'``. Here 191 | is an example for logging to the standard output: 192 | 193 | .. code-block:: python 194 | 195 | LOGGING = { 196 | 'formatters': { 197 | 'simple': { 198 | 'format': '%(asctime)s %(levelname)s: %(message)s' 199 | }, 200 | # other formatters 201 | }, 202 | 'handlers': { 203 | 'console': { 204 | 'level': 'DEBUG', 205 | 'class': 'logging.StreamHandler', 206 | 'formatter': 'simple', 207 | }, 208 | # other handlers 209 | }, 210 | 'loggers': { 211 | 'ratelimitbackend': { 212 | 'handlers': ['console'], 213 | 'level': 'INFO', 214 | }, 215 | # other loggers 216 | }, 217 | } 218 | 219 | You will see two kinds of messages: 220 | 221 | * "No request passed to the backend, unable to rate-limit. Username was…" 222 | 223 | This means you're not using the app correctly, the request object wasn't 224 | passed to the authentication backend. Double-check the documentation, and if 225 | you make manual calls to login-related functions you may need to pass the 226 | request object manually. 227 | 228 | The log level for this message is: ``WARNING``. 229 | 230 | * "Login failed: username 'foo', IP 127.0.0.1" 231 | 232 | This is a failed attempt that has been temporarily cached. 233 | 234 | The log level for this message is: ``INFO``. 235 | 236 | * "Login rate-limit reached: username 'foo', IP 127.0.0.1" 237 | 238 | This means someone has used all his quotas and got a 239 | ``RateLimitException``, locking him temporarily until the quota decreases. 240 | 241 | The log level for this message is: ``WARNING``. 242 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Installation 5 | ------------ 6 | 7 | :: 8 | 9 | pip install django-ratelimit-backend 10 | 11 | There's nothing to add to your ``INSTALLED_APPS``, unless you want to run the 12 | tests. In which case, add ``'ratelimitbackend'``. 13 | 14 | Quickstart 15 | ---------- 16 | 17 | * Set your ``AUTHENTICATION_BACKENDS`` to: 18 | 19 | .. code-block:: python 20 | 21 | AUTHENTICATION_BACKENDS = ( 22 | 'ratelimitbackend.backends.RateLimitModelBackend', 23 | ) 24 | 25 | If you have a custom backend, see the :ref:`backends reference `. 26 | 27 | * Everytime you use ``django.contrib.auth.views.login``, use 28 | ``ratelimitbackend.views.login`` instead. 29 | 30 | * Register ratelimitbackend's admin URLs in your URLConf instead of the 31 | default admin URLs. 32 | 33 | In your ``urls.py``: 34 | 35 | .. code-block:: python 36 | 37 | from ratelimitbackend import admin 38 | 39 | urlpatterns += [ 40 | (r'^admin/', include(admin.site.urls)), 41 | ] 42 | 43 | Ratelimitbackend's admin site overrides the default admin login view to add 44 | rate-limiting. You can keep registering your models to the default admin 45 | site and they will show up in the ratelimitbackend-enabled admin. 46 | 47 | * Add ``'ratelimitbackend.middleware.RateLimitMiddleware'`` to your 48 | ``MIDDLEWARE_CLASSES``, or create you own middleware to handle rate limits. 49 | See the :ref:`middleware reference `. 50 | 51 | * If you use ``django.contrib.auth.forms.AuthenticationForm`` directly, 52 | replace it with ``ratelimitbackend.forms.AuthenticationForm`` and **always** 53 | pass it the request object. For instance: 54 | 55 | .. code-block:: python 56 | 57 | if request.method == 'POST': 58 | form = AuthenticationForm(data=request.POST, request=request) 59 | # etc. etc. 60 | 61 | If you use ``django.contrib.auth.authenticate``, pass it the request object 62 | as well. 63 | 64 | Customizing rate-limiting criteria 65 | ---------------------------------- 66 | 67 | By default, rate limits are based on the IP of the client. An IP that submits 68 | a form too many times gets rate-limited, whatever it submits. For custom 69 | rate-limiting you can subclass the backend and implement your own logic. 70 | 71 | Let's see with an example: instead of checking the client's IP, we will use a 72 | combination of the IP *and* the tried username. This way after 30 failed 73 | attempts with one username, people can start brute-forcing a new username. 74 | Yay! More seriously, it can become useful if you have lots of users logging in 75 | at the same time from the same IP. 76 | 77 | While we're at it, we'll also allow 50 login attempts every 10 minutes. 78 | 79 | To do this, simply subclass 80 | ``ratelimitbackend.backends.RateLimitModelBackend``: 81 | 82 | .. code-block:: python 83 | 84 | from ratelimitbackend.backends import RateLimitModelBackend 85 | 86 | class MyBackend(RateLimitModelBackend): 87 | minutes = 10 88 | requests = 50 89 | 90 | def key(self, request, dt): 91 | return '%s%s-%s-%s' % ( 92 | self.cache_prefix, 93 | self.get_ip(request), 94 | request.POST['username'], 95 | dt.strftime('%Y%m%d%H%M'), 96 | ) 97 | 98 | The ``key()`` method is used to build the cache keys storing the login 99 | attempts. The default implementation doesn't use POST data, here we're adding 100 | another part to the cache key. 101 | 102 | Note that we're not sanitizing anything, so we may end up with a rather long 103 | cache key. Be careful. 104 | 105 | For all the details about the rate-limiting implementation, see the 106 | :ref:`backend reference `. 107 | 108 | Using with other backends 109 | ------------------------- 110 | 111 | .. _custom_backends: 112 | 113 | The way django-ratelimit-backend is implemented requires the authentication 114 | backends to have an ``authenticate()`` that takes an additional ``request`` 115 | keyword argument. 116 | 117 | While django-ratelimit-backend works fine with the default ``ModelBackend`` by 118 | providing a replacement class, it's obviously not possible to do that for every 119 | single backend. 120 | 121 | The way to deal with this is to create a custom class using the 122 | ``RateLimitMixin`` class before registering the backend in your settings. For 123 | instance, for the LdapAuthBackend:: 124 | 125 | from django_auth_ldap.backend import LDAPBackend 126 | from ratelimitbackend.backends import RateLimitMixin 127 | 128 | class RateLimitedLDAPBackend(RateLimitMixin, LDAPBackend): 129 | pass 130 | 131 | AUTHENTICATION_BACKENDS = ( 132 | 'path.to.settings.RateLimitedLDAPBackend', 133 | ) 134 | 135 | ``RateLimitMixin`` lets you simply add rate-limiting capabilities to any 136 | authentication backend. 137 | 138 | ``RateLimitMixin`` throws a warning when no request is passed to its 139 | ``authenticate()`` method. This warning also contains the username that was 140 | passed. If you use an authentication backend that doesn't take the traditional 141 | ``username`` and ``password`` arguments, set the ``username_key`` attribute on the backend class to the proper keyword argument name. For instance, if your 142 | backend authenticates with an ``email``:: 143 | 144 | class CustomBackend(BaseBackend): 145 | def authenticate(self, email, password): 146 | ... 147 | 148 | class RateLimitedLCustomBackend(RateLimitMixin, CustomBackend): 149 | username_key = 'email' 150 | 151 | If your backend does not have the concept of a ``username`` at all, 152 | for example with OAuth 2 bearer token authentication, set the 153 | ``no_username`` attribute on the backend class to ``True``. 154 | 155 | The ``RateLimitNoUsernameModelBackend`` can be used for this purpose 156 | if you don't need any additional customization. 157 | -------------------------------------------------------------------------------- /ratelimitbackend/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.0' 2 | -------------------------------------------------------------------------------- /ratelimitbackend/admin.py: -------------------------------------------------------------------------------- 1 | # Allow transitive imports, e.g. 2 | # `from ratelimitbackend import admin; admin.ModelAdmin` 3 | from django.contrib.admin import * # noqa 4 | from django.contrib.admin import site as django_site 5 | from django.contrib.auth import REDIRECT_FIELD_NAME 6 | from django.utils.translation import ugettext as _ 7 | 8 | from .forms import AdminAuthenticationForm 9 | from .views import login 10 | 11 | 12 | class RateLimitAdminSite(AdminSite): # noqa 13 | def login(self, request, extra_context=None): 14 | """ 15 | Displays the login form for the given HttpRequest. 16 | """ 17 | context = { 18 | 'title': _('Log in'), 19 | 'app_path': request.get_full_path(), 20 | } 21 | if (REDIRECT_FIELD_NAME not in request.GET and 22 | REDIRECT_FIELD_NAME not in request.POST): 23 | context[REDIRECT_FIELD_NAME] = request.get_full_path() 24 | context.update(extra_context or {}) 25 | defaults = { 26 | 'extra_context': context, 27 | 'current_app': self.name, 28 | 'authentication_form': self.login_form or AdminAuthenticationForm, 29 | 'template_name': self.login_template or 'admin/login.html', 30 | } 31 | return login(request, **defaults) 32 | 33 | 34 | site = RateLimitAdminSite() 35 | 36 | for model, admin in django_site._registry.items(): 37 | site.register(model, admin.__class__) 38 | -------------------------------------------------------------------------------- /ratelimitbackend/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | 4 | from datetime import datetime, timedelta 5 | 6 | from django.contrib.auth.backends import ModelBackend 7 | from django.core.cache import cache 8 | 9 | from .exceptions import RateLimitException 10 | 11 | logger = logging.getLogger('ratelimitbackend') 12 | 13 | 14 | class RateLimitMixin(object): 15 | """ 16 | A mixin to enable rate-limiting in an existing authentication backend. 17 | """ 18 | cache_prefix = 'ratelimitbackend-' 19 | minutes = 5 20 | requests = 30 21 | username_key = 'username' 22 | no_username = False 23 | 24 | def authenticate(self, request=None, **kwargs): 25 | username = None 26 | try: 27 | username = kwargs[self.username_key] 28 | except KeyError: 29 | if not self.no_username: 30 | raise 31 | 32 | if request is not None: 33 | counts = self.get_counters(request) 34 | if sum(counts.values()) >= self.requests: 35 | logger.warning( 36 | u"Login rate-limit reached: username '{0}', IP {1}".format( 37 | username, self.get_ip(request), 38 | ) 39 | ) 40 | raise RateLimitException('Rate-limit reached', counts) 41 | else: 42 | warnings.warn(u"No request passed to the backend, unable to " 43 | u"rate-limit. Username was '%s'" % username, 44 | stacklevel=2) 45 | user = super(RateLimitMixin, self).authenticate( 46 | request=request, **kwargs 47 | ) 48 | if user is None and request is not None: 49 | logger.info( 50 | u"Login failed: username '{0}', IP {1}".format( 51 | username, 52 | self.get_ip(request), 53 | ) 54 | ) 55 | cache_key = self.get_cache_key(request) 56 | self.cache_incr(cache_key) 57 | return user 58 | 59 | def get_counters(self, request): 60 | return cache.get_many(self.keys_to_check(request)) 61 | 62 | def keys_to_check(self, request): 63 | now = datetime.now() 64 | return [ 65 | self.key( 66 | request, 67 | now - timedelta(minutes=minute), 68 | ) for minute in range(self.minutes + 1) 69 | ] 70 | 71 | def get_cache_key(self, request): 72 | return self.key(request, datetime.now()) 73 | 74 | def key(self, request, dt): 75 | return '%s%s-%s' % ( 76 | self.cache_prefix, 77 | self.get_ip(request), 78 | dt.strftime('%Y%m%d%H%M'), 79 | ) 80 | 81 | def get_ip(self, request): 82 | return request.META['REMOTE_ADDR'] 83 | 84 | def cache_incr(self, key): 85 | """ 86 | Non-atomic cache increment operation. Not optimal but 87 | consistent across different cache backends. 88 | """ 89 | cache.set(key, cache.get(key, 0) + 1, self.expire_after()) 90 | 91 | def expire_after(self): 92 | """Cache expiry delay""" 93 | return (self.minutes + 1) * 60 94 | 95 | 96 | class RateLimitModelBackend(RateLimitMixin, ModelBackend): 97 | pass 98 | 99 | 100 | class RateLimitNoUsernameModelBackend(RateLimitModelBackend): 101 | no_username = True 102 | -------------------------------------------------------------------------------- /ratelimitbackend/exceptions.py: -------------------------------------------------------------------------------- 1 | class RateLimitException(Exception): 2 | def __init__(self, msg, counts): 3 | self.counts = counts 4 | super(RateLimitException, self).__init__(msg) 5 | -------------------------------------------------------------------------------- /ratelimitbackend/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.admin.forms import AdminAuthenticationForm as AdminAuthForm 3 | 4 | from django.contrib.auth import authenticate 5 | from django.contrib.auth.forms import AuthenticationForm as AuthForm 6 | from django.utils.translation import ugettext_lazy as _ 7 | 8 | 9 | class AuthenticationForm(AuthForm): 10 | def clean(self): 11 | username = self.cleaned_data.get('username') 12 | password = self.cleaned_data.get('password') 13 | 14 | if username and password: 15 | self.user_cache = authenticate(username=username, 16 | password=password, 17 | request=self.request) 18 | if self.user_cache is None: 19 | raise forms.ValidationError( 20 | _('Please enter a correct username and password. ' 21 | 'Note that both fields may be case-sensitive.'), 22 | ) 23 | elif not self.user_cache.is_active: 24 | raise forms.ValidationError(_('This account is inactive.')) 25 | return self.cleaned_data 26 | 27 | 28 | class AdminAuthenticationForm(AdminAuthForm): 29 | def clean(self): 30 | username = self.cleaned_data.get('username') 31 | password = self.cleaned_data.get('password') 32 | message = self.error_messages['invalid_login'] 33 | 34 | if username and password: 35 | self.user_cache = authenticate(username=username, 36 | password=password, 37 | request=self.request) 38 | if self.user_cache is None: 39 | raise forms.ValidationError(message) 40 | elif not self.user_cache.is_active or not self.user_cache.is_staff: 41 | raise forms.ValidationError(message) 42 | return self.cleaned_data 43 | -------------------------------------------------------------------------------- /ratelimitbackend/middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseForbidden 2 | from django.utils.deprecation import MiddlewareMixin 3 | 4 | from .exceptions import RateLimitException 5 | 6 | 7 | class RateLimitMiddleware(MiddlewareMixin): 8 | """ 9 | Handles exceptions thrown by rate-limited login attepmts. 10 | """ 11 | def process_exception(self, request, exception): 12 | if isinstance(exception, RateLimitException): 13 | return HttpResponseForbidden( 14 | 'Too many failed login attempts. Try again later.', 15 | content_type='text/plain', 16 | ) 17 | -------------------------------------------------------------------------------- /ratelimitbackend/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-ratelimit-backend/ed1364e314cebbba6405ac74f7462e79784a7fb0/ratelimitbackend/models.py -------------------------------------------------------------------------------- /ratelimitbackend/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import REDIRECT_FIELD_NAME, login as auth_login 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.shortcuts import redirect 5 | from django.template.response import TemplateResponse 6 | from django.utils.six.moves.urllib.parse import urlparse 7 | from django.views.decorators.cache import never_cache 8 | from django.views.decorators.csrf import csrf_protect 9 | from django.views.decorators.debug import sensitive_post_parameters 10 | 11 | from .forms import AuthenticationForm 12 | 13 | 14 | @sensitive_post_parameters() 15 | @csrf_protect 16 | @never_cache 17 | def login(request, template_name='registration/login.html', 18 | redirect_field_name=REDIRECT_FIELD_NAME, 19 | authentication_form=AuthenticationForm, 20 | current_app=None, extra_context=None): 21 | """ 22 | Displays the login form and handles the login action. 23 | """ 24 | redirect_to = request.POST.get(redirect_field_name, 25 | request.GET.get(redirect_field_name, '')) 26 | 27 | if request.method == "POST": 28 | form = authentication_form(data=request.POST, request=request) 29 | if form.is_valid(): 30 | netloc = urlparse(redirect_to)[1] 31 | 32 | # Use default setting if redirect_to is empty 33 | if not redirect_to: 34 | redirect_to = settings.LOGIN_REDIRECT_URL 35 | 36 | # Heavier security check -- don't allow redirection to a different 37 | # host. 38 | elif netloc and netloc != request.get_host(): 39 | redirect_to = settings.LOGIN_REDIRECT_URL 40 | 41 | # Okay, security checks complete. Log the user in. 42 | auth_login(request, form.get_user()) 43 | 44 | return redirect(redirect_to) 45 | else: 46 | form = authentication_form(request) 47 | 48 | current_site = get_current_site(request) 49 | 50 | context = { 51 | 'form': form, 52 | redirect_field_name: redirect_to, 53 | 'site': current_site, 54 | 'site_name': current_site.name, 55 | } 56 | if extra_context is not None: 57 | context.update(extra_context) 58 | request.current_app = current_app 59 | return TemplateResponse(request, template_name, context) 60 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.conf import settings 6 | from django.test.runner import DiscoverRunner 7 | from django.utils.functional import empty 8 | 9 | 10 | def setup_test_environment(): 11 | # reset settings 12 | settings._wrapped = empty 13 | 14 | apps = [ 15 | 'django.contrib.sessions', 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sites', 19 | 'django.contrib.admin', 20 | 'django.contrib.messages', 21 | 'ratelimitbackend', 22 | 'tests', 23 | ] 24 | settings_dict = { 25 | "DATABASES": { 26 | 'default': { 27 | 'ENGINE': "django.db.backends.sqlite3", 28 | 'NAME': 'ratelimitbackend.sqlite', 29 | }, 30 | }, 31 | "CACHES": { 32 | 'default': { 33 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 34 | }, 35 | }, 36 | "ROOT_URLCONF": "tests.urls", 37 | "MIDDLEWARE": [ 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.contrib.messages.middleware.MessageMiddleware', 41 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 42 | 'django.middleware.csrf.CsrfViewMiddleware', 43 | 'ratelimitbackend.middleware.RateLimitMiddleware', 44 | ], 45 | "INSTALLED_APPS": apps, 46 | "SITE_ID": 1, 47 | "AUTHENTICATION_BACKENDS": ( 48 | 'ratelimitbackend.backends.RateLimitModelBackend', 49 | ), 50 | "LOGGING": { 51 | 'version': 1, 52 | 'handlers': { 53 | 'null': { 54 | 'class': 'logging.NullHandler', 55 | } 56 | }, 57 | 'loggers': { 58 | 'ratelimitbackend': { 59 | 'handlers': ['null'], 60 | }, 61 | }, 62 | }, 63 | "TEMPLATES": [{ 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'OPTIONS': { 66 | 'loaders': ( 67 | 'django.template.loaders.app_directories.Loader', 68 | ), 69 | 'context_processors': ( 70 | 'django.contrib.auth.context_processors.auth', 71 | ), 72 | }, 73 | }], 74 | } 75 | # set up settings for running tests for all apps 76 | settings.configure(**settings_dict) 77 | from django import setup 78 | setup() 79 | 80 | 81 | def runtests(): 82 | setup_test_environment() 83 | 84 | parent = os.path.dirname(os.path.abspath(__file__)) 85 | sys.path.insert(0, parent) 86 | 87 | runner = DiscoverRunner(verbosity=1, interactive=True, failfast=False) 88 | failures = runner.run_tests(()) 89 | sys.exit(failures) 90 | 91 | 92 | if __name__ == '__main__': 93 | runtests() 94 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = F405 3 | 4 | [wheel] 5 | universal = 1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | import ratelimitbackend 5 | 6 | with open('README.rst', 'r') as f: 7 | long_description = f.read() 8 | 9 | 10 | setup( 11 | name='django-ratelimit-backend', 12 | version=ratelimitbackend.__version__, 13 | author='Bruno Renié', 14 | author_email='bruno@renie.fr', 15 | packages=find_packages(exclude=['tests']), 16 | include_package_data=True, 17 | url='https://github.com/brutasse/django-ratelimit-backend', 18 | license='BSD licence, see LICENCE file', 19 | description='Login rate-limiting at the auth backend level', 20 | long_description=long_description, 21 | install_requires=[ 22 | 'Django', 23 | ], 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Environment :: Web Environment', 27 | 'Framework :: Django', 28 | 'Framework :: Django :: 1.11', 29 | 'Framework :: Django :: 2.0', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: BSD License', 32 | 'Natural Language :: English', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.4', 37 | 'Programming Language :: Python :: 3.5', 38 | ], 39 | test_suite='runtests.runtests', 40 | zip_safe=False, 41 | ) 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutasse/django-ratelimit-backend/ed1364e314cebbba6405ac74f7462e79784a7fb0/tests/__init__.py -------------------------------------------------------------------------------- /tests/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | from django.contrib.auth.models import User 3 | 4 | from ratelimitbackend.backends import RateLimitMixin, RateLimitModelBackend 5 | 6 | 7 | class TestBackend(RateLimitModelBackend): 8 | minutes = 10 9 | requests = 50 10 | 11 | def key(self, request, dt): 12 | """Derives the cache key from the submitted username too.""" 13 | return '%s%s-%s-%s' % ( 14 | self.cache_prefix, 15 | request.META.get('REMOTE_ADDR', ''), 16 | request.POST['username'], 17 | dt.strftime('%Y%m%d%H%M'), 18 | ) 19 | 20 | 21 | class CustomBackend(ModelBackend): 22 | def authenticate(self, request=None, token=None, secret=None): 23 | try: 24 | user = User.objects.get(username=token) 25 | if user.check_password(secret): 26 | return user 27 | except User.DoesNotExist: 28 | return None 29 | 30 | 31 | class TestCustomBackend(RateLimitMixin, CustomBackend): 32 | """Rate-limited backend with token/secret instead of username/password""" 33 | username_key = 'token' 34 | 35 | 36 | class TestCustomBrokenBackend(RateLimitMixin, CustomBackend): 37 | """Rate-limited backend with token/secret instead of username/password""" 38 | -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import Form, ValidationError, CharField, PasswordInput 2 | from django.contrib.auth import authenticate 3 | 4 | 5 | class CustomAuthForm(Form): 6 | token = CharField(max_length=30) 7 | secret = CharField(widget=PasswordInput) 8 | 9 | def __init__(self, request=None, *args, **kwargs): 10 | self.request = request 11 | self.user_cache = None 12 | super(CustomAuthForm, self).__init__(*args, **kwargs) 13 | 14 | def clean(self): 15 | token = self.cleaned_data.get('token') 16 | secret = self.cleaned_data.get('secret') 17 | if token and secret: 18 | self.user_cache = authenticate(token=token, 19 | secret=secret, 20 | request=self.request) 21 | if self.user_cache is None: 22 | raise ValidationError("Invalid") 23 | elif not self.user_cache.is_active: 24 | raise ValidationError("Inactive") 25 | return self.cleaned_data 26 | 27 | def get_user(self): 28 | return self.user_cache 29 | 30 | 31 | class TokenOnlyAuthForm(Form): 32 | token = CharField(max_length=30) 33 | 34 | def __init__(self, request=None, *args, **kwargs): 35 | self.request = request 36 | self.user_cache = None 37 | super(TokenOnlyAuthForm, self).__init__(*args, **kwargs) 38 | 39 | def clean_token(self): 40 | token = self.cleaned_data.get('token') 41 | 42 | # This is NOT how a token-only authentication system should work, but 43 | # it allows us to simulate one for testing relatively easily. 44 | username, password = token.split('_') 45 | self.user_cache = authenticate(username=username, 46 | password=password, 47 | request=self.request) 48 | if self.user_cache is None: 49 | raise ValidationError("Invalid") 50 | elif not self.user_cache.is_active: 51 | raise ValidationError("Inactive") 52 | return self.cleaned_data 53 | 54 | def get_user(self): 55 | return self.user_cache 56 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager 3 | from django.utils import timezone 4 | 5 | 6 | class UserManager(BaseUserManager): 7 | def create_user(self, email, password=None, **extra_fields): 8 | now = timezone.now() 9 | if not email: 10 | raise ValueError('The email must be set.') 11 | email = UserManager.normalize_email(email) 12 | user = self.model(email=email, last_login=now, date_joined=now, 13 | **extra_fields) 14 | user.set_password(password) 15 | user.save(using=self._db) 16 | return user 17 | 18 | def create_superuser(self, email, password, **extra_fields): 19 | user = self.create_user(email, password, **extra_fields) 20 | user.is_staff = True 21 | user.is_active = True 22 | user.is_superuser = True 23 | user.save(using=self._db, update_fields=['is_staff', 'is_active', 24 | 'is_superuser']) 25 | return user 26 | 27 | 28 | class User(AbstractBaseUser): 29 | """A user with email as identifier""" 30 | USERNAME_FIELD = 'email' 31 | REQUIRED_FIELDS = [] 32 | email = models.EmailField(max_length=255, unique=True, db_index=True) 33 | is_staff = models.BooleanField(default=False) 34 | is_active = models.BooleanField(default=False) 35 | is_superuser = models.BooleanField(default=False) 36 | date_joined = models.DateTimeField(default=timezone.now) 37 | 38 | objects = UserManager() 39 | -------------------------------------------------------------------------------- /tests/templates/custom_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ form.token }}
9 | {{ form.secret }}
10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/templates/token_only_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ form.token }}
9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/test_backends.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import warnings 3 | 4 | from django.contrib.auth import get_user, get_user_model 5 | from django.contrib.auth.models import User 6 | from django.core.cache import cache 7 | from django.test import TestCase 8 | from django.test.utils import override_settings 9 | from django.urls import reverse 10 | 11 | 12 | class RateLimitTests(TestCase): 13 | def setUp(self): # noqa 14 | cache.clear() 15 | 16 | def assertRateLimited(self, response): # noqa 17 | self.assertContains(response, 'Too many failed login attempts', 18 | status_code=403) 19 | 20 | @override_settings(AUTH_USER_MODEL='tests.User') 21 | def test_ratelimit_login_attempt_swapped_user(self): 22 | url = reverse('login') 23 | response = self.client.get(url) 24 | self.assertContains(response, 'username') 25 | self.assertContains(response, 'password') 26 | 27 | wrong_data = { 28 | 'username': u'hï', 29 | 'password': 'suspicious attempt', 30 | } 31 | # 30 failing attempts are allowed 32 | for iteration in range(30): 33 | response = self.client.post(url, wrong_data) 34 | self.assertContains(response, 'username') 35 | 36 | response = self.client.post(url, wrong_data) 37 | self.assertRateLimited(response) 38 | 39 | # IP is rate-limited; even valid login attempts are blocked. 40 | get_user_model().objects.create_user('foo@bar.com', 'pass') 41 | response = self.client.post(url, {'username': 'foo', 42 | 'password': 'pass'}) 43 | self.assertRateLimited(response) 44 | 45 | def test_ratelimit_login_attempt(self): 46 | url = reverse('login') 47 | response = self.client.get(url) 48 | self.assertContains(response, 'username') 49 | self.assertContains(response, 'password') 50 | 51 | wrong_data = { 52 | 'username': u'hï', 53 | 'password': 'suspicious attempt', 54 | } 55 | # 30 failing attempts are allowed 56 | for iteration in range(30): 57 | response = self.client.post(url, wrong_data) 58 | self.assertContains(response, 'username') 59 | 60 | response = self.client.post(url, wrong_data) 61 | self.assertRateLimited(response) 62 | 63 | # IP is rate-limited; even valid login attempts are blocked. 64 | User.objects.create_user('foo', 'foo@bar.com', 'pass') 65 | response = self.client.post(url, {'username': 'foo', 66 | 'password': 'pass'}) 67 | self.assertRateLimited(response) 68 | 69 | @override_settings(AUTH_USER_MODEL='tests.User') 70 | def test_ratelimit_login_attempt_swapped(self): 71 | url = reverse('login') 72 | response = self.client.get(url) 73 | self.assertContains(response, 'username') 74 | self.assertContains(response, 'password') 75 | 76 | wrong_data = { 77 | 'username': u'hï', 78 | 'password': 'suspicious attempt', 79 | } 80 | # 30 failing attempts are allowed 81 | for iteration in range(30): 82 | response = self.client.post(url, wrong_data) 83 | self.assertContains(response, 'username') 84 | 85 | response = self.client.post(url, wrong_data) 86 | self.assertRateLimited(response) 87 | 88 | # IP is rate-limited; even valid login attempts are blocked. 89 | get_user_model().objects.create_user('foo@bar.com', 'pass') 90 | response = self.client.post(url, {'username': 'foo', 91 | 'password': 'pass'}) 92 | self.assertRateLimited(response) 93 | 94 | def test_ratelimit_admin_logins(self): 95 | url = reverse('admin:index') 96 | response = self.client.get(url, follow=True) 97 | login_url = response.request['PATH_INFO'] 98 | self.assertContains(response, 'username') 99 | wrong_data = { 100 | 'username': u'hî', 101 | 'password': 'suspicious attempt', 102 | } 103 | # 30 failing attempts are allowed 104 | for iteration in range(30): 105 | response = self.client.post(login_url, wrong_data) 106 | self.assertContains(response, 'username') 107 | self.assertContains(response, 'for a staff account') 108 | 109 | response = self.client.post(login_url, wrong_data) 110 | self.assertRateLimited(response) 111 | 112 | def test_django_registry(self): 113 | user = User.objects.create_user('username', 'foo@bar.com', 'pass') 114 | user.is_staff = True 115 | user.is_superuser = True 116 | user.save() 117 | with warnings.catch_warnings(record=True) as w: 118 | self.client.login(request=None, username='username', 119 | password='pass') 120 | self.assertEqual(len(w), 1) 121 | url = reverse('admin:index') 122 | response = self.client.get(url) 123 | self.assertContains( 124 | response, 125 | 'Models in the Authentication and Authorization application') 126 | self.assertContains(response, '"/admin/auth/user/add/"') 127 | 128 | @override_settings(AUTHENTICATION_BACKENDS=('tests.backends.TestBackend',)) 129 | def test_custom_ratelimit_logic(self): 130 | """Custom backend behaviour""" 131 | url = reverse('login') 132 | 133 | wrong_data = { 134 | 'username': u'ùser1', 135 | 'password': 'suspicious attempt', 136 | } 137 | # 50 failing attempts are allowed 138 | for iteration in range(50): 139 | response = self.client.post(url, wrong_data) 140 | self.assertContains(response, 'username') 141 | 142 | # Attempts for this username are blocked 143 | response = self.client.post(url, wrong_data) 144 | self.assertRateLimited(response) 145 | 146 | # Further attempts with another username are allowed 147 | wrong_data['username'] = 'user2' 148 | response = self.client.post(url, wrong_data) 149 | self.assertContains(response, 'username') 150 | 151 | @override_settings(AUTHENTICATION_BACKENDS=( 152 | 'tests.backends.TestCustomBackend',)) 153 | def test_custom_backend(self): 154 | """Backend with custom authentication method""" 155 | url = reverse('custom_login') 156 | response = self.client.get(url) 157 | self.assertContains(response, 'token') 158 | self.assertContains(response, 'secret') 159 | 160 | wrong_data = { 161 | 'token': u'hï', 162 | 'secret': 'suspicious attempt', 163 | } 164 | # 30 failed attempts are allowed 165 | for iteration in range(30): 166 | response = self.client.post(url, wrong_data) 167 | self.assertContains(response, 'secret') 168 | 169 | response = self.client.post(url, wrong_data) 170 | self.assertRateLimited(response) 171 | 172 | # IP is rate-limited; even valid login attempts are blocked. 173 | User.objects.create_user('foo', 'foo@bar.com', 'pass') 174 | response = self.client.post(url, {'token': 'foo', 175 | 'secret': 'pass'}) 176 | self.assertRateLimited(response) 177 | 178 | @override_settings(AUTHENTICATION_BACKENDS=( 179 | 'tests.backends.TestCustomBrokenBackend',)) 180 | def test_custom_backend_no_username_key(self): 181 | """Custom backend with missing username_key""" 182 | url = reverse('custom_login') 183 | wrong_data = { 184 | 'token': u'hï', 185 | 'secret': 'suspicious attempt', 186 | } 187 | self.assertRaises(KeyError, self.client.post, url, wrong_data) 188 | 189 | @override_settings(AUTHENTICATION_BACKENDS=( 190 | 'ratelimitbackend.backends.RateLimitNoUsernameModelBackend',)) 191 | def test_no_username_model_backend(self): 192 | url = reverse('token_only_login') 193 | wrong_data = { 194 | 'token': u'bad_token', 195 | } 196 | User.objects.create_user('foo', 'foo@bar.com', 'pass') 197 | 198 | # Login succeeds normally 199 | response = self.client.post(url, {'token': 'foo_pass'}) 200 | self.assertTrue(get_user(self.client).is_authenticated) 201 | 202 | # 30 failed attempts are allowed 203 | for iteration in range(30): 204 | response = self.client.post(url, wrong_data) 205 | self.assertContains(response, 'token') 206 | 207 | response = self.client.post(url, wrong_data) 208 | self.assertRateLimited(response) 209 | 210 | # IP is rate-limited; even valid login attempts are blocked. 211 | response = self.client.post(url, {'token': 'foo_pass'}) 212 | self.assertRateLimited(response) 213 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from ratelimitbackend import admin 4 | from ratelimitbackend.views import login 5 | 6 | from .forms import CustomAuthForm, TokenOnlyAuthForm 7 | 8 | 9 | urlpatterns = [ 10 | url(r'^login/$', login, 11 | {'template_name': 'admin/login.html'}, name='login'), 12 | url(r'^custom_login/$', login, 13 | {'template_name': 'custom_login.html', 14 | 'authentication_form': CustomAuthForm}, 15 | name='custom_login'), 16 | url(r'^token_login/$', login, 17 | {'template_name': 'token_only_login.html', 18 | 'authentication_form': TokenOnlyAuthForm}, 19 | name='token_only_login'), 20 | url(r'^admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,34,35,36}-django111, 4 | py{34,35,36}-django20, 5 | py{35,36}-django21, 6 | docs, lint 7 | 8 | [testenv] 9 | commands = python -Wall setup.py test 10 | deps = 11 | django111: Django>=1.11a1,<2.0 12 | django20: Django>=2.0a1,<2.1 13 | django21: Django>=2.1,<2.2 14 | 15 | [testenv:docs] 16 | changedir = docs 17 | deps = 18 | Sphinx 19 | sphinx_rtd_theme 20 | commands = 21 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 22 | 23 | [testenv:lint] 24 | deps = 25 | flake8 26 | commands = 27 | flake8 {toxinidir}/ratelimitbackend {toxinidir}/tests 28 | --------------------------------------------------------------------------------