├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── doc ├── Makefile ├── conf.py └── index.rst ├── password_validation ├── __init__.py ├── common-passwords.txt.gz ├── conf.py ├── forms.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── test │ ├── __init__.py │ ├── common-passwords-custom.txt │ ├── conftest.py │ ├── settings.py │ ├── test_forms.py │ ├── test_settings.py │ ├── test_validators.py │ └── test_views.py ├── validation.py └── views.py ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = password_validation 3 | omit = password_validation/test/* 4 | branch = 1 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | django_password_validation.egg-info/ 2 | htmlcov/ 3 | .tox/ 4 | doc/_build/ 5 | dist/ 6 | .coverage 7 | password_validation/test/test.sqlite 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | - 3.3 6 | - 3.4 7 | - pypy 8 | - pypy3.3-5.2-alpha1 9 | 10 | install: travis_retry pip install tox-travis coveralls 11 | 12 | script: tox 13 | 14 | after_success: coveralls 15 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Erik Romijn 2 | Carl Meyer 3 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | master (unreleased) 5 | ------------------- 6 | 7 | 8 | 0.1.1 (2015.06.10 9 | ----------------- 10 | 11 | * Add common-passwords.txt.gz to package-data. 12 | 13 | 14 | 0.1 (2015.06.10) 15 | ---------------- 16 | 17 | * Initial release, backported from Django commit 9851e54121b3. 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Thanks for your interest in contributing! The advice below will help you get 5 | your issue fixed / pull request merged. 6 | 7 | Please file bugs and send pull requests to the `GitHub repository`_ and `issue 8 | tracker`_. 9 | 10 | .. _GitHub repository: https://github.com/orcasgit/django-password-validation/ 11 | .. _issue tracker: https://github.com/orcasgit/django-password-validation/issues 12 | 13 | 14 | 15 | Submitting Issues 16 | ----------------- 17 | 18 | Issues are easier to reproduce/resolve when they have: 19 | 20 | - A pull request with a failing test demonstrating the issue 21 | - A code example that produces the issue consistently 22 | - A traceback (when applicable) 23 | 24 | 25 | Pull Requests 26 | ------------- 27 | 28 | When creating a pull request: 29 | 30 | - Write tests (see below) 31 | - Note user-facing changes in the `CHANGES`_ file 32 | - Update the documentation as needed 33 | - Add yourself to the `AUTHORS`_ file 34 | 35 | .. _AUTHORS: AUTHORS.rst 36 | .. _CHANGES: CHANGES.rst 37 | 38 | 39 | Testing 40 | ------- 41 | 42 | Please add tests for any changes you submit. The tests should fail before your 43 | code changes, and pass with your changes. Existing tests should not 44 | break. Coverage (see below) should remain at 100% following a full tox run. 45 | 46 | To install all the requirements for running the tests:: 47 | 48 | pip install -r requirements.txt 49 | 50 | To run the tests once:: 51 | 52 | ./runtests.py 53 | 54 | To run tox (which runs the tests across all supported Python and Django 55 | versions) and generate a coverage report in the ``htmlcov/`` directory:: 56 | 57 | make test 58 | 59 | This requires that you have ``python2.7``, ``python3.3``, ``python3.4``, 60 | ``pypy``, and ``pypy3`` binaries on your system's shell path. 61 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Original code from Django Copyright (c) 2015 Erik Romijn 2 | Additional shims and documentation (c) 2015 ORCAS, Inc 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | * Neither the name of the author nor the names of other 16 | contributors may be used to endorse or promote products derived 17 | from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | recursive-include password_validation/locale * 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | coverage erase 3 | tox 4 | coverage html 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | django-password-validation 3 | ========================== 4 | 5 | .. image:: https://secure.travis-ci.org/orcasgit/django-password-validation.png?branch=master 6 | :target: http://travis-ci.org/orcasgit/django-password-validation 7 | :alt: Test status 8 | .. image:: https://coveralls.io/repos/orcasgit/django-password-validation/badge.png?branch=master 9 | :target: https://coveralls.io/r/orcasgit/django-password-validation 10 | :alt: Test coverage 11 | .. image:: https://readthedocs.org/projects/django-password-validation/badge/?version=latest 12 | :target: https://readthedocs.org/projects/django-password-validation/?badge=latest 13 | :alt: Documentation Status 14 | .. image:: https://badge.fury.io/py/django-password-validation.svg 15 | :target: https://pypi.python.org/pypi/django-password-validation 16 | :alt: Latest version 17 | 18 | A backport of the `password validation system`_ from Django 1.9 (by Erik 19 | Romijn), for use on earlier Django versions. 20 | 21 | Password validation isn't hard to implement yourself, but if you use this 22 | backport you'll be writing your validators to the same API that will be 23 | built-in to upcoming Django versions. 24 | 25 | ``django-password-validation`` supports `Django`_ 1.8.2 and later on Python 26 | 2.7, 3.3, 3.4, pypy, and pypy3. 27 | 28 | .. _Django: http://www.djangoproject.com/ 29 | .. _password validation system: https://docs.djangoproject.com/en/dev/topics/auth/passwords/#password-validation 30 | 31 | 32 | Getting Help 33 | ============ 34 | 35 | Documentation for django-password-validation is available at 36 | https://django-password-validation.readthedocs.org/ 37 | 38 | This app is available on `PyPI`_ and can be installed with ``pip install 39 | django-password-validation``. 40 | 41 | .. _PyPI: https://pypi.python.org/pypi/django-password-validation/ 42 | 43 | 44 | Contributing 45 | ============ 46 | 47 | See the `contributing docs`_. 48 | 49 | .. _contributing docs: https://github.com/orcasgit/django-password-validation/blob/master/CONTRIBUTING.rst 50 | 51 | -------------------------------------------------------------------------------- /doc/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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-password-validation.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-password-validation.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-password-validation" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-password-validation" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # django-password-validation documentation build configuration file, created by 5 | # sphinx-quickstart on Mon May 25 12:37:44 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ------------------------------------------------ 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | #templates_path = ['_templates'] 33 | 34 | # The suffix(es) of source filenames. 35 | # You can specify multiple suffix as a list of string: 36 | # source_suffix = ['.rst', '.md'] 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = 'django-password-validation' 47 | copyright = '2015' 48 | author = 'Erik Romijn' 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | version = '0.1' 56 | # The full version, including alpha/beta/rc tags. 57 | release = '0.1' 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | # 62 | # This is also used if you do content translation via gettext catalogs. 63 | # Usually you set "language" from the command line for these cases. 64 | language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all 77 | # documents. 78 | #default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | #add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | #add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | #show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = 'sphinx' 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | #modindex_common_prefix = [] 96 | 97 | # If true, keep warnings as "system message" paragraphs in the built documents. 98 | #keep_warnings = False 99 | 100 | # If true, `todo` and `todoList` produce output, else they produce nothing. 101 | todo_include_todos = False 102 | 103 | 104 | # -- Options for HTML output ---------------------------------------------- 105 | 106 | # The theme to use for HTML and HTML Help pages. See the documentation for 107 | # a list of builtin themes. 108 | html_theme = 'alabaster' 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | #html_theme_options = {} 114 | 115 | # Add any paths that contain custom themes here, relative to this directory. 116 | #html_theme_path = [] 117 | 118 | # The name for this set of Sphinx documents. If None, it defaults to 119 | # " v documentation". 120 | #html_title = None 121 | 122 | # A shorter title for the navigation bar. Default is the same as html_title. 123 | #html_short_title = None 124 | 125 | # The name of an image file (relative to this directory) to place at the top 126 | # of the sidebar. 127 | #html_logo = None 128 | 129 | # The name of an image file (within the static path) to use as favicon of the 130 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 131 | # pixels large. 132 | #html_favicon = None 133 | 134 | # Add any paths that contain custom static files (such as style sheets) here, 135 | # relative to this directory. They are copied after the builtin static files, 136 | # so a file named "default.css" will overwrite the builtin "default.css". 137 | #html_static_path = ['_static'] 138 | 139 | # Add any extra paths that contain custom files (such as robots.txt or 140 | # .htaccess) here, relative to this directory. These files are copied 141 | # directly to the root of the documentation. 142 | #html_extra_path = [] 143 | 144 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 145 | # using the given strftime format. 146 | #html_last_updated_fmt = '%b %d, %Y' 147 | 148 | # If true, SmartyPants will be used to convert quotes and dashes to 149 | # typographically correct entities. 150 | #html_use_smartypants = True 151 | 152 | # Custom sidebar templates, maps document names to template names. 153 | #html_sidebars = {} 154 | 155 | # Additional templates that should be rendered to pages, maps page names to 156 | # template names. 157 | #html_additional_pages = {} 158 | 159 | # If false, no module index is generated. 160 | #html_domain_indices = True 161 | 162 | # If false, no index is generated. 163 | #html_use_index = True 164 | 165 | # If true, the index is split into individual pages for each letter. 166 | #html_split_index = False 167 | 168 | # If true, links to the reST sources are added to the pages. 169 | #html_show_sourcelink = True 170 | 171 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 172 | #html_show_sphinx = True 173 | 174 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 175 | #html_show_copyright = True 176 | 177 | # If true, an OpenSearch description file will be output, and all pages will 178 | # contain a tag referring to it. The value of this option must be the 179 | # base URL from which the finished HTML is served. 180 | #html_use_opensearch = '' 181 | 182 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 183 | #html_file_suffix = None 184 | 185 | # Language to be used for generating the HTML full-text search index. 186 | # Sphinx supports the following languages: 187 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 188 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 189 | #html_search_language = 'en' 190 | 191 | # A dictionary with options for the search language support, empty by default. 192 | # Now only 'ja' uses this config value 193 | #html_search_options = {'type': 'default'} 194 | 195 | # The name of a javascript file (relative to the configuration directory) that 196 | # implements a search results scorer. If empty, the default will be used. 197 | #html_search_scorer = 'scorer.js' 198 | 199 | # Output file base name for HTML help builder. 200 | htmlhelp_basename = 'django-password-validationdoc' 201 | 202 | # -- Options for LaTeX output --------------------------------------------- 203 | 204 | latex_elements = { 205 | # The paper size ('letterpaper' or 'a4paper'). 206 | #'papersize': 'letterpaper', 207 | 208 | # The font size ('10pt', '11pt' or '12pt'). 209 | #'pointsize': '10pt', 210 | 211 | # Additional stuff for the LaTeX preamble. 212 | #'preamble': '', 213 | 214 | # Latex figure (float) alignment 215 | #'figure_align': 'htbp', 216 | } 217 | 218 | # Grouping the document tree into LaTeX files. List of tuples 219 | # (source start file, target name, title, 220 | # author, documentclass [howto, manual, or own class]). 221 | latex_documents = [ 222 | ( 223 | master_doc, 224 | 'django-password-validation.tex', 225 | 'django-password-validation Documentation', 226 | 'Carl Meyer', 227 | 'manual', 228 | ), 229 | ] 230 | 231 | # The name of an image file (relative to this directory) to place at the top of 232 | # the title page. 233 | #latex_logo = None 234 | 235 | # For "manual" documents, if this is true, then toplevel headings are parts, 236 | # not chapters. 237 | #latex_use_parts = False 238 | 239 | # If true, show page references after internal links. 240 | #latex_show_pagerefs = False 241 | 242 | # If true, show URL addresses after external links. 243 | #latex_show_urls = False 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #latex_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #latex_domain_indices = True 250 | 251 | 252 | # -- Options for manual page output --------------------------------------- 253 | 254 | # One entry per manual page. List of tuples 255 | # (source start file, name, description, authors, manual section). 256 | man_pages = [ 257 | ( 258 | master_doc, 259 | 'django-password-validation', 260 | 'django-password-validation Documentation', 261 | [author], 262 | 1, 263 | ) 264 | ] 265 | 266 | # If true, show URL addresses after external links. 267 | #man_show_urls = False 268 | 269 | 270 | # -- Options for Texinfo output ------------------------------------------- 271 | 272 | # Grouping the document tree into Texinfo files. List of tuples 273 | # (source start file, target name, title, author, 274 | # dir menu entry, description, category) 275 | texinfo_documents = [ 276 | ( 277 | master_doc, 278 | 'django-password-validation', 279 | 'django-password-validation Documentation', 280 | author, 281 | 'django-password-validation', 282 | 'One line description of project.', 283 | 'Miscellaneous', 284 | ), 285 | ] 286 | 287 | # Documents to append as an appendix to all manuals. 288 | #texinfo_appendices = [] 289 | 290 | # If false, no module index is generated. 291 | #texinfo_domain_indices = True 292 | 293 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 294 | #texinfo_show_urls = 'footnote' 295 | 296 | # If true, do not generate a @detailmenu in the "Top" node's menu. 297 | #texinfo_no_detailmenu = False 298 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-password-validation! 2 | ====================================== 3 | 4 | A backport of the `password validation system`_ from Django 1.9 (by Erik 5 | Romijn), for use on earlier Django versions. 6 | 7 | Password validation isn't hard to implement yourself, but if you use this 8 | backport you'll be writing your validators to the same API that will be 9 | built-in to upcoming Django versions. 10 | 11 | .. _password validation system: https://docs.djangoproject.com/en/dev/topics/auth/passwords/#password-validation 12 | 13 | 14 | Prerequisites 15 | ------------- 16 | 17 | ``django-password-validation`` supports `Django`_ 1.8.2 and later on Python 18 | 2.7, 3.3, 3.4, pypy, and pypy3. 19 | 20 | .. _Django: http://www.djangoproject.com/ 21 | 22 | 23 | Installation 24 | ------------ 25 | 26 | ``django-password-validation`` is available on `PyPI`_. Install it with:: 27 | 28 | pip install django-password-validation 29 | 30 | .. _PyPI: https://pypi.python.org/pypi/django-password-validation/ 31 | 32 | 33 | Usage 34 | ----- 35 | 36 | Just follow the `Django documentation`_! 37 | 38 | When using the built-in validators in your ``AUTH_PASSWORD_VALIDATORS`` 39 | setting, use import paths like 40 | e.g. ``'password_validation.MinimumLengthValidator'`` in place of 41 | ``'django.contrib.auth.password_validation.MinimumLengthValidator'``. 42 | 43 | In place of the built-in views for password setting/changing you'll need to 44 | switch to using ``password_validation.views.password_reset_confirm`` and 45 | ``password_validation.views.password_change``. 46 | 47 | If you have your own custom views for changing or resetting passwords, use 48 | ``password_validation.forms.SetPasswordForm`` or 49 | ``password_validation.forms.PasswordChangeForm`` instead of 50 | ``django.contrib.auth.forms.SetPasswordForm`` or 51 | ``django.contrib.auth.forms.PasswordChangeForm`` in those views. 52 | 53 | A validation-enabled admin password change form is not currently provided 54 | here. Pull requests welcome! 55 | 56 | .. _django documentation: https://docs.djangoproject.com/en/dev/topics/auth/passwords/#password-validation 57 | 58 | 59 | Contributing 60 | ------------ 61 | 62 | See the `contributing docs`_. 63 | 64 | .. _contributing docs: https://github.com/orcasgit/django-password-validation/blob/master/CONTRIBUTING.rst 65 | -------------------------------------------------------------------------------- /password_validation/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.dev2' 2 | 3 | from .validation import ( # noqa 4 | CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator, 5 | UserAttributeSimilarityValidator, get_default_password_validators, 6 | get_password_validators, password_changed, 7 | password_validators_help_text_html, password_validators_help_texts, 8 | validate_password, 9 | ) 10 | -------------------------------------------------------------------------------- /password_validation/common-passwords.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orcasgit/django-password-validation/90fdb0d614407bcb75c4e0d1fc560a9efc9c881a/password_validation/common-passwords.txt.gz -------------------------------------------------------------------------------- /password_validation/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | 3 | 4 | class SettingsProxy(object): 5 | @property 6 | def AUTH_PASSWORD_VALIDATORS(self): 7 | return getattr(django_settings, 'AUTH_PASSWORD_VALIDATORS', []) 8 | 9 | 10 | settings = SettingsProxy() 11 | -------------------------------------------------------------------------------- /password_validation/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import forms 2 | 3 | from . import validation 4 | 5 | 6 | class PasswordValidationMixin(object): 7 | def __init__(self, *a, **kw): 8 | super(PasswordValidationMixin, self).__init__(*a, **kw) 9 | self.fields['new_password1'].help_text = validation.password_validators_help_text_html() 10 | 11 | def clean_new_password2(self): 12 | password = super(PasswordValidationMixin, self).clean_new_password2() 13 | validation.validate_password(password, self.user) 14 | 15 | def save(self, commit=True): 16 | user = super(PasswordValidationMixin, self).save(commit=commit) 17 | validation.password_changed(self.cleaned_data['new_password1'], user) 18 | return user 19 | 20 | 21 | class SetPasswordForm(PasswordValidationMixin, forms.SetPasswordForm): 22 | pass 23 | 24 | 25 | class PasswordChangeForm(PasswordValidationMixin, forms.PasswordChangeForm): 26 | pass 27 | -------------------------------------------------------------------------------- /password_validation/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orcasgit/django-password-validation/90fdb0d614407bcb75c4e0d1fc560a9efc9c881a/password_validation/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /password_validation/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-03-23 10:20+0000\n" 11 | "PO-Revision-Date: 2017-03-23 11:20+0100\n" 12 | "Last-Translator: Stefan Foulis \n" 13 | "Language-Team: \n" 14 | "Language: de\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 1.8.12\n" 20 | 21 | #: validation.py:109 22 | #, python-format 23 | msgid "" 24 | "This password is too short. It must contain at least %(min_length)d " 25 | "characters." 26 | msgstr "" 27 | "Dieses Passwort ist zu kurz. Es muss mindestens %(min_length)d Zeichen " 28 | "enthalten." 29 | 30 | #: validation.py:115 31 | #, python-format 32 | msgid "Your password must contain at least %(min_length)d characters." 33 | msgstr "Das Passwort muss mindestens %(min_length)d Zeichen enthalten." 34 | 35 | #: validation.py:148 36 | #, python-format 37 | msgid "The password is too similar to the %(verbose_name)s." 38 | msgstr "Das Passwort ist zu ähnlich wie %(verbose_name)s." 39 | 40 | #: validation.py:154 41 | msgid "Your password can't be too similar to your other personal information." 42 | msgstr "Das Passwort darf nicht zu ähnlich zu anderen persönlichen Daten sein." 43 | 44 | #: validation.py:179 45 | msgid "This password is too common." 46 | msgstr "Dieses Passwort ist zu gewöhnlich." 47 | 48 | #: validation.py:184 49 | msgid "Your password can't be a commonly used password." 50 | msgstr "Das Passwort darf nicht ein gängig verwendetes Passwort sein." 51 | 52 | #: validation.py:194 53 | msgid "This password is entirely numeric." 54 | msgstr "Das Passwort besteht nur aus Zahlen." 55 | 56 | #: validation.py:199 57 | msgid "Your password can't be entirely numeric." 58 | msgstr "Das Passwort darf nicht nur aus Zahlen bestehen." 59 | -------------------------------------------------------------------------------- /password_validation/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orcasgit/django-password-validation/90fdb0d614407bcb75c4e0d1fc560a9efc9c881a/password_validation/test/__init__.py -------------------------------------------------------------------------------- /password_validation/test/common-passwords-custom.txt: -------------------------------------------------------------------------------- 1 | from-my-custom-list 2 | -------------------------------------------------------------------------------- /password_validation/test/conftest.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.test.signals import setting_changed 3 | 4 | from password_validation import get_default_password_validators 5 | 6 | 7 | @receiver(setting_changed) 8 | def changed_validators(**kwargs): 9 | if kwargs['setting'] == 'AUTH_PASSWORD_VALIDATORS': 10 | get_default_password_validators.cache_clear() 11 | -------------------------------------------------------------------------------- /password_validation/test/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | INSTALLED_APPS = [ 4 | 'django.contrib.auth', 5 | 'django.contrib.contenttypes', 6 | ] 7 | 8 | SECRET_KEY = 'secret' 9 | 10 | HERE = os.path.dirname(os.path.abspath(__file__)) 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'NAME': os.path.join(HERE, 'test.sqlite'), 16 | } 17 | } 18 | 19 | SILENCED_SYSTEM_CHECKS = ['1_7.W001'] 20 | -------------------------------------------------------------------------------- /password_validation/test/test_forms.py: -------------------------------------------------------------------------------- 1 | """One test in this module is backported from Django. 2 | 3 | The SetPasswordFormTest.test_validates_password is copied from 4 | tests/auth_tests/test_forms.py at commit 5 | 9851e54121b3eebd3a7a29de3ed874d82554396b 6 | 7 | The only changes are to replace `django.contrib.auth.password_validation` with 8 | `password_validation` throughout, and replace `User.objects.get` with 9 | `User.objects.create`. 10 | 11 | The other tests are added. 12 | 13 | """ 14 | from __future__ import unicode_literals 15 | 16 | from password_validation.forms import SetPasswordForm, PasswordChangeForm 17 | from django.contrib.auth.models import User 18 | from django.test import TestCase, override_settings 19 | 20 | 21 | @override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher']) 22 | class SetPasswordFormTest(TestCase): 23 | @override_settings(AUTH_PASSWORD_VALIDATORS=[ 24 | {'NAME': 'password_validation.UserAttributeSimilarityValidator'}, 25 | {'NAME': 'password_validation.MinimumLengthValidator', 'OPTIONS': { 26 | 'min_length': 12, 27 | }}, 28 | ]) 29 | def test_validates_password(self): 30 | user = User.objects.create(username='testclient') 31 | data = { 32 | 'new_password1': 'testclient', 33 | 'new_password2': 'testclient', 34 | } 35 | form = SetPasswordForm(user, data) 36 | self.assertFalse(form.is_valid()) 37 | self.assertEqual(len(form["new_password2"].errors), 2) 38 | self.assertIn('The password is too similar to the username.', form["new_password2"].errors) 39 | self.assertIn( 40 | 'This password is too short. It must contain at least 12 characters.', 41 | form["new_password2"].errors 42 | ) 43 | 44 | 45 | @override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher']) 46 | class ChangePasswordFormTest(TestCase): 47 | @override_settings(AUTH_PASSWORD_VALIDATORS=[ 48 | {'NAME': 'password_validation.MinimumLengthValidator', 'OPTIONS': { 49 | 'min_length': 12, 50 | }}, 51 | ]) 52 | def test_validates_password(self): 53 | user = User.objects.create_user(username='testclient', password='sekret') 54 | data = { 55 | 'old_password': 'sekret', 56 | 'new_password1': 'testclient', 57 | 'new_password2': 'testclient', 58 | } 59 | form = PasswordChangeForm(user, data) 60 | self.assertFalse(form.is_valid()) 61 | self.assertEqual(len(form["new_password2"].errors), 1) 62 | self.assertIn( 63 | 'This password is too short. It must contain at least 12 characters.', 64 | form["new_password2"].errors 65 | ) 66 | -------------------------------------------------------------------------------- /password_validation/test/test_settings.py: -------------------------------------------------------------------------------- 1 | from password_validation import get_default_password_validators 2 | 3 | 4 | def test_default_setting(): 5 | """Defaults to no password validation.""" 6 | assert get_default_password_validators() == [] 7 | -------------------------------------------------------------------------------- /password_validation/test/test_validators.py: -------------------------------------------------------------------------------- 1 | """This module is backported from Django. 2 | 3 | Copied from tests/auth_tests/test_validators.py at commit 4 | 9851e54121b3eebd3a7a29de3ed874d82554396b 5 | 6 | The only change is to replace `django.contrib.auth.password_validation` with 7 | `password_validation` throughout. 8 | 9 | """ 10 | from __future__ import unicode_literals 11 | 12 | import os 13 | 14 | from django.contrib.auth.models import User 15 | from password_validation import ( 16 | CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator, 17 | UserAttributeSimilarityValidator, get_default_password_validators, 18 | get_password_validators, password_changed, 19 | password_validators_help_text_html, password_validators_help_texts, 20 | validate_password, 21 | ) 22 | from django.core.exceptions import ValidationError 23 | from django.test import TestCase, override_settings 24 | from django.utils._os import upath 25 | 26 | 27 | @override_settings(AUTH_PASSWORD_VALIDATORS=[ 28 | {'NAME': 'password_validation.CommonPasswordValidator'}, 29 | {'NAME': 'password_validation.MinimumLengthValidator', 'OPTIONS': { 30 | 'min_length': 12, 31 | }}, 32 | ]) 33 | class PasswordValidationTest(TestCase): 34 | def test_get_default_password_validators(self): 35 | validators = get_default_password_validators() 36 | self.assertEqual(len(validators), 2) 37 | self.assertEqual(validators[0].__class__.__name__, 'CommonPasswordValidator') 38 | self.assertEqual(validators[1].__class__.__name__, 'MinimumLengthValidator') 39 | self.assertEqual(validators[1].min_length, 12) 40 | 41 | def test_get_password_validators_custom(self): 42 | validator_config = [{'NAME': 'password_validation.CommonPasswordValidator'}] 43 | validators = get_password_validators(validator_config) 44 | self.assertEqual(len(validators), 1) 45 | self.assertEqual(validators[0].__class__.__name__, 'CommonPasswordValidator') 46 | 47 | self.assertEqual(get_password_validators([]), []) 48 | 49 | def test_validate_password(self): 50 | self.assertIsNone(validate_password('sufficiently-long')) 51 | msg_too_short = 'This password is too short. It must contain at least 12 characters.' 52 | 53 | with self.assertRaises(ValidationError, args=['This password is too short.']) as cm: 54 | validate_password('django4242') 55 | self.assertEqual(cm.exception.messages, [msg_too_short]) 56 | self.assertEqual(cm.exception.error_list[0].code, 'password_too_short') 57 | 58 | with self.assertRaises(ValidationError) as cm: 59 | validate_password('password') 60 | self.assertEqual(cm.exception.messages, ['This password is too common.', msg_too_short]) 61 | self.assertEqual(cm.exception.error_list[0].code, 'password_too_common') 62 | 63 | self.assertIsNone(validate_password('password', password_validators=[])) 64 | 65 | def test_password_changed(self): 66 | self.assertIsNone(password_changed('password')) 67 | 68 | def test_password_validators_help_texts(self): 69 | help_texts = password_validators_help_texts() 70 | self.assertEqual(len(help_texts), 2) 71 | self.assertIn('12 characters', help_texts[1]) 72 | 73 | self.assertEqual(password_validators_help_texts(password_validators=[]), []) 74 | 75 | def test_password_validators_help_text_html(self): 76 | help_text = password_validators_help_text_html() 77 | self.assertEqual(help_text.count('
  • '), 2) 78 | self.assertIn('12 characters', help_text) 79 | 80 | 81 | class MinimumLengthValidatorTest(TestCase): 82 | def test_validate(self): 83 | expected_error = "This password is too short. It must contain at least %d characters." 84 | self.assertIsNone(MinimumLengthValidator().validate('12345678')) 85 | self.assertIsNone(MinimumLengthValidator(min_length=3).validate('123')) 86 | 87 | with self.assertRaises(ValidationError) as cm: 88 | MinimumLengthValidator().validate('1234567') 89 | self.assertEqual(cm.exception.messages, [expected_error % 8]) 90 | self.assertEqual(cm.exception.error_list[0].code, 'password_too_short') 91 | 92 | with self.assertRaises(ValidationError) as cm: 93 | MinimumLengthValidator(min_length=3).validate('12') 94 | self.assertEqual(cm.exception.messages, [expected_error % 3]) 95 | 96 | def test_help_text(self): 97 | self.assertEqual( 98 | MinimumLengthValidator().get_help_text(), 99 | "Your password must contain at least 8 characters." 100 | ) 101 | 102 | 103 | class UserAttributeSimilarityValidatorTest(TestCase): 104 | def test_validate(self): 105 | user = User.objects.create( 106 | username='testclient', first_name='Test', last_name='Client', email='testclient@example.com', 107 | password='sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161', 108 | ) 109 | expected_error = "The password is too similar to the %s." 110 | 111 | self.assertIsNone(UserAttributeSimilarityValidator().validate('testclient')) 112 | 113 | with self.assertRaises(ValidationError) as cm: 114 | UserAttributeSimilarityValidator().validate('testclient', user=user), 115 | self.assertEqual(cm.exception.messages, [expected_error % "username"]) 116 | self.assertEqual(cm.exception.error_list[0].code, 'password_too_similar') 117 | 118 | with self.assertRaises(ValidationError) as cm: 119 | UserAttributeSimilarityValidator().validate('example.com', user=user), 120 | self.assertEqual(cm.exception.messages, [expected_error % "email address"]) 121 | 122 | with self.assertRaises(ValidationError) as cm: 123 | UserAttributeSimilarityValidator( 124 | user_attributes=['first_name'], 125 | max_similarity=0.3, 126 | ).validate('testclient', user=user) 127 | self.assertEqual(cm.exception.messages, [expected_error % "first name"]) 128 | 129 | self.assertIsNone( 130 | UserAttributeSimilarityValidator(user_attributes=['first_name']).validate('testclient', user=user) 131 | ) 132 | 133 | def test_help_text(self): 134 | self.assertEqual( 135 | UserAttributeSimilarityValidator().get_help_text(), 136 | "Your password can't be too similar to your other personal information." 137 | ) 138 | 139 | 140 | class CommonPasswordValidatorTest(TestCase): 141 | def test_validate(self): 142 | expected_error = "This password is too common." 143 | self.assertIsNone(CommonPasswordValidator().validate('a-safe-password')) 144 | 145 | with self.assertRaises(ValidationError) as cm: 146 | CommonPasswordValidator().validate('godzilla') 147 | self.assertEqual(cm.exception.messages, [expected_error]) 148 | 149 | def test_validate_custom_list(self): 150 | path = os.path.join(os.path.dirname(os.path.realpath(upath(__file__))), 'common-passwords-custom.txt') 151 | validator = CommonPasswordValidator(password_list_path=path) 152 | expected_error = "This password is too common." 153 | self.assertIsNone(validator.validate('a-safe-password')) 154 | 155 | with self.assertRaises(ValidationError) as cm: 156 | validator.validate('from-my-custom-list') 157 | self.assertEqual(cm.exception.messages, [expected_error]) 158 | self.assertEqual(cm.exception.error_list[0].code, 'password_too_common') 159 | 160 | def test_help_text(self): 161 | self.assertEqual( 162 | CommonPasswordValidator().get_help_text(), 163 | "Your password can't be a commonly used password." 164 | ) 165 | 166 | 167 | class NumericPasswordValidatorTest(TestCase): 168 | def test_validate(self): 169 | expected_error = "This password is entirely numeric." 170 | self.assertIsNone(NumericPasswordValidator().validate('a-safe-password')) 171 | 172 | with self.assertRaises(ValidationError) as cm: 173 | NumericPasswordValidator().validate('42424242') 174 | self.assertEqual(cm.exception.messages, [expected_error]) 175 | self.assertEqual(cm.exception.error_list[0].code, 'password_entirely_numeric') 176 | 177 | def test_help_text(self): 178 | self.assertEqual( 179 | NumericPasswordValidator().get_help_text(), 180 | "Your password can't be entirely numeric." 181 | ) 182 | -------------------------------------------------------------------------------- /password_validation/test/test_views.py: -------------------------------------------------------------------------------- 1 | from password_validation import forms, views 2 | 3 | 4 | def mock_view(*args, **kwargs): 5 | return args, kwargs 6 | 7 | 8 | def test_password_reset_confirm(monkeypatch): 9 | monkeypatch.setattr(views.auth_views, 'password_reset_confirm', mock_view) 10 | 11 | args, kwargs = views.password_reset_confirm() 12 | custom = object() 13 | args2, kwargs2 = views.password_reset_confirm(set_password_form=custom) 14 | 15 | assert kwargs['set_password_form'] is forms.SetPasswordForm 16 | assert kwargs2['set_password_form'] is custom 17 | 18 | 19 | def test_password_change(monkeypatch): 20 | monkeypatch.setattr(views.auth_views, 'password_change', mock_view) 21 | 22 | args, kwargs = views.password_change() 23 | custom = object() 24 | args2, kwargs2 = views.password_change(password_change_form=custom) 25 | 26 | assert kwargs['password_change_form'] is forms.PasswordChangeForm 27 | assert kwargs2['password_change_form'] is custom 28 | -------------------------------------------------------------------------------- /password_validation/validation.py: -------------------------------------------------------------------------------- 1 | """This module is backported unchanged from Django. 2 | 3 | Copied from django/contrib/auth/password_validation.py at commit 4 | 9851e54121b3eebd3a7a29de3ed874d82554396b 5 | 6 | The only modification is changing `from django.conf import settings` to `from 7 | password_validation.conf import settings`. 8 | 9 | """ 10 | from __future__ import unicode_literals 11 | 12 | import gzip 13 | import os 14 | import re 15 | from difflib import SequenceMatcher 16 | 17 | from password_validation.conf import settings 18 | from django.core.exceptions import ImproperlyConfigured, ValidationError 19 | from django.utils import lru_cache 20 | from django.utils._os import upath 21 | from django.utils.encoding import force_text 22 | from django.utils.html import format_html 23 | from django.utils.module_loading import import_string 24 | from django.utils.six import string_types 25 | from django.utils.translation import ugettext as _ 26 | 27 | 28 | @lru_cache.lru_cache(maxsize=None) 29 | def get_default_password_validators(): 30 | return get_password_validators(settings.AUTH_PASSWORD_VALIDATORS) 31 | 32 | 33 | def get_password_validators(validator_config): 34 | validators = [] 35 | for validator in validator_config: 36 | try: 37 | klass = import_string(validator['NAME']) 38 | except ImportError: 39 | msg = "The module in NAME could not be imported: %s. Check your AUTH_PASSWORD_VALIDATORS setting." 40 | raise ImproperlyConfigured(msg % validator['NAME']) 41 | validators.append(klass(**validator.get('OPTIONS', {}))) 42 | 43 | return validators 44 | 45 | 46 | def validate_password(password, user=None, password_validators=None): 47 | """ 48 | Validate whether the password meets all validator requirements. 49 | 50 | If the password is valid, return ``None``. 51 | If the password is invalid, raise ValidationError with all error messages. 52 | """ 53 | errors = [] 54 | if password_validators is None: 55 | password_validators = get_default_password_validators() 56 | for validator in password_validators: 57 | try: 58 | validator.validate(password, user) 59 | except ValidationError as error: 60 | errors.append(error) 61 | if errors: 62 | raise ValidationError(errors) 63 | 64 | 65 | def password_changed(password, user=None, password_validators=None): 66 | """ 67 | Inform all validators that have implemented a password_changed() method 68 | that the password has been changed. 69 | """ 70 | if password_validators is None: 71 | password_validators = get_default_password_validators() 72 | for validator in password_validators: 73 | password_changed = getattr(validator, 'password_changed', lambda *a: None) 74 | password_changed(password, user) 75 | 76 | 77 | def password_validators_help_texts(password_validators=None): 78 | """ 79 | Return a list of all help texts of all configured validators. 80 | """ 81 | help_texts = [] 82 | if password_validators is None: 83 | password_validators = get_default_password_validators() 84 | for validator in password_validators: 85 | help_texts.append(validator.get_help_text()) 86 | return help_texts 87 | 88 | 89 | def password_validators_help_text_html(password_validators=None): 90 | """ 91 | Return an HTML string with all help texts of all configured validators 92 | in an
      . 93 | """ 94 | help_texts = password_validators_help_texts(password_validators) 95 | help_items = [format_html('
    • {}
    • ', help_text) for help_text in help_texts] 96 | return '
        %s
      ' % ''.join(help_items) 97 | 98 | 99 | class MinimumLengthValidator(object): 100 | """ 101 | Validate whether the password is of a minimum length. 102 | """ 103 | def __init__(self, min_length=8): 104 | self.min_length = min_length 105 | 106 | def validate(self, password, user=None): 107 | if len(password) < self.min_length: 108 | raise ValidationError( 109 | _("This password is too short. It must contain at least %(min_length)d characters."), 110 | code='password_too_short', 111 | params={'min_length': self.min_length}, 112 | ) 113 | 114 | def get_help_text(self): 115 | return _("Your password must contain at least %(min_length)d characters.") % {'min_length': self.min_length} 116 | 117 | 118 | class UserAttributeSimilarityValidator(object): 119 | """ 120 | Validate whether the password is sufficiently different from the user's 121 | attributes. 122 | 123 | If no specific attributes are provided, look at a sensible list of 124 | defaults. Attributes that don't exist are ignored. Comparison is made to 125 | not only the full attribute value, but also its components, so that, for 126 | example, a password is validated against either part of an email address, 127 | as well as the full address. 128 | """ 129 | DEFAULT_USER_ATTRIBUTES = ('username', 'first_name', 'last_name', 'email') 130 | 131 | def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7): 132 | self.user_attributes = user_attributes 133 | self.max_similarity = max_similarity 134 | 135 | def validate(self, password, user=None): 136 | if not user: 137 | return 138 | 139 | for attribute_name in self.user_attributes: 140 | value = getattr(user, attribute_name, None) 141 | if not value or not isinstance(value, string_types): 142 | continue 143 | value_parts = re.split('\W+', value) + [value] 144 | for value_part in value_parts: 145 | if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() > self.max_similarity: 146 | verbose_name = force_text(user._meta.get_field(attribute_name).verbose_name) 147 | raise ValidationError( 148 | _("The password is too similar to the %(verbose_name)s."), 149 | code='password_too_similar', 150 | params={'verbose_name': verbose_name}, 151 | ) 152 | 153 | def get_help_text(self): 154 | return _("Your password can't be too similar to your other personal information.") 155 | 156 | 157 | class CommonPasswordValidator(object): 158 | """ 159 | Validate whether the password is a common password. 160 | 161 | The password is rejected if it occurs in a provided list, which may be gzipped. 162 | The list Django ships with contains 1000 common passwords, created by Mark Burnett: 163 | https://xato.net/passwords/more-top-worst-passwords/ 164 | """ 165 | DEFAULT_PASSWORD_LIST_PATH = os.path.join( 166 | os.path.dirname(os.path.realpath(upath(__file__))), 'common-passwords.txt.gz' 167 | ) 168 | 169 | def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH): 170 | try: 171 | common_passwords_lines = gzip.open(password_list_path).read().decode('utf-8').splitlines() 172 | except IOError: 173 | common_passwords_lines = open(password_list_path).readlines() 174 | self.passwords = {p.strip() for p in common_passwords_lines} 175 | 176 | def validate(self, password, user=None): 177 | if password.lower().strip() in self.passwords: 178 | raise ValidationError( 179 | _("This password is too common."), 180 | code='password_too_common', 181 | ) 182 | 183 | def get_help_text(self): 184 | return _("Your password can't be a commonly used password.") 185 | 186 | 187 | class NumericPasswordValidator(object): 188 | """ 189 | Validate whether the password is alphanumeric. 190 | """ 191 | def validate(self, password, user=None): 192 | if password.isdigit(): 193 | raise ValidationError( 194 | _("This password is entirely numeric."), 195 | code='password_entirely_numeric', 196 | ) 197 | 198 | def get_help_text(self): 199 | return _("Your password can't be entirely numeric.") 200 | -------------------------------------------------------------------------------- /password_validation/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views as auth_views 2 | 3 | from . import forms 4 | 5 | 6 | def password_reset_confirm(*args, **kwargs): 7 | kwargs.setdefault('set_password_form', forms.SetPasswordForm) 8 | return auth_views.password_reset_confirm(*args, **kwargs) 9 | 10 | 11 | def password_change(*args, **kwargs): 12 | kwargs.setdefault('password_change_form', forms.PasswordChangeForm) 13 | return auth_views.password_change(*args, **kwargs) 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements for running django-password-validation tests 2 | 3 | Django>=1.8.2 4 | 5 | tox>=2.0.1 6 | pytest-django>=2.8.0 7 | pytest>=2.7.1 8 | coverage>=3.7.1 9 | 10 | Sphinx>=1.3.1 11 | 12 | flake8>=2.4.1 13 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This script exists so this dir is on sys.path when running pytest in tox. 3 | import pytest 4 | import os 5 | import sys 6 | 7 | os.environ.setdefault( 8 | 'DJANGO_SETTINGS_MODULE', 'password_validation.test.settings') 9 | 10 | sys.exit(pytest.main()) 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .tox,.git,__pycache__,doc/conf.py 3 | ignore = E123,E128,E402,E501,W503,E731,W601 4 | max-line-length = 119 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | from setuptools import setup, find_packages 3 | 4 | 5 | long_description = ( 6 | open('README.rst').read() + open('CHANGES.rst').read()) 7 | 8 | 9 | def get_version(): 10 | with open(join('password_validation', '__init__.py')) as f: 11 | for line in f: 12 | if line.startswith('__version__ ='): 13 | return line.split('=')[1].strip().strip('"\'') 14 | 15 | 16 | setup( 17 | name='django-password-validation', 18 | version=get_version(), 19 | description="Backport of Django 1.9 password validation", 20 | long_description=long_description, 21 | author='Erik Romijn', 22 | author_email='', 23 | maintainer='ORCAS, Inc', 24 | maintainer_email='orcastech@orcasinc.com', 25 | url='https://github.com/orcasgit/django-password-validation/', 26 | packages=find_packages(), 27 | package_data={ 28 | 'password_validation': ['common-passwords.txt.gz'], 29 | }, 30 | install_requires=['Django>=1.8.2'], 31 | classifiers=[ 32 | 'Development Status :: 3 - Alpha', 33 | 'Environment :: Web Environment', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2.6', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.2', 42 | 'Programming Language :: Python :: 3.3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Framework :: Django', 45 | ], 46 | zip_safe=False, 47 | ) 48 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py34-{docs,flake8}, 4 | py{27,33,34,py,py3}-django18 5 | 6 | [testenv] 7 | deps = 8 | pytest-django==2.8.0 9 | pytest==2.7.1 10 | py==1.4.27 11 | coverage==3.7.1 12 | django18: Django>=1.8,<1.9 13 | commands = 14 | coverage run -a runtests.py password_validation/test --tb short 15 | 16 | [testenv:py34-flake8] 17 | deps = flake8 18 | changedir = {toxinidir} 19 | commands = flake8 . 20 | 21 | [testenv:py34-docs] 22 | deps = Sphinx 23 | changedir = {toxinidir}/doc 24 | commands = 25 | sphinx-build -aEWq -b html . _build/html 26 | --------------------------------------------------------------------------------