├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── RELEASES.rst ├── authtools ├── __init__.py ├── admin.py ├── apps.py ├── backends.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_django18.py │ ├── 0003_auto_20160128_0912.py │ └── __init__.py └── models.py ├── docs ├── Makefile ├── _ext │ └── djangodocs.py ├── _themes │ └── kr │ │ ├── layout.html │ │ ├── relations.html │ │ ├── static │ │ ├── flasky.css_t │ │ └── small_flask.css │ │ └── theme.conf ├── admin.rst ├── backends.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── forms.rst ├── how-to │ ├── admin.py │ ├── index.rst │ ├── invitation-email.rst │ └── migrate-to-a-custom-user-model.rst ├── index.rst ├── intro.rst └── talks.rst ├── requirements-dev.txt ├── setup.py ├── tests ├── .gitignore ├── manage.py └── tests │ ├── __init__.py │ ├── fixtures │ ├── authtoolstestdata.json │ └── customusertestdata.json │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── sqlite_test_settings.py │ ├── tests.py │ └── urls.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | # Django 2.2 12 | - django-version: "2.2" 13 | python-version: "3.7" 14 | - django-version: "2.2" 15 | python-version: "3.8" 16 | - django-version: "2.2" 17 | python-version: "3.9" 18 | 19 | # Django 3.0 20 | - django-version: "3.0" 21 | python-version: "3.7" 22 | - django-version: "3.0" 23 | python-version: "3.8" 24 | - django-version: "3.0" 25 | python-version: "3.9" 26 | 27 | # Django 3.1 28 | - django-version: "3.1" 29 | python-version: "3.7" 30 | - django-version: "3.1" 31 | python-version: "3.8" 32 | - django-version: "3.1" 33 | python-version: "3.9" 34 | 35 | # Django 3.2 36 | - django-version: "3.2" 37 | python-version: "3.7" 38 | - django-version: "3.2" 39 | python-version: "3.8" 40 | - django-version: "3.2" 41 | python-version: "3.9" 42 | - django-version: "3.2" 43 | python-version: "3.10" 44 | 45 | # Django 4.0 46 | - django-version: "4.0" 47 | python-version: "3.8" 48 | - django-version: "4.0" 49 | python-version: "3.9" 50 | - django-version: "4.0" 51 | python-version: "3.10" 52 | 53 | # Django 4.1 54 | - django-version: "4.1" 55 | python-version: "3.8" 56 | - django-version: "4.1" 57 | python-version: "3.9" 58 | - django-version: "4.1" 59 | python-version: "3.10" 60 | 61 | # Django 4.2 62 | - django-version: "4.2" 63 | python-version: "3.8" 64 | - django-version: "4.2" 65 | python-version: "3.9" 66 | - django-version: "4.2" 67 | python-version: "3.10" 68 | - django-version: "4.2" 69 | python-version: "3.11" 70 | 71 | # Django 5.0 72 | - django-version: "5.0" 73 | python-version: "3.10" 74 | - django-version: "5.0" 75 | python-version: "3.11" 76 | - django-version: "5.0" 77 | python-version: "3.12" 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | 82 | - name: Set up Python ${{ matrix.python-version }} 83 | uses: actions/setup-python@v5 84 | with: 85 | python-version: ${{ matrix.python-version }} 86 | 87 | - name: Upgrade pip version 88 | run: python -m pip install -U pip 89 | 90 | - name: Upgrade django version 91 | run: python -m pip install "Django~=${{ matrix.django-version }}" 92 | 93 | - name: Install authtools 94 | run: python -m pip install -e . 95 | 96 | - name: Run Tests 97 | run: | 98 | make test 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | docs/_build/ 4 | tests/sqlite_database 5 | tests/.coverage 6 | tests/htmlcov/ 7 | dist/ 8 | build/ 9 | .tox 10 | /django.tar.gz 11 | .idea/ 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.9" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: requirements-dev.txt 14 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 2.0.2 (unreleased) 5 | ------------------ 6 | 7 | - Nothing changed yet. 8 | 9 | 10 | 2.0.1 (2024-03-19) 11 | ------------------ 12 | 13 | - Resolve `SHA1PasswordHasher` deprecation warning for Django 4.0 and above 14 | - Resolve `pkg_resources` deprecation warning for Python 3.8 and above 15 | - Add test coverage for Django 4.1, 4.2, and 5.0 16 | - Add test coverage for Python 3.11 and 3.12 17 | - Python 3.5 and 3.6 are no longer availble in GitHub runner using `ubuntu-latest` 18 | 19 | 20 | 2.0.0 (2022-07-29) 21 | ------------------ 22 | ** BREAKING ** 23 | 24 | Remove views and URLs. You can now use the ones built in to Django. Removes 25 | support for Django 1.11 and Python 2. 26 | 27 | - Add support for Django 2.2, 3.0, 3.1, 3.2, and 4.0. 28 | - Fix bug where request is not properly set on AuthenticationForm (#102) 29 | - Make UserAdmin compatible with Django 2.0 30 | - Fixes a bug where the password change link would not format correctly 31 | - Fixes a bug where BetterReadOnlyPasswordWidget would not work on a view only permission 32 | - Documentation fixes (#87, #117) 33 | - Set DEFAULT_AUTO_FIELD to AutoField in AuthtoolsConfig (#123) 34 | - Silences warning and prevents new migrations when using authtools with Django >= 3.2 35 | - Normalize email in User clean method and UserManager get_by_natural_key method (weslord #112) 36 | - Fixes a bug where email would not be normalized when creating a user in the admin 37 | - Migrate from TravisCI to GitHub Actions 38 | 39 | 40 | 1.7.0 (2019-06-26) 41 | ------------------ 42 | 43 | - Fix bug when using Django 1.11 where resetting a password when already logged in 44 | as another user caused an error 45 | - Remove support for Django versions below 1.11 and Python below 2.7 and 3.6 46 | 47 | 48 | 1.6.0 (2017-06-14) 49 | ------------------ 50 | 51 | - Add support for Django 1.9, 1.10, 1.11 (Jared Proffitt #82) 52 | - Remove old conditional imports dating as far back as Django 1.5 53 | - Update readme 54 | 55 | 56 | 1.5.0 (2016-03-26) 57 | ------------------ 58 | 59 | - Update various help_text fields to match Django 1.9 (Wenze van Klink #51, Gavin Wahl #64, Jared Proffitt #67, Ivan VenOsdel #69) 60 | - Documentation fixes (Yuki Izumi #52, Pi Delport #60, Germán Larraín #65) 61 | - Made case-insensitive tooling work with more than just USERNAME_FIELD='username' (Jared Proffitt, Rocky Meza #72, #73) 62 | 63 | 64 | 1.4.0 (2015-11-02) 65 | ------------------ 66 | 67 | - Dropped Django 1.7 compatibility (Antoine Catton) 68 | - Add Django 1.8 compatibility (Antoine Catton, Gavin Wahl, #56) 69 | - **Backwards Incompatible:** Remove 1.6 URLs (Antoine Catton) 70 | - **Backwards Incompatible:** Remove view functions 71 | 72 | 1.3.0 (unreleased) 73 | ------------------ 74 | 75 | - Added Django 1.7 compatibility (Antoine Catton, Rocky Meza, #35) 76 | - ``LoginView.disallow_authenticated`` was changed to ``LoginView.allow_authenticated`` 77 | - ``LoginView.disallow_authenticated`` was deprecated. 78 | - **Backwards Incompatible:** ``LoginView.allow_authenticated`` is now ``True`` 79 | by default (which is the default behavior in Django) 80 | - Create migrations for authtools. 81 | 82 | If updating from an older authtools, these migrations must be run on your apps:: 83 | 84 | $ python manage.py migrate --fake authtools 0001_initial 85 | 86 | $ python manage.py migrate 87 | 88 | 89 | 1.2.0 (2015-04-02) 90 | ------------------ 91 | 92 | - Add CaseInsensitiveEmailUserCreationForm for creating users with lowercased email address 93 | usernames (Bradley Gordon, #31, #11) 94 | - Add CaseInsensitiveEmailBackendMixin, CaseInsensitiveEmailModelBackend for authenticating 95 | case-insensitive email address usernames (Bradley Gordon, #31, #11) 96 | - Add tox support for test running (Piper Merriam, #25) 97 | 98 | 99 | 1.1.0 (2015-02-24) 100 | ------------------ 101 | 102 | - PasswordChangeView now handles a ``next`` URL parameter (#24) 103 | 104 | 1.0.0 (released August 16, 2014) 105 | -------------------------------- 106 | 107 | - Add friendly_password_reset view and FriendlyPasswordResetForm (Antoine Catton, #18) 108 | - **Bugfix** Allow LOGIN_REDIRECT_URL to be unicode (Alan Johnson, Gavin Wahl, Rocky Meza, #13) 109 | - **Backwards Incompatible** Dropped support for Python 3.2 110 | 111 | 0.2.2 (released July 21, 2014) 112 | ------------------------------ 113 | 114 | - Update safe urls in tests 115 | - Give the ability to restrain which users can reset their password 116 | - Add send_mail to AbstractEmailUser. (Jorge C. Leitão) 117 | 118 | 119 | 0.2.1 120 | ----- 121 | 122 | - Bugfix: UserAdmin was expecting a User with a `name` field. 123 | 124 | 0.2.0 125 | ----- 126 | 127 | - Django 1.6 support. 128 | 129 | Django 1.6 `broke backwards compatibility 130 | `_ 131 | of the ``password_reset_confirm`` view. Be sure to update any references to 132 | this URL. Rather than using a separate view for each encoding, authtools uses 133 | `a single view 134 | `_ 135 | that works with both. 136 | 137 | - Bugfix: if LOGIN_URL was a URL name, it wasn't being reversed in the 138 | PasswordResetConfirmView. 139 | 140 | 0.1.2 (released July 01, 2013) 141 | ------------------------------ 142 | 143 | - Use ``prefetch_related`` in the 144 | `UserChangeForm `_ 145 | to avoid doing hundreds of ``ContentType`` queries. The form from 146 | Django has the same feature, it wasn't copied over correctly in our 147 | original form. 148 | 149 | 0.1.1 (released May 30, 2013) 150 | ----------------------------- 151 | 152 | * some bugfixes: 153 | 154 | - Call ``UserManager.normalize_email`` on an instance, not a class. 155 | - ``authtools.models.User`` should inherit its parent's ``Meta``. 156 | 157 | 0.1.0 (released May 28, 2013) 158 | ----------------------------- 159 | 160 | - django-authtools 161 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ------------ 3 | 4 | We welcome contributions of all sizes, whether it be a small text change or a large new feature. 5 | Here are are some steps for getting started contributing. 6 | 7 | 8 | 9 | Getting Started 10 | =============== 11 | 12 | 1. Install the development requirements:: 13 | 14 | $ pip install -r requirements-dev.txt 15 | 16 | 17 | Running Tests 18 | ============= 19 | 20 | The best way to run the tests is using `tox`_. You can run the tests on all of our supported 21 | Python and Django versions by running:: 22 | 23 | $ tox 24 | 25 | You can also run specific targets using the ``-e`` flag. :: 26 | 27 | $ tox -e py33-dj18 28 | 29 | A full list of available tox environments is in the ``tox.ini`` configuration file. 30 | 31 | django-authtools comes with a test suite that inherits from the built-in Django auth test suite. 32 | This helps us ensure compatibility with Django and that we can get a little bit of code reuse. The 33 | tests are run three times against three different User models. 34 | 35 | You can get a test coverage report by running ``make coverage``. We do not strive for 100% coverage 36 | on django-authtools, but it is still a useful metric. 37 | 38 | .. _tox: http://tox.readthedocs.org/en/latest/ 39 | 40 | 41 | Building Documentation 42 | ====================== 43 | 44 | You can build the documentation by running :: 45 | 46 | $ make docs 47 | 48 | If you are editing the ``README.rst`` file, please make sure that it compiles correctly using the 49 | ``longtest`` command that is provided by ``zest.releaser``. :: 50 | 51 | $ longtest 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Fusionbox, Inc. 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 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst CHANGES.rst CONTRIBUTING.rst RELEASES.rst LICENSE Makefile 2 | include requirements-dev.txt 3 | include tox.ini 4 | include .readthedocs.yaml 5 | recursive-include tests *.py 6 | recursive-include tests *.json 7 | recursive-include docs * 8 | prune docs/_build 9 | global-exclude *.pyc 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS=tests authtools 2 | SETTINGS=tests.sqlite_test_settings 3 | COVERAGE_COMMAND= 4 | 5 | test: test-builtin test-authtools test-customuser 6 | 7 | test-builtin: 8 | cd tests && DJANGO_SETTINGS_MODULE=$(SETTINGS) $(COVERAGE_COMMAND) ./manage.py test --traceback $(TESTS) --verbosity=2 9 | 10 | test-authtools: 11 | +AUTH_USER_MODEL='authtools.User' make test-builtin 12 | 13 | test-customuser: 14 | +AUTH_USER_MODEL='tests.User' make test-builtin 15 | 16 | coverage: 17 | +make test COVERAGE_COMMAND='coverage run --source=authtools --branch --parallel-mode' 18 | cd tests && coverage combine && coverage html 19 | 20 | docs: 21 | cd docs && $(MAKE) html 22 | 23 | .PHONY: test test-builtin test-authtools test-customuser coverage docs 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-authtools 2 | ================ 3 | 4 | |Build status| 5 | 6 | .. |Build status| image:: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml/badge.svg 7 | :target: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml 8 | :alt: Build Status 9 | 10 | 11 | A custom user model app for Django 2.2+ that features email as username and 12 | other things. It tries to stay true to the built-in user model for the most 13 | part. 14 | 15 | Read the `django-authtools documentation 16 | `_. 17 | 18 | Quickstart 19 | ========== 20 | 21 | Before you use this, you should probably read the documentation about `custom 22 | User models 23 | `_. 24 | 25 | 1. Install the package: 26 | 27 | .. code-block:: bash 28 | 29 | $ pip install django-authtools 30 | 31 | 2. Add ``authtools`` to your ``INSTALLED_APPS``. 32 | 33 | 3. Add the following to your settings.py: 34 | 35 | .. code-block:: python 36 | 37 | AUTH_USER_MODEL = 'authtools.User' 38 | 39 | 4. Enjoy. 40 | -------------------------------------------------------------------------------- /RELEASES.rst: -------------------------------------------------------------------------------- 1 | Release Process 2 | =============== 3 | 4 | django-authtools uses `zest.releaser`_ to manage releases. For a fuller 5 | understanding of the release process, please read zest.releaser's 6 | documentation, this document is more of a cheat sheet. 7 | 8 | Getting Setup 9 | ------------- 10 | 11 | You will need to install zest.releaser:: 12 | 13 | $ pip install -r requirements-dev.txt 14 | 15 | You will also need to configure your ``.pypirc`` file to have the PyPI 16 | credentials. Ask one of the other Fusionbox Programmers how to do that. 17 | 18 | Releases 19 | -------- 20 | 21 | The process for releases is the same regardless of whether it's a patch, minor, 22 | or major release. It is as follows. 23 | 24 | 1. Add the changes to ``CHANGES.rst``. Don't commit. NOTE: You do not have to replace "(unreleased)" 25 | with the desired release date; zest.releaser will do this automatically. 26 | 2. Run the ``longtest`` command to make sure that the ``README.rst`` and 27 | ``CHANGES.rst`` files are valid. 28 | 3. Commit changes with a commit message like "CHANGES for 1.1.0". 29 | 4. Run the ``fullrelease`` command. 30 | 31 | 32 | Editing the Changelog 33 | --------------------- 34 | 35 | Editing the changelog is very important. It is where we write down all of our 36 | release notes and upgrade instructions. Please spend time when editing the 37 | changelog. 38 | 39 | One way to help getting the changes for new versions is to run the following 40 | commands:: 41 | 42 | $ git tag | sort -rn # figure out the latest tag (imagine it's 1.0.0) 43 | 1.0.0 44 | $ git log HEAD ^1.0.0 45 | 46 | This will show all the commits that are in HEAD that weren't in the last 47 | release. 48 | 49 | If possible, it's nice to add a credit line with the author's name and the 50 | issue number of GitHub. 51 | 52 | Deciding on a Version Number 53 | ---------------------------- 54 | 55 | Here are some nominal guidelines for deciding on version numbers when cutting 56 | releases. If you feel the need to deviate from them, go ahead. If you find 57 | yourself deviating every time, please update this document. 58 | 59 | This is not semver, but it's similar. 60 | 61 | Patch Release (1.0.x) 62 | ^^^^^^^^^^^^^^^^^^^^^ 63 | 64 | Bug fixes, documentation, and general project maintenance. 65 | 66 | Avoid backwards incompatible changes like the plague. 67 | 68 | Minor Release (1.x.0) 69 | ^^^^^^^^^^^^^^^^^^^^^ 70 | 71 | New features, and anything in patch releases. 72 | 73 | Try to avoid backwards incompatible changes, but if you feel like you need 74 | (especially for security), it's acceptable. 75 | 76 | Major Release (x.0.0) 77 | ^^^^^^^^^^^^^^^^^^^^^ 78 | 79 | Really Cool New Features, and anything that you include in a minor release. 80 | 81 | Backwards incompatibility is more acceptable here, although still frowned upon. 82 | 83 | 84 | Additional Reading 85 | ------------------ 86 | 87 | - `zest.releaser Version handling `_ 88 | - `PEP 396 - Module Version Numbers `_ 89 | - `PEP 440 - Version Identification and Dependency Specification `_ 90 | 91 | .. _zest.releaser: http://zestreleaser.readthedocs.org/ 92 | -------------------------------------------------------------------------------- /authtools/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib.metadata import version 3 | 4 | __version__ = version('django-authtools') 5 | except ImportError: 6 | import pkg_resources 7 | 8 | __version__ = pkg_resources.get_distribution('django-authtools').version 9 | -------------------------------------------------------------------------------- /authtools/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import copy 4 | 5 | from django.contrib import admin 6 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 7 | from django.contrib.auth import get_user_model 8 | from django.contrib.auth.forms import UserCreationForm 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from authtools.forms import UserChangeForm 12 | from authtools.models import User 13 | 14 | USERNAME_FIELD = get_user_model().USERNAME_FIELD 15 | 16 | REQUIRED_FIELDS = (USERNAME_FIELD,) + tuple(get_user_model().REQUIRED_FIELDS) 17 | 18 | BASE_FIELDS = (None, { 19 | 'fields': REQUIRED_FIELDS + ('password',), 20 | }) 21 | 22 | SIMPLE_PERMISSION_FIELDS = (_('Permissions'), { 23 | 'fields': ('is_active', 'is_staff', 'is_superuser',), 24 | }) 25 | 26 | ADVANCED_PERMISSION_FIELDS = copy.deepcopy(SIMPLE_PERMISSION_FIELDS) 27 | ADVANCED_PERMISSION_FIELDS[1]['fields'] += ('groups', 'user_permissions',) 28 | 29 | DATE_FIELDS = (_('Important dates'), { 30 | 'fields': ('last_login', 'date_joined',), 31 | }) 32 | 33 | 34 | class StrippedUserAdmin(DjangoUserAdmin): 35 | # The forms to add and change user instances 36 | add_form_template = None 37 | add_form = UserCreationForm 38 | form = UserChangeForm 39 | 40 | # The fields to be used in displaying the User model. 41 | # These override the definitions on the base UserAdmin 42 | # that reference specific fields on auth.User. 43 | list_display = ('is_active', USERNAME_FIELD, 'is_superuser', 'is_staff',) 44 | list_display_links = (USERNAME_FIELD,) 45 | list_filter = ('is_superuser', 'is_staff', 'is_active',) 46 | fieldsets = ( 47 | BASE_FIELDS, 48 | SIMPLE_PERMISSION_FIELDS, 49 | ) 50 | add_fieldsets = ( 51 | (None, { 52 | 'fields': REQUIRED_FIELDS + ( 53 | 'password1', 54 | 'password2', 55 | ), 56 | }), 57 | ) 58 | search_fields = (USERNAME_FIELD,) 59 | ordering = None 60 | filter_horizontal = tuple() 61 | readonly_fields = ('last_login', 'date_joined') 62 | 63 | 64 | class StrippedNamedUserAdmin(StrippedUserAdmin): 65 | list_display = ('is_active', 'email', 'name', 'is_superuser', 'is_staff',) 66 | list_display_links = ('email', 'name',) 67 | search_fields = ('email', 'name',) 68 | 69 | 70 | class UserAdmin(StrippedUserAdmin): 71 | fieldsets = ( 72 | BASE_FIELDS, 73 | ADVANCED_PERMISSION_FIELDS, 74 | DATE_FIELDS, 75 | ) 76 | filter_horizontal = ('groups', 'user_permissions',) 77 | 78 | 79 | class NamedUserAdmin(UserAdmin, StrippedNamedUserAdmin): 80 | pass 81 | 82 | 83 | # If the model has been swapped, this is basically a noop. 84 | admin.site.register(User, NamedUserAdmin) 85 | -------------------------------------------------------------------------------- /authtools/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthtoolsConfig(AppConfig): 5 | name = 'authtools' 6 | default_auto_field = 'django.db.models.AutoField' 7 | -------------------------------------------------------------------------------- /authtools/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | 3 | 4 | class CaseInsensitiveUsernameFieldBackendMixin(object): 5 | """ 6 | This authentication backend assumes that usernames are email addresses and simply 7 | lowercases a username before an attempt is made to authenticate said username using a 8 | superclass's authenticate method. This superclass should be either a user-defined 9 | authentication backend, or a Django-provided authentication backend (e.g., ModelBackend). 10 | 11 | Example usage: 12 | See CaseInsensitiveUsernameFieldBackend, below. 13 | 14 | NOTE: 15 | A word of caution. Use of this backend presupposes a way to ensure that users cannot 16 | create usernames that differ only in case (e.g., joe@test.org and JOE@test.org). It is 17 | advised that you use this backend in conjunction with the 18 | CaseInsensitiveUsernameFieldCreationForm provided in the forms module. 19 | """ 20 | 21 | def authenticate(self, request, username=None, password=None, **kwargs): 22 | if username is not None: 23 | username = username.lower() 24 | 25 | return super(CaseInsensitiveUsernameFieldBackendMixin, self).authenticate( 26 | request, 27 | username=username, 28 | password=password, 29 | **kwargs 30 | ) 31 | 32 | class CaseInsensitiveUsernameFieldModelBackend( 33 | CaseInsensitiveUsernameFieldBackendMixin, 34 | ModelBackend): 35 | pass 36 | 37 | 38 | # alias for the old name for backwards-compatability 39 | CaseInsensitiveEmailBackendMixin = CaseInsensitiveUsernameFieldBackendMixin 40 | CaseInsensitiveEmailModelBackend = CaseInsensitiveUsernameFieldModelBackend 41 | -------------------------------------------------------------------------------- /authtools/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django import forms 4 | from django.forms.utils import flatatt 5 | from django.contrib.auth.forms import ( 6 | ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget, 7 | PasswordResetForm as OldPasswordResetForm, 8 | UserChangeForm as DjangoUserChangeForm, 9 | AuthenticationForm as DjangoAuthenticationForm, 10 | ) 11 | from django.contrib.auth import get_user_model, password_validation 12 | from django.contrib.auth.hashers import identify_hasher, UNUSABLE_PASSWORD_PREFIX 13 | from django.utils.translation import gettext_lazy as _, gettext 14 | from django.utils.html import format_html 15 | 16 | User = get_user_model() 17 | 18 | 19 | def is_password_usable(pw): 20 | """Decide whether a password is usable only by the unusable password prefix. 21 | 22 | We can't use django.contrib.auth.hashers.is_password_usable either, because 23 | it not only checks against the unusable password, but checks for a valid 24 | hasher too. We need different error messages in those cases. 25 | """ 26 | 27 | return not pw.startswith(UNUSABLE_PASSWORD_PREFIX) 28 | 29 | 30 | class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): 31 | """ 32 | A ReadOnlyPasswordHashWidget that has a less intimidating output. 33 | """ 34 | def render(self, name, value, attrs=None, renderer=None): 35 | final_attrs = flatatt(self.build_attrs(attrs or {})) 36 | 37 | if not value or not is_password_usable(value): 38 | summary = gettext("No password set.") 39 | else: 40 | try: 41 | identify_hasher(value) 42 | except ValueError: 43 | summary = gettext("Invalid password format or unknown" 44 | " hashing algorithm.") 45 | else: 46 | summary = gettext('*************') 47 | 48 | return format_html('{summary}', 49 | attrs=final_attrs, summary=summary) 50 | 51 | 52 | class UserChangeForm(DjangoUserChangeForm): 53 | def __init__(self, *args, **kwargs): 54 | super(UserChangeForm, self).__init__(*args, **kwargs) 55 | password = self.fields.get('password') 56 | if password: 57 | password.widget = BetterReadOnlyPasswordHashWidget() 58 | 59 | 60 | class UserCreationForm(forms.ModelForm): 61 | """ 62 | A form for creating new users. Includes all the required 63 | fields, plus a repeated password. 64 | """ 65 | 66 | error_messages = { 67 | 'password_mismatch': _("The two password fields didn't match."), 68 | 'duplicate_username': _("A user with that %(username)s already exists."), 69 | } 70 | 71 | password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) 72 | password2 = forms.CharField(label=_("Password confirmation"), 73 | widget=forms.PasswordInput, 74 | help_text=_("Enter the same password as above," 75 | " for verification.")) 76 | 77 | class Meta: 78 | model = User 79 | fields = (User.USERNAME_FIELD,) + tuple(User.REQUIRED_FIELDS) 80 | 81 | def __init__(self, *args, **kwargs): 82 | super(UserCreationForm, self).__init__(*args, **kwargs) 83 | 84 | def validate_uniqueness_of_username_field(value): 85 | # Since User.username is unique, this check is redundant, 86 | # but it sets a nicer error message than the ORM. See #13147. 87 | try: 88 | User._default_manager.get_by_natural_key(value) 89 | except User.DoesNotExist: 90 | return value 91 | raise forms.ValidationError(self.error_messages['duplicate_username'] % { 92 | 'username': User.USERNAME_FIELD, 93 | }) 94 | 95 | self.fields[User.USERNAME_FIELD].validators.append(validate_uniqueness_of_username_field) 96 | 97 | def clean_password2(self): 98 | # Check that the two password entries match 99 | password1 = self.cleaned_data.get("password1") 100 | password2 = self.cleaned_data.get("password2") 101 | if password1 and password2 and password1 != password2: 102 | raise forms.ValidationError(self.error_messages['password_mismatch']) 103 | return password2 104 | 105 | def _post_clean(self): 106 | super(UserCreationForm, self)._post_clean() 107 | # Validate the password after self.instance is updated with form data 108 | # by super(). 109 | password = self.cleaned_data.get('password2') 110 | if password: 111 | try: 112 | password_validation.validate_password(password, self.instance) 113 | except forms.ValidationError as error: 114 | self.add_error('password2', error) 115 | 116 | def save(self, commit=True): 117 | # Save the provided password in hashed format 118 | user = super(UserCreationForm, self).save(commit=False) 119 | user.set_password(self.cleaned_data["password1"]) 120 | if commit: 121 | user.save() 122 | return user 123 | 124 | 125 | class CaseInsensitiveUsernameFieldCreationForm(UserCreationForm): 126 | """ 127 | This form is the same as UserCreationForm, except that usernames are lowercased before they 128 | are saved. This is to disallow the existence of email address usernames which differ only in 129 | case. 130 | """ 131 | def clean_USERNAME_FIELD(self): 132 | username = self.cleaned_data.get(User.USERNAME_FIELD) 133 | if username: 134 | username = username.lower() 135 | 136 | return username 137 | 138 | # set the correct clean method on the class so that child classes can override and call super() 139 | setattr( 140 | CaseInsensitiveUsernameFieldCreationForm, 141 | 'clean_' + User.USERNAME_FIELD, 142 | CaseInsensitiveUsernameFieldCreationForm.clean_USERNAME_FIELD 143 | ) 144 | 145 | # alias for the old name for backwards-compatability 146 | CaseInsensitiveEmailUserCreationForm = CaseInsensitiveUsernameFieldCreationForm 147 | 148 | 149 | class FriendlyPasswordResetForm(OldPasswordResetForm): 150 | error_messages = dict(getattr(OldPasswordResetForm, 'error_messages', {})) 151 | error_messages['unknown'] = _("This email address doesn't have an " 152 | "associated user account. Are you " 153 | "sure you've registered?") 154 | 155 | def clean_email(self): 156 | """Return an error message if the email address being reset is unknown. 157 | 158 | This is to revert https://code.djangoproject.com/ticket/19758 159 | The bug #19758 tries not to leak emails through password reset because 160 | only usernames are unique in Django's default user model. 161 | 162 | django-authtools leaks email addresses through the registration form. 163 | In the case of django-authtools not warning the user doesn't add any 164 | security, and worsen user experience. 165 | """ 166 | 167 | email = self.cleaned_data['email'] 168 | results = list(self.get_users(email)) 169 | 170 | if not results: 171 | raise forms.ValidationError(self.error_messages['unknown']) 172 | return email 173 | 174 | 175 | class AuthenticationForm(DjangoAuthenticationForm): 176 | def __init__(self, request=None, *args, **kwargs): 177 | super(AuthenticationForm, self).__init__(request, *args, **kwargs) 178 | username_field = User._meta.get_field(User.USERNAME_FIELD) 179 | self.fields['username'].widget = username_field.formfield().widget 180 | -------------------------------------------------------------------------------- /authtools/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('auth', '__first__'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='User', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('password', models.CharField(max_length=128, verbose_name='password')), 20 | ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')), 21 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 22 | ('email', models.EmailField(unique=True, max_length=255, verbose_name='email address', db_index=True)), 23 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 24 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 25 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 26 | ('name', models.CharField(max_length=255, verbose_name='name')), 27 | ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', verbose_name='groups')), 28 | ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')), 29 | ], 30 | options={ 31 | 'ordering': ['name', 'email'], 32 | 'abstract': False, 33 | 'verbose_name': 'user', 34 | 'swappable': 'AUTH_USER_MODEL', 35 | 'verbose_name_plural': 'users', 36 | }, 37 | bases=(models.Model,), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /authtools/migrations/0002_django18.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('authtools', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='last_login', 17 | field=models.DateTimeField(null=True, verbose_name='last login', blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /authtools/migrations/0003_auto_20160128_0912.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.1 on 2016-01-28 15:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('authtools', '0002_django18'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='user', 17 | name='email', 18 | field=models.EmailField(max_length=255, unique=True, verbose_name='email address'), 19 | ), 20 | migrations.AlterField( 21 | model_name='user', 22 | name='is_active', 23 | field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /authtools/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusionbox/django-authtools/eecc4903f26a9f0ef1d7f772096c51e0825609eb/authtools/migrations/__init__.py -------------------------------------------------------------------------------- /authtools/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin 4 | from django.core.mail import send_mail 5 | from django.db import models 6 | from django.utils import timezone 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class UserManager(BaseUserManager): 11 | def create_user(self, email, password=None, **kwargs): 12 | email = self.normalize_email(email) 13 | user = self.model(email=email, **kwargs) 14 | user.set_password(password) 15 | user.save(using=self._db) 16 | return user 17 | 18 | def create_superuser(self, **kwargs): 19 | kwargs.setdefault('is_staff', True) 20 | kwargs.setdefault('is_superuser', True) 21 | 22 | if kwargs.get('is_staff') is not True: 23 | raise ValueError('Superuser must have is_staff=True.') 24 | if kwargs.get('is_superuser') is not True: 25 | raise ValueError('Superuser must have is_superuser=True.') 26 | 27 | return self.create_user(**kwargs) 28 | 29 | def get_by_natural_key(self, email): 30 | normalized_email = self.normalize_email(email) 31 | return self.get(**{self.model.USERNAME_FIELD: normalized_email}) 32 | 33 | 34 | class AbstractEmailUser(AbstractBaseUser, PermissionsMixin): 35 | email = models.EmailField(_('email address'), max_length=255, unique=True) 36 | 37 | is_staff = models.BooleanField(_('staff status'), default=False, 38 | help_text=_('Designates whether the user can log into this admin ' 39 | 'site.')) 40 | is_active = models.BooleanField(_('active'), default=True, 41 | help_text=_('Designates whether this user should be treated as ' 42 | 'active. Unselect this instead of deleting accounts.')) 43 | date_joined = models.DateTimeField(_('date joined'), default=timezone.now) 44 | 45 | objects = UserManager() 46 | 47 | USERNAME_FIELD = 'email' 48 | REQUIRED_FIELDS = [] 49 | 50 | class Meta: 51 | abstract = True 52 | ordering = ['email'] 53 | 54 | def clean(self): 55 | super().clean() 56 | self.email = self.__class__.objects.normalize_email(self.email) 57 | 58 | def get_full_name(self): 59 | return self.email 60 | 61 | def get_short_name(self): 62 | return self.email 63 | 64 | def email_user(self, subject, message, from_email=None, **kwargs): 65 | """Sends an email to this User.""" 66 | 67 | send_mail(subject, message, from_email, [self.email], **kwargs) 68 | 69 | class AbstractNamedUser(AbstractEmailUser): 70 | name = models.CharField(_('name'), max_length=255) 71 | 72 | REQUIRED_FIELDS = ['name'] 73 | 74 | class Meta: 75 | abstract = True 76 | ordering = ['name', 'email'] 77 | 78 | def __str__(self): 79 | return '{name} <{email}>'.format( 80 | name=self.name, 81 | email=self.email, 82 | ) 83 | 84 | def get_full_name(self): 85 | return self.name 86 | 87 | def get_short_name(self): 88 | return self.name 89 | 90 | 91 | class User(AbstractNamedUser): 92 | class Meta(AbstractNamedUser.Meta): 93 | swappable = 'AUTH_USER_MODEL' 94 | verbose_name = _('user') 95 | verbose_name_plural = _('users') 96 | -------------------------------------------------------------------------------- /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 | # 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) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 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 " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-authtools.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-authtools.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-authtools" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-authtools" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_ext/djangodocs.py: -------------------------------------------------------------------------------- 1 | def setup(app): 2 | app.add_crossref_type( 3 | directivename="setting", 4 | rolename="setting", 5 | indextemplate="pair: %s; setting", 6 | ) 7 | -------------------------------------------------------------------------------- /docs/_themes/kr/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 9 | 10 | {% endblock %} 11 | {%- block relbar2 %}{% endblock %} 12 | {%- block footer %} 13 | 16 | 17 | Fork me on GitHub 18 | 19 | {%- endblock %} 20 | -------------------------------------------------------------------------------- /docs/_themes/kr/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /docs/_themes/kr/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro'; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0; 95 | margin: -10px 0 0 -20px; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 166 | div.body h2 { font-size: 180%; } 167 | div.body h3 { font-size: 150%; } 168 | div.body h4 { font-size: 130%; } 169 | div.body h5 { font-size: 100%; } 170 | div.body h6 { font-size: 100%; } 171 | 172 | a.headerlink { 173 | color: #ddd; 174 | padding: 0 4px; 175 | text-decoration: none; 176 | } 177 | 178 | a.headerlink:hover { 179 | color: #444; 180 | background: #eaeaea; 181 | } 182 | 183 | div.body p, div.body dd, div.body li { 184 | line-height: 1.4em; 185 | } 186 | 187 | div.admonition { 188 | background: #fafafa; 189 | margin: 20px -30px; 190 | padding: 10px 30px; 191 | border-top: 1px solid #ccc; 192 | border-bottom: 1px solid #ccc; 193 | } 194 | 195 | div.admonition tt.xref, div.admonition a tt { 196 | border-bottom: 1px solid #fafafa; 197 | } 198 | 199 | dd div.admonition { 200 | margin-left: -60px; 201 | padding-left: 60px; 202 | } 203 | 204 | div.admonition p.admonition-title { 205 | font-family: 'Garamond', 'Georgia', serif; 206 | font-weight: normal; 207 | font-size: 24px; 208 | margin: 0 0 10px 0; 209 | padding: 0; 210 | line-height: 1; 211 | } 212 | 213 | div.admonition p.last { 214 | margin-bottom: 0; 215 | } 216 | 217 | div.highlight { 218 | background-color: white; 219 | } 220 | 221 | dt:target, .highlight { 222 | background: #FAF3E8; 223 | } 224 | 225 | div.note { 226 | background-color: #eee; 227 | border: 1px solid #ccc; 228 | } 229 | 230 | div.seealso { 231 | background-color: #ffc; 232 | border: 1px solid #ff6; 233 | } 234 | 235 | div.topic { 236 | background-color: #eee; 237 | } 238 | 239 | p.admonition-title { 240 | display: inline; 241 | } 242 | 243 | p.admonition-title:after { 244 | content: ":"; 245 | } 246 | 247 | pre, tt { 248 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 249 | font-size: 0.9em; 250 | } 251 | 252 | img.screenshot { 253 | } 254 | 255 | tt.descname, tt.descclassname { 256 | font-size: 0.95em; 257 | } 258 | 259 | tt.descname { 260 | padding-right: 0.08em; 261 | } 262 | 263 | img.screenshot { 264 | -moz-box-shadow: 2px 2px 4px #eee; 265 | -webkit-box-shadow: 2px 2px 4px #eee; 266 | box-shadow: 2px 2px 4px #eee; 267 | } 268 | 269 | table.docutils { 270 | border: 1px solid #888; 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils td, table.docutils th { 277 | border: 1px solid #888; 278 | padding: 0.25em 0.7em; 279 | } 280 | 281 | table.field-list, table.footnote { 282 | border: none; 283 | -moz-box-shadow: none; 284 | -webkit-box-shadow: none; 285 | box-shadow: none; 286 | } 287 | 288 | table.footnote { 289 | margin: 15px 0; 290 | width: 100%; 291 | border: 1px solid #eee; 292 | background: #fdfdfd; 293 | font-size: 0.9em; 294 | } 295 | 296 | table.footnote + table.footnote { 297 | margin-top: -15px; 298 | border-top: none; 299 | } 300 | 301 | table.field-list th { 302 | padding: 0 0.8em 0 0; 303 | } 304 | 305 | table.field-list td { 306 | padding: 0; 307 | } 308 | 309 | table.footnote td.label { 310 | width: 0px; 311 | padding: 0.3em 0 0.3em 0.5em; 312 | } 313 | 314 | table.footnote td { 315 | padding: 0.3em 0.5em; 316 | } 317 | 318 | dl { 319 | margin: 0; 320 | padding: 0; 321 | } 322 | 323 | dl dd { 324 | margin-left: 30px; 325 | } 326 | 327 | blockquote { 328 | margin: 0 0 0 30px; 329 | padding: 0; 330 | } 331 | 332 | ul, ol { 333 | margin: 10px 0 10px 30px; 334 | padding: 0; 335 | } 336 | 337 | pre { 338 | background: #eee; 339 | padding: 7px 30px; 340 | margin: 15px -30px; 341 | line-height: 1.3em; 342 | } 343 | 344 | dl pre, blockquote pre, li pre { 345 | margin-left: -60px; 346 | padding-left: 60px; 347 | } 348 | 349 | dl dl pre { 350 | margin-left: -90px; 351 | padding-left: 90px; 352 | } 353 | 354 | tt { 355 | background-color: #ecf0f3; 356 | color: #222; 357 | /* padding: 1px 2px; */ 358 | } 359 | 360 | tt.xref, a tt { 361 | background-color: #FBFBFB; 362 | border-bottom: 1px solid white; 363 | } 364 | 365 | a.reference { 366 | text-decoration: none; 367 | border-bottom: 1px dotted #004B6B; 368 | } 369 | 370 | a.reference:hover { 371 | border-bottom: 1px solid #6D4100; 372 | } 373 | 374 | a.footnote-reference { 375 | text-decoration: none; 376 | font-size: 0.7em; 377 | vertical-align: top; 378 | border-bottom: 1px dotted #004B6B; 379 | } 380 | 381 | a.footnote-reference:hover { 382 | border-bottom: 1px solid #6D4100; 383 | } 384 | 385 | a:hover tt { 386 | background: #EEE; 387 | } 388 | 389 | 390 | @media screen and (max-width: 600px) { 391 | 392 | div.sphinxsidebar { 393 | display: none; 394 | } 395 | 396 | div.document { 397 | width: 100%; 398 | 399 | } 400 | 401 | div.documentwrapper { 402 | margin-left: 0; 403 | margin-top: 0; 404 | margin-right: 0; 405 | margin-bottom: 0; 406 | } 407 | 408 | div.bodywrapper { 409 | margin-top: 0; 410 | margin-right: 0; 411 | margin-bottom: 0; 412 | margin-left: 0; 413 | } 414 | 415 | ul { 416 | margin-left: 0; 417 | } 418 | 419 | .document { 420 | width: auto; 421 | } 422 | 423 | .footer { 424 | width: auto; 425 | } 426 | 427 | .bodywrapper { 428 | margin: 0; 429 | } 430 | 431 | .footer { 432 | width: auto; 433 | } 434 | 435 | .github { 436 | display: none; 437 | } 438 | 439 | } 440 | 441 | /* misc. */ 442 | 443 | .revsys-inline { 444 | display: none!important; 445 | } 446 | -------------------------------------------------------------------------------- /docs/_themes/kr/static/small_flask.css: -------------------------------------------------------------------------------- 1 | /* 2 | * small_flask.css_t 3 | * ~~~~~~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | body { 10 | margin: 0; 11 | padding: 20px 30px; 12 | } 13 | 14 | div.documentwrapper { 15 | float: none; 16 | background: white; 17 | } 18 | 19 | div.sphinxsidebar { 20 | display: block; 21 | float: none; 22 | width: 102.5%; 23 | margin: 50px -30px -20px -30px; 24 | padding: 10px 20px; 25 | background: #333; 26 | color: white; 27 | } 28 | 29 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 30 | div.sphinxsidebar h3 a { 31 | color: white; 32 | } 33 | 34 | div.sphinxsidebar a { 35 | color: #aaa; 36 | } 37 | 38 | div.sphinxsidebar p.logo { 39 | display: none; 40 | } 41 | 42 | div.document { 43 | width: 100%; 44 | margin: 0; 45 | } 46 | 47 | div.related { 48 | display: block; 49 | margin: 0; 50 | padding: 10px 0 20px 0; 51 | } 52 | 53 | div.related ul, 54 | div.related ul li { 55 | margin: 0; 56 | padding: 0; 57 | } 58 | 59 | div.footer { 60 | display: none; 61 | } 62 | 63 | div.bodywrapper { 64 | margin: 0; 65 | } 66 | 67 | div.body { 68 | min-height: 0; 69 | padding: 0; 70 | } 71 | 72 | .rtd_doc_footer { 73 | display: none; 74 | } 75 | 76 | .document { 77 | width: auto; 78 | } 79 | 80 | .footer { 81 | width: auto; 82 | } 83 | 84 | .footer { 85 | width: auto; 86 | } 87 | 88 | .github { 89 | display: none; 90 | } -------------------------------------------------------------------------------- /docs/_themes/kr/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | touch_icon = 8 | -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | Admin 2 | ===== 3 | 4 | .. currentmodule:: authtools.admin 5 | 6 | django-authtools provides a couple of Admin classes. The default one is 7 | :class:`NamedUserAdmin`, which provides an admin similar to 8 | django.contrib.auth. If you are not using the 9 | :class:`~authtools.models.AbstractNamedUser`, you might want the 10 | :class:`UserAdmin` instead. 11 | 12 | If you are using your own user model, authtools won't register an Admin class to 13 | avoid problems. If you define ``REQUIRED_FIELDS`` on your custom model, authtools 14 | will add those to the first fieldset. 15 | 16 | 17 | .. class:: NamedUserAdmin 18 | 19 | This is the default Admin that is used if you use 20 | :class:`authtools.models.User` as you ``AUTH_USER_MODEL``. Provides an admin 21 | for the default :class:`authtools.models.User` model. It includes the 22 | default Permissions and Important Date sections. 23 | 24 | .. class:: UserAdmin 25 | 26 | Provides a generic admin class for any User model. It behaves as similarly 27 | to the built-in UserAdmin class as possible. 28 | 29 | .. class:: StrippedUserAdmin 30 | 31 | Provides a simpler view on the UserAdmin, it doesn't include the Permission 32 | filters or the Important Dates section. 33 | 34 | 35 | .. class:: StrippedNamedUserAdmin 36 | 37 | Same as StrippedUserAdmin, but for a User model that has a ``name`` field. 38 | -------------------------------------------------------------------------------- /docs/backends.rst: -------------------------------------------------------------------------------- 1 | Authentication Backends 2 | ======================= 3 | 4 | .. currentmodule:: authtools.backends 5 | 6 | django-authtools provides two authentication backend classes. These backends offer more customization 7 | for authentication. 8 | 9 | .. class:: CaseInsensitiveUsernameFieldModelBackend 10 | 11 | Enables case-insensitive logins for the User model. It works by simply lowercasing usernames 12 | before trying to authenticate. 13 | 14 | There is also a :class:`CaseInsensitiveUsernameFieldBackendMixin` if you need more flexibility. 15 | 16 | To use this backend class, add it to your settings: 17 | 18 | .. code-block:: python 19 | 20 | # settings.py 21 | AUTHENTICATION_BACKENDS = [ 22 | 'authtools.backends.CaseInsensitiveUsernameFieldModelBackend', 23 | ] 24 | 25 | .. warning:: 26 | 27 | Use of this mixin assumes that all usernames are stored in their lowercase form, and 28 | that there is no way to have usernames differing only in case. If usernames can differ in 29 | case, this authentication backend mixin could cause errors in user authentication. It is 30 | advised that you use this mixin in conjuction with the 31 | :class:`~authtools.forms.CaseInsensitiveUsernameFieldCreationForm` form. 32 | 33 | .. class:: CaseInsensitiveUsernameFieldBackendMixin 34 | 35 | Mixin enabling case-insensitive logins. 36 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 1 2 | 3 | .. _changes: 4 | 5 | .. include:: ../CHANGES.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-authtools documentation build configuration file, created by 4 | # sphinx-quickstart on Thu May 23 14:33:19 2013. 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 sys, os 15 | 16 | sys.path.append(os.path.abspath('_themes')) 17 | sys.path.append(os.path.abspath('_ext')) 18 | 19 | import pkg_resources 20 | distribution = pkg_resources.get_distribution('django-authtools') 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | #sys.path.insert(0, os.path.abspath('.')) 26 | 27 | # -- General configuration ----------------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be extensions 33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 35 | 'sphinx.ext.intersphinx', 'djangodocs'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'django-authtools' 51 | copyright = u'2013, Fusionbox, Inc.' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The full version, including alpha/beta/rc tags. 58 | release = distribution.version 59 | # The short X.Y version. 60 | version = '.'.join(release.split('.')[:2]) 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 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 documents. 77 | #default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | #add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | #add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | #show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | #modindex_common_prefix = [] 95 | 96 | # If true, keep warnings as "system message" paragraphs in the built documents. 97 | #keep_warnings = False 98 | 99 | 100 | # -- Options for HTML output --------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'kr' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | html_theme_path = ['_themes'] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | #html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | #html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | #html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | #html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ['_static'] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | #html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | #html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | #html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | #html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | #html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | #html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | #html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | #html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | #html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | #html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | #html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | #html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = 'django-authtoolsdoc' 178 | 179 | 180 | # -- Options for LaTeX output -------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | #'papersize': 'letterpaper', 185 | 186 | # The font size ('10pt', '11pt' or '12pt'). 187 | #'pointsize': '10pt', 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #'preamble': '', 191 | } 192 | 193 | # Grouping the document tree into LaTeX files. List of tuples 194 | # (source start file, target name, title, author, documentclass [howto/manual]). 195 | latex_documents = [ 196 | ('index', 'django-authtools.tex', u'django-authtools Documentation', 197 | u'Fusionbox, Inc.', 'manual'), 198 | ] 199 | 200 | # The name of an image file (relative to this directory) to place at the top of 201 | # the title page. 202 | #latex_logo = None 203 | 204 | # For "manual" documents, if this is true, then toplevel headings are parts, 205 | # not chapters. 206 | #latex_use_parts = False 207 | 208 | # If true, show page references after internal links. 209 | #latex_show_pagerefs = False 210 | 211 | # If true, show URL addresses after external links. 212 | #latex_show_urls = False 213 | 214 | # Documents to append as an appendix to all manuals. 215 | #latex_appendices = [] 216 | 217 | # If false, no module index is generated. 218 | #latex_domain_indices = True 219 | 220 | 221 | # -- Options for manual page output -------------------------------------------- 222 | 223 | # One entry per manual page. List of tuples 224 | # (source start file, name, description, authors, manual section). 225 | man_pages = [ 226 | ('index', 'django-authtools', u'django-authtools Documentation', 227 | [u'Fusionbox, Inc.'], 1) 228 | ] 229 | 230 | # If true, show URL addresses after external links. 231 | #man_show_urls = False 232 | 233 | 234 | # -- Options for Texinfo output ------------------------------------------------ 235 | 236 | # Grouping the document tree into Texinfo files. List of tuples 237 | # (source start file, target name, title, author, 238 | # dir menu entry, description, category) 239 | texinfo_documents = [ 240 | ('index', 'django-authtools', u'django-authtools Documentation', 241 | u'Fusionbox, Inc.', 'django-authtools', 'A custom User model for everybody!', 242 | 'Miscellaneous'), 243 | ] 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #texinfo_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #texinfo_domain_indices = True 250 | 251 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 252 | #texinfo_show_urls = 'footnote' 253 | 254 | # If true, do not generate a @detailmenu in the "Top" node's menu. 255 | #texinfo_no_detailmenu = False 256 | 257 | intersphinx_mapping = { 258 | 'django': ('https://docs.djangoproject.com/en/stable/', 259 | 'https://docs.djangoproject.com/en/stable/_objects/'), 260 | } 261 | 262 | sys.path.insert(0, os.path.abspath('../tests')) 263 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 264 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/forms.rst: -------------------------------------------------------------------------------- 1 | Forms 2 | ===== 3 | 4 | .. currentmodule:: authtools.forms 5 | 6 | django-authtools provides several Form classes that mimic the forms in 7 | django.contrib.auth.forms, but work better with ``USERNAME_FIELD`` and 8 | ``REQUIRED_FIELDS``. These forms don't require the 9 | :class:`authtools.models.User` class in order to work, they should work with any 10 | User model that follows the :class:`User class contract `. 11 | 12 | .. class:: UserCreationForm 13 | 14 | Basically the same as django.contrib.auth, but respects ``USERNAME_FIELD`` 15 | and ``User.REQUIRED_FIELDS``. 16 | 17 | .. class:: CaseInsensitiveUsernameFieldCreationForm 18 | 19 | This is the same form as ``UserCreationForm``, but with an added method, ``clean_username`` 20 | which lowercases the username before saving. It is recommended that you use this form if you 21 | choose to use either the 22 | :class:`~authtools.backends.CaseInsensitiveUsernameFieldModelBackend` authentication backend 23 | class. 24 | 25 | .. note:: 26 | 27 | This form is also available sa CaseInsensitiveEmailUserCreationForm for 28 | backwards compatibility. 29 | 30 | .. class:: FriendlyPasswordResetForm 31 | 32 | Basically the same as 33 | :class:`django:django.contrib.auth.forms.PasswordResetForm`, but checks the 34 | email address against the database and gives a friendly error message. 35 | 36 | .. warning:: 37 | 38 | This form leaks user email addresses. 39 | 40 | It also provides a Widget class. 41 | 42 | .. class:: BetterReadOnlyPasswordHashWidget 43 | 44 | This is basically the same as django's ``ReadOnlyPasswordHashWidget``, but 45 | it provides a less intimidating user interface. Whereas django's Widget 46 | displays the password hash with it's salt, 47 | :class:`BetterReadOnlyPasswordHashWidget` simply presents a string of 48 | asterisks. 49 | -------------------------------------------------------------------------------- /docs/how-to/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django import forms 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.forms import PasswordResetForm 5 | from django.utils.crypto import get_random_string 6 | 7 | from authtools.admin import NamedUserAdmin 8 | from authtools.forms import UserCreationForm 9 | 10 | User = get_user_model() 11 | 12 | 13 | class UserCreationForm(UserCreationForm): 14 | """ 15 | A UserCreationForm with optional password inputs. 16 | """ 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(UserCreationForm, self).__init__(*args, **kwargs) 20 | self.fields['password1'].required = False 21 | self.fields['password2'].required = False 22 | # If one field gets autocompleted but not the other, our 'neither 23 | # password or both password' validation will be triggered. 24 | self.fields['password1'].widget.attrs['autocomplete'] = 'off' 25 | self.fields['password2'].widget.attrs['autocomplete'] = 'off' 26 | 27 | def clean_password2(self): 28 | password1 = self.cleaned_data.get("password1") 29 | password2 = super(UserCreationForm, self).clean_password2() 30 | if bool(password1) ^ bool(password2): 31 | raise forms.ValidationError("Fill out both fields") 32 | return password2 33 | 34 | 35 | class UserAdmin(NamedUserAdmin): 36 | """ 37 | A UserAdmin that sends a password-reset email when creating a new user, 38 | unless a password was entered. 39 | """ 40 | add_form = UserCreationForm 41 | add_fieldsets = ( 42 | (None, { 43 | 'description': ( 44 | "Enter the new user's name and email address and click save." 45 | " The user will be emailed a link allowing them to login to" 46 | " the site and set their password." 47 | ), 48 | 'fields': ('email', 'name',), 49 | }), 50 | ('Password', { 51 | 'description': "Optionally, you may set the user's password here.", 52 | 'fields': ('password1', 'password2'), 53 | 'classes': ('collapse', 'collapse-closed'), 54 | }), 55 | ) 56 | 57 | def save_model(self, request, obj, form, change): 58 | if not change and not obj.has_usable_password(): 59 | # Django's PasswordResetForm won't let us reset an unusable 60 | # password. We set it above super() so we don't have to save twice. 61 | obj.set_password(get_random_string()) 62 | reset_password = True 63 | else: 64 | reset_password = False 65 | 66 | super(UserAdmin, self).save_model(request, obj, form, change) 67 | 68 | if reset_password: 69 | reset_form = PasswordResetForm({'email': obj.email}) 70 | assert reset_form.is_valid() 71 | reset_form.save( 72 | subject_template_name='registration/account_creation_subject.txt', 73 | email_template_name='registration/account_creation_email.html', 74 | ) 75 | 76 | admin.site.unregister(User) 77 | admin.site.register(User, UserAdmin) 78 | -------------------------------------------------------------------------------- /docs/how-to/index.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | Here is a list of tutorials for dealing with custom User models. 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | 9 | migrate-to-a-custom-user-model 10 | invitation-email 11 | -------------------------------------------------------------------------------- /docs/how-to/invitation-email.rst: -------------------------------------------------------------------------------- 1 | How To Create Users Without Setting Their Password 2 | ================================================== 3 | 4 | When creating a new user through Django's admin interface, you are asked 5 | to enter the new user's password. This is less than ideal, because it 6 | requires the admin to think of a password for someone else, communicate 7 | it to them somehow, and then the user must remember to change it. A 8 | better way would be to send a password-reset email to the new user, 9 | allowing them to enter their own password. 10 | 11 | To implement this, we need to provide a user-creation form that has an 12 | optional (instead of required, like the built-in form) password field 13 | and a User admin that uses the form and sends the password-reset email 14 | when creating a new user. 15 | 16 | 17 | We'll subclass :class:`~authtools.forms.UserCreationForm` to create a form with 18 | optional password fields:: 19 | 20 | from django import forms 21 | from authtools.forms import UserCreationForm 22 | 23 | class UserCreationForm(UserCreationForm): 24 | """ 25 | A UserCreationForm with optional password inputs. 26 | """ 27 | 28 | def __init__(self, *args, **kwargs): 29 | super(UserCreationForm, self).__init__(*args, **kwargs) 30 | self.fields['password1'].required = False 31 | self.fields['password2'].required = False 32 | # If one field gets autocompleted but not the other, our 'neither 33 | # password or both password' validation will be triggered. 34 | self.fields['password1'].widget.attrs['autocomplete'] = 'off' 35 | self.fields['password2'].widget.attrs['autocomplete'] = 'off' 36 | 37 | def clean_password2(self): 38 | password1 = self.cleaned_data.get("password1") 39 | password2 = super(UserCreationForm, self).clean_password2() 40 | if bool(password1) ^ bool(password2): 41 | raise forms.ValidationError("Fill out both fields") 42 | return password2 43 | 44 | Then an admin class that uses our form and sends the email:: 45 | 46 | from django.contrib.auth import get_user_model 47 | from django.contrib.auth.forms import PasswordResetForm 48 | from django.utils.crypto import get_random_string 49 | from authtools.admin import NamedUserAdmin 50 | 51 | User = get_user_model() 52 | 53 | class UserAdmin(NamedUserAdmin): 54 | """ 55 | A UserAdmin that sends a password-reset email when creating a new user, 56 | unless a password was entered. 57 | """ 58 | add_form = UserCreationForm 59 | add_fieldsets = ( 60 | (None, { 61 | 'description': ( 62 | "Enter the new user's name and email address and click save." 63 | " The user will be emailed a link allowing them to login to" 64 | " the site and set their password." 65 | ), 66 | 'fields': ('email', 'name',), 67 | }), 68 | ('Password', { 69 | 'description': "Optionally, you may set the user's password here.", 70 | 'fields': ('password1', 'password2'), 71 | 'classes': ('collapse', 'collapse-closed'), 72 | }), 73 | ) 74 | 75 | def save_model(self, request, obj, form, change): 76 | if not change and (not form.cleaned_data['password1'] or not obj.has_usable_password()): 77 | # Django's PasswordResetForm won't let us reset an unusable 78 | # password. We set it above super() so we don't have to save twice. 79 | obj.set_password(get_random_string()) 80 | reset_password = True 81 | else: 82 | reset_password = False 83 | 84 | super(UserAdmin, self).save_model(request, obj, form, change) 85 | 86 | if reset_password: 87 | reset_form = PasswordResetForm({'email': obj.email}) 88 | assert reset_form.is_valid() 89 | reset_form.save( 90 | request=request, 91 | use_https=request.is_secure(), 92 | subject_template_name='registration/account_creation_subject.txt', 93 | email_template_name='registration/account_creation_email.html', 94 | ) 95 | 96 | Using :class:`django:django.contrib.auth.forms.PasswordResetForm` allows us to 97 | share the email-sending code with Django. If you wanted to change the template 98 | the email uses, ``email_template_name`` would be the place to do it. 99 | 100 | Now we can replace the installed UserAdmin with our own. :: 101 | 102 | from django.contrib import admin 103 | admin.site.unregister(User) 104 | admin.site.register(User, UserAdmin) 105 | 106 | 107 | You can view the :download:`complete admin.py file here. ` 108 | -------------------------------------------------------------------------------- /docs/how-to/migrate-to-a-custom-user-model.rst: -------------------------------------------------------------------------------- 1 | How To Migrate to a Custom User Model 2 | ===================================== 3 | 4 | 5 | If you are using the built-in Django User model and you want to switch to an 6 | authtools-based User model, there are certain steps you have to take in order 7 | to keep all of your data. These are steps that have worked for me in the past, 8 | maybe they will help to inform your journey. 9 | 10 | This tutorial assumes that you already have users in your database and that you need 11 | to preserve that data. If you don't already have users in your database, you can 12 | switch easily already. 13 | 14 | Step 1: Backup your database 15 | ---------------------------- 16 | 17 | There are several commands for doing this depending on your RDBMS (``pg_dump``, 18 | ``mysqldump``, ``cp``). If you don't want to worry about those, you could also 19 | look for a solution like `django-backupdb 20 | `_. You *do not* want to start 21 | this process without having a backup of your database. 22 | 23 | 24 | Step 2: Make a new app 25 | ---------------------- 26 | 27 | This is the app where your custom User model will live. I usually call this 28 | app ``accounts``. :: 29 | 30 | $ python manage.py startapp accounts 31 | 32 | In your new app, edit the models file and add the following:: 33 | 34 | from django.db import models 35 | from django.contrib.auth.models import AbstractUser 36 | 37 | class User(AbstractUser): 38 | class Meta: 39 | db_table = 'auth_user' 40 | 41 | 42 | This will put the User model in the same database table as the old one. This 43 | is not ideal, but it is the easiest way to do this migration. 44 | 45 | Add your ``accounts`` app to :django:setting:`INSTALLED_APPS`. 46 | 47 | Set the :django:setting:`AUTH_USER_MODEL` setting to point to your new User 48 | model. :: 49 | 50 | AUTH_USER_MODEL = 'accounts.User' 51 | 52 | If your code has any references to Django's ``User`` model, you will have to go through and replace them with `generic references `_. In most places, this means using ``get_user_model()`` instead of ``User``. 53 | For models with a database relationship to ``User``, you should use ``settings.AUTH_USER_MODEL``. 54 | 55 | 56 | Step 3: Seize control 57 | --------------------- 58 | 59 | Generate an initial migration for the ``accounts`` app. :: 60 | 61 | $ python manage.py makemigrations accounts 62 | 63 | If you are working on a new database and are running the migrations from 64 | scratch, you can run that migration normally. However, if you are working on an 65 | existing database, this migration will fail because the tables it attempts to 66 | create already exist. In this type of situation, the solution would usually be to fake apply the migration, 67 | but doing so in this case will cause Django to raise an :class:`InconsistentMigrationHistory` exception. 68 | There a couple of ways around this. 69 | 70 | One solution would be to delete all your old migration files, truncate the migrations table in the database, 71 | create new migrations, and then fake apply them as outlined `in this tutorial `_. 72 | 73 | This is not ideal. Instead, I suggest another solution that preserves your migration history. Thanks to `this blog post by Tobias McNulty `_ for the idea. 74 | 75 | Start by opening up a database shell. :: 76 | 77 | $ python manage.py dbshell 78 | 79 | Then manually add the migration to the database like this: :: 80 | 81 | INSERT INTO django_migrations (app, name, applied) VALUES ('accounts', '0001_initial', CURRENT_TIMESTAMP); 82 | 83 | Finally, update the ``django_content_type`` table with the new ``app_label`` so that existing references will point to your new user model. You can then exit the shell. :: 84 | 85 | UPDATE django_content_type SET app_label = 'accounts' WHERE app_label = 'auth' and model = 'user'; 86 | 87 | .. warning :: 88 | 89 | Make sure to test this process in a staging environment. If your deployment process automatically runs ``migrate``, you will need to run the 2 SQL statements above 90 | beforehand or the migration command will fail. 91 | 92 | 93 | 94 | 95 | Step 4: Conquer 96 | --------------- 97 | 98 | Your ``accounts`` app is now the authoritative source for the User model. You 99 | are in charge now. 100 | 101 | Go build stuff. 102 | 103 | 104 | Optional Step 5: Customize 105 | -------------------------- 106 | 107 | .. warning :: 108 | 109 | There is a potential unique constraint failure here. If you don't have 110 | emails for all of your users, you won't be able to migrate. If you don't 111 | have emails for all of your users, they won't be able to log in either, so 112 | you should make sure that you have all of those email addresses first. 113 | 114 | Now that you have control of the User model, there are tons of customizations 115 | that you can do. One thing that I like to do is treat ``email`` as the username 116 | and get rid of ``first_name``/``last_name`` in favor of a single ``name`` 117 | field. Here's how I've done it in the past. 118 | 119 | 1. Install django-authtools. :: 120 | 121 | $ pip install django-authtools 122 | 123 | 2. Add ``authtools`` to your ``INSTALLED_APPS``. :: 124 | 125 | INSTALLED_APPS = ( 126 | ... 127 | 'authtools', 128 | ... 129 | ) 130 | 131 | 132 | 3. Add the fields that I want to User. In this case, all I want to add is 133 | ``name``. ``email`` already exists on User, but I do need to make it 134 | unique if I'm going to treat it as a username. 135 | 136 | Here is an implementation of the User model using 137 | :class:`authtools.models.AbstractNamedUser` as a base. It preserves all of 138 | the fields that are on the built-in User model, but adds ``name`` and 139 | treats ``email`` as the username. :: 140 | 141 | from django.db import models 142 | from django.utils.translation import gettext_lazy as _ 143 | 144 | from authtools.models import AbstractNamedUser 145 | 146 | 147 | class User(AbstractNamedUser): 148 | username = models.CharField(_('username'), max_length=30, unique=True) 149 | first_name = models.CharField(_('first name'), max_length=30, blank=True) 150 | last_name = models.CharField(_('last name'), max_length=30, blank=True) 151 | 152 | class Meta: 153 | db_table = 'auth_user' 154 | 155 | I still have ``first_name`` and ``last_name`` because I have to preserve 156 | that data, I will get rid of those fields in step 5. 157 | 158 | 159 | 4. Make a migration to add those fields. :: 160 | 161 | $ python manage.py makemigrations accounts 162 | 163 | 164 | 5. Add python functions to run with the migration that consolidate ``first_name``/``last_name`` into ``name`` (and vice-versa when rolling-back). :: 165 | 166 | def forwards(apps, schema_editor): 167 | User = apps.get_model('accounts', 'User') 168 | for user in User.objects.all(): 169 | user.name = user.first_name + ' ' + user.last_name 170 | user.save() 171 | 172 | def backwards(apps, schema_editor): 173 | User = apps.get_model('accounts', 'User') 174 | for user in User.objects.all(): 175 | user.first_name, _, user.last_name = user.name.partition(' ') 176 | user.save() 177 | 178 | Add these functions to the list of operations in the generated migration file. :: 179 | 180 | operations = [ 181 | ..., 182 | migrations.RunPython(forwards, backwards), 183 | ] 184 | 185 | The backwards migration does make some assumptions about how names work, 186 | but those are the assumptions you are forced to make when using a system 187 | that assumes people have two names. 188 | 189 | 190 | 6. Delete the columns you don't want on your User model. For me, that's 191 | ``username``, ``first_name``, and ``last_name``. My User model now looks 192 | like this:: 193 | 194 | class User(AbstractNamedUser): 195 | class Meta: 196 | db_table = 'auth_user' 197 | 198 | 199 | 7. Generate a migration that deletes those extra fields. :: 200 | 201 | $ python manage.py makemigrations accounts 202 | 203 | 8. Run the migrations. :: 204 | 205 | $ python manage.py migrate accounts 206 | 207 | 208 | 9. Watch `YouTube `_. You are 209 | done. 210 | 211 | .. _this blog post by Tobias McNulty: https://www.caktusgroup.com/blog/2019/04/26/how-switch-custom-django-user-model-mid-project/ 212 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-authtools 2 | ================ 3 | 4 | A custom user model app for Django 2.2+. It tries to stay true to the built-in 5 | User model for the most part. The main differences between authtools and 6 | django.contrib.auth are a User model with email as username. 7 | 8 | It provides its own custom User model, ModelAdmin, and Forms. The Admin classes 9 | and forms, however, are all User model agnostic, so they will work with any 10 | User model. django-authtools also provides base classes that make creating 11 | your own custom User model easier. 12 | 13 | Contents: 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | intro 19 | admin 20 | forms 21 | backends 22 | how-to/index 23 | talks 24 | contributing 25 | changelog 26 | 27 | Development 28 | ----------- 29 | 30 | Development for django-authtools happens on `GitHub 31 | `_. Pull requests are welcome. 32 | Continuous integration uses `GitHub Actions 33 | `_. 34 | 35 | |Build status| 36 | 37 | .. |Build status| image:: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml/badge.svg 38 | :target: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml 39 | :alt: Build Status 40 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Before you use this, you should probably read the documentation about :ref:`custom User models `. 5 | 6 | Installation 7 | ------------ 8 | 9 | 1. Install the package:: 10 | 11 | $ pip install django-authtools 12 | 13 | Or you can install it from source:: 14 | 15 | $ pip install -e git+http://github.com/fusionbox/django-authtools@master#egg=django-authtools-dev 16 | 17 | 2. Add ``authtools`` to your ``INSTALLED_APPS``. 18 | 19 | 3. Run the authtools migrations:: 20 | 21 | $ python manage.py migrate 22 | 23 | 24 | Quick Setup 25 | ----------- 26 | 27 | If you want to use the User model provided by authtools (a sensible choice), there are three short steps. 28 | 29 | 1. Add ``authtools`` to your ``INSTALLED_APPS``. 30 | 31 | 2. Add the following to your settings.py:: 32 | 33 | AUTH_USER_MODEL = 'authtools.User' 34 | 35 | This will set you up with a custom user that 36 | 37 | - uses email as username 38 | - has one ``name`` field instead of ``first_name``, ``last_name`` (see `Falsehoods Programmers Believe About Names `_) 39 | 40 | It also gives you a registered admin class that has a less intimidating 41 | :class:`ReadOnlyPasswordHashWidget `. 42 | 43 | 44 | But it's supposed to be a *custom* User model! 45 | ---------------------------------------------- 46 | 47 | Making a User model that only concerns itself with authentication and 48 | authorization just *might* be a good idea. I recommend you read these: 49 | 50 | - `The User-Profile Pattern in Django `_ 51 | - `Williams, Master of the "Come From" `_ 52 | 53 | Also, please read this quote from the `Django documentation 54 | `_: 55 | 56 | .. warning:: 57 | 58 | Think carefully before handling information not directly related to 59 | authentication in your custom User Model. 60 | 61 | It may be better to store app-specific user information in a model that has 62 | a relation with the User model. That allows each app to specify its own 63 | user data requirements without risking conflicts with other apps. On the 64 | other hand, queries to retrieve this related information will involve a 65 | database join, which may have an effect on performance. 66 | 67 | However, there are many valid reasons for wanting a User model that you can 68 | change things on. django-authtools allows you to do that too. 69 | django-authtools provides a couple of abstract classes for subclassing. 70 | 71 | .. class:: authtools.models.AbstractEmailUser 72 | 73 | A no-frills email as username model that satisifes the User contract and 74 | the permissions API needed for the Admin site. 75 | 76 | .. class:: authtools.models.AbstractNamedUser 77 | 78 | A subclass of :class:`~authtools.models.AbstractEmailUser` that adds a name 79 | field. 80 | 81 | If want to make your custom User model, you can use one of these base classes. 82 | 83 | .. tip:: 84 | 85 | If you are just adding some methods to the User model, but not changing the 86 | database fields, you should consider using a proxy model. 87 | 88 | If you wanted a User model that had ``full_name`` and ``preferred_name`` 89 | fields instead of just ``name``, you could do this:: 90 | 91 | from authtools.models import AbstractEmailUser 92 | 93 | class User(AbstractEmailUser): 94 | full_name = models.CharField('full name', max_length=255, blank=True) 95 | preferred_name = models.CharField('preferred name', 96 | max_length=255, blank=True) 97 | 98 | def get_full_name(self): 99 | return self.full_name 100 | 101 | def get_short_name(self): 102 | return self.preferred_name 103 | 104 | Caveats 105 | ------- 106 | 107 | There are a couple of limitations to using the User model provided by authtools. 108 | 109 | The :class:`~authtools.models.User` provided by authtools specifies an email of ``max_length=255``. This works fine for PostgreSQL, but may cause issues with some other databases (MYSQL, MariaDB) where unique indexes can only be created with 191 characters. For this reason, Django's ``User`` model has a ``username`` field of ``max_length=150``. If you use one of these databases, you may want to subclass :class:`~authtools.models.AbstractEmailUser` or :class:`~authtools.models.AbstractNamedUser` and set the ``username`` field to ``max_length=191``. See the `Django docs `_ for more about this issue. 110 | 111 | 112 | Authtools specifies ``DEFAULT_AUTO_FIELD='django.db.models.AutoField'`` to prevent new migrations in existing projects when upgrading to Django >= 3.2. If you want to use ``django.db.models.BigAutoField``, you should subclass :class:`~authtools.models.AbstractEmailUser` or :class:`~authtools.models.AbstractNamedUser`. See the `Django 3.2 release notes `_ for more information. 113 | -------------------------------------------------------------------------------- /docs/talks.rst: -------------------------------------------------------------------------------- 1 | Talks 2 | ===== 3 | 4 | 2013 August 27 - Boulder Django 5 | ------------------------------- 6 | 7 | You can see the slides for "django-authtools, Custom User Model for 8 | Everyone!" given at `Boulder Django 9 | `_ on `Speaker Deck 10 | `_. 11 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | coverage 3 | Sphinx 4 | zest.releaser[recommended] 5 | Django>=1.11 6 | -e . 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import io 3 | import os 4 | 5 | from setuptools import setup, find_packages 6 | 7 | __doc__ = "Custom user model app for Django featuring email as username." 8 | 9 | 10 | def read(fname): 11 | return io.open(os.path.join(os.path.dirname(__file__), fname), encoding="utf-8").read() 12 | 13 | 14 | install_requires = [ 15 | 'Django>=2.2', 16 | ] 17 | 18 | setup( 19 | name='django-authtools', 20 | version='2.0.2.dev0', 21 | author='Fusionbox, Inc.', 22 | author_email='programmers@fusionbox.com', 23 | description=__doc__, 24 | long_description='\n\n'.join([read('README.rst'), read('CHANGES.rst')]), 25 | url='https://django-authtools.readthedocs.org/', 26 | license='BSD', 27 | packages=[package for package in find_packages() if package.startswith('authtools')], 28 | install_requires=install_requires, 29 | zip_safe=False, 30 | include_package_data=True, 31 | classifiers=[ 32 | 'Development Status :: 5 - Production/Stable', 33 | 'Environment :: Web Environment', 34 | 'Framework :: Django', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: BSD License', 37 | 'Natural Language :: English', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 3', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Programming Language :: Python :: 3.7', 43 | 'Programming Language :: Python :: 3.8', 44 | 'Programming Language :: Python :: 3.9', 45 | 'Programming Language :: Python :: 3.10', 46 | 'Programming Language :: Python :: 3.11', 47 | 'Programming Language :: Python :: 3.12', 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /auth_tests/ 2 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import warnings 5 | 6 | import django 7 | 8 | warnings.simplefilter('error') 9 | 10 | if __name__ == "__main__": 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 12 | 13 | from django.core.management import execute_from_command_line 14 | 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusionbox/django-authtools/eecc4903f26a9f0ef1d7f772096c51e0825609eb/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/fixtures/authtoolstestdata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": "1", 4 | "model": "authtools.user", 5 | "fields": { 6 | "name": "Test Client", 7 | "name": "Test Client", 8 | "is_active": true, 9 | "is_superuser": false, 10 | "is_staff": false, 11 | "last_login": "2006-12-17 07:03:31", 12 | "groups": [], 13 | "user_permissions": [], 14 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 15 | "email": "testclient@example.com", 16 | "date_joined": "2006-12-17 07:03:31" 17 | } 18 | }, 19 | { 20 | "pk": "2", 21 | "model": "authtools.user", 22 | "fields": { 23 | "name": "Inactive User", 24 | "is_active": false, 25 | "is_superuser": false, 26 | "is_staff": false, 27 | "last_login": "2006-12-17 07:03:31", 28 | "groups": [], 29 | "user_permissions": [], 30 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 31 | "email": "testclient2@example.com", 32 | "date_joined": "2006-12-17 07:03:31" 33 | } 34 | }, 35 | { 36 | "pk": "3", 37 | "model": "authtools.user", 38 | "fields": { 39 | "name": "Staff Member", 40 | "is_active": true, 41 | "is_superuser": false, 42 | "is_staff": true, 43 | "last_login": "2006-12-17 07:03:31", 44 | "groups": [], 45 | "user_permissions": [], 46 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 47 | "email": "staffmember@example.com", 48 | "date_joined": "2006-12-17 07:03:31" 49 | } 50 | }, 51 | { 52 | "pk": "4", 53 | "model": "authtools.user", 54 | "fields": { 55 | "name": "Empty Password", 56 | "is_active": true, 57 | "is_superuser": false, 58 | "is_staff": false, 59 | "last_login": "2006-12-17 07:03:31", 60 | "groups": [], 61 | "user_permissions": [], 62 | "password": "", 63 | "email": "empty_password@example.com", 64 | "date_joined": "2006-12-17 07:03:31" 65 | } 66 | }, 67 | { 68 | "pk": "5", 69 | "model": "authtools.user", 70 | "fields": { 71 | "name": "Unmanageable Password", 72 | "is_active": true, 73 | "is_superuser": false, 74 | "is_staff": false, 75 | "last_login": "2006-12-17 07:03:31", 76 | "groups": [], 77 | "user_permissions": [], 78 | "password": "$", 79 | "email": "unmanageable_password@example.com", 80 | "date_joined": "2006-12-17 07:03:31" 81 | } 82 | }, 83 | { 84 | "pk": "6", 85 | "model": "authtools.user", 86 | "fields": { 87 | "name": "Unknown Password", 88 | "is_active": true, 89 | "is_superuser": false, 90 | "is_staff": false, 91 | "last_login": "2006-12-17 07:03:31", 92 | "groups": [], 93 | "user_permissions": [], 94 | "password": "foo$bar", 95 | "email": "unknown_password@example.com", 96 | "date_joined": "2006-12-17 07:03:31" 97 | } 98 | } 99 | ] 100 | -------------------------------------------------------------------------------- /tests/tests/fixtures/customusertestdata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": "1", 4 | "model": "tests.user", 5 | "fields": { 6 | "full_name": "Test Client", 7 | "preferred_name": "Test", 8 | "is_active": true, 9 | "is_superuser": false, 10 | "is_staff": false, 11 | "last_login": "2006-12-17 07:03:31", 12 | "groups": [], 13 | "user_permissions": [], 14 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 15 | "email": "testclient@example.com", 16 | "date_joined": "2006-12-17 07:03:31" 17 | } 18 | }, 19 | { 20 | "pk": "2", 21 | "model": "tests.user", 22 | "fields": { 23 | "full_name": "Inactive User", 24 | "preferred_name": "Inactive", 25 | "is_active": false, 26 | "is_superuser": false, 27 | "is_staff": false, 28 | "last_login": "2006-12-17 07:03:31", 29 | "groups": [], 30 | "user_permissions": [], 31 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 32 | "email": "testclient2@example.com", 33 | "date_joined": "2006-12-17 07:03:31" 34 | } 35 | }, 36 | { 37 | "pk": "3", 38 | "model": "tests.user", 39 | "fields": { 40 | "full_name": "Staff Member", 41 | "preferred_name": "Staff", 42 | "is_active": true, 43 | "is_superuser": false, 44 | "is_staff": true, 45 | "last_login": "2006-12-17 07:03:31", 46 | "groups": [], 47 | "user_permissions": [], 48 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", 49 | "email": "staffmember@example.com", 50 | "date_joined": "2006-12-17 07:03:31" 51 | } 52 | }, 53 | { 54 | "pk": "4", 55 | "model": "tests.user", 56 | "fields": { 57 | "full_name": "Empty Password", 58 | "preferred_name": "Empty", 59 | "is_active": true, 60 | "is_superuser": false, 61 | "is_staff": false, 62 | "last_login": "2006-12-17 07:03:31", 63 | "groups": [], 64 | "user_permissions": [], 65 | "password": "", 66 | "email": "empty_password@example.com", 67 | "date_joined": "2006-12-17 07:03:31" 68 | } 69 | }, 70 | { 71 | "pk": "5", 72 | "model": "tests.user", 73 | "fields": { 74 | "full_name": "Unmanageable Password", 75 | "preferred_name": "Unmanageable", 76 | "is_active": true, 77 | "is_superuser": false, 78 | "is_staff": false, 79 | "last_login": "2006-12-17 07:03:31", 80 | "groups": [], 81 | "user_permissions": [], 82 | "password": "$", 83 | "email": "unmanageable_password@example.com", 84 | "date_joined": "2006-12-17 07:03:31" 85 | } 86 | }, 87 | { 88 | "pk": "6", 89 | "model": "tests.user", 90 | "fields": { 91 | "full_name": "Unknown Password", 92 | "preferred_name": "Unknown", 93 | "is_active": true, 94 | "is_superuser": false, 95 | "is_staff": false, 96 | "last_login": "2006-12-17 07:03:31", 97 | "groups": [], 98 | "user_permissions": [], 99 | "password": "foo$bar", 100 | "email": "unknown_password@example.com", 101 | "date_joined": "2006-12-17 07:03:31" 102 | } 103 | } 104 | ] 105 | -------------------------------------------------------------------------------- /tests/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-01 15:51 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('auth', '0009_alter_user_last_name_max_length'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='User', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('password', models.CharField(max_length=128, verbose_name='password')), 21 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 22 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 23 | ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')), 24 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 25 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 26 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 27 | ('full_name', models.CharField(blank=True, max_length=255, verbose_name='full name')), 28 | ('preferred_name', models.CharField(blank=True, max_length=255, verbose_name='preferred name')), 29 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 30 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 31 | ], 32 | options={ 33 | 'swappable': 'AUTH_USER_MODEL', 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /tests/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusionbox/django-authtools/eecc4903f26a9f0ef1d7f772096c51e0825609eb/tests/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from authtools.models import AbstractEmailUser 4 | 5 | 6 | class User(AbstractEmailUser): 7 | full_name = models.CharField('full name', max_length=255, blank=True) 8 | preferred_name = models.CharField('preferred name', max_length=255, 9 | blank=True) 10 | 11 | REQUIRED_FIELDS = ['full_name', 'preferred_name'] 12 | 13 | class Meta: 14 | # We need this line to prevent manage.py validate clashes. 15 | swappable = 'AUTH_USER_MODEL' 16 | 17 | def get_full_name(self): 18 | return self.full_name 19 | 20 | def get_short_name(self): 21 | return self.preferred_name 22 | -------------------------------------------------------------------------------- /tests/tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | 5 | from django import VERSION as DJANGO_VERSION 6 | 7 | SECRET_KEY = 'w6bidenrf5q%byf-q82b%pli50i0qmweus6gt_3@k$=zg7ymd3' 8 | SITE_ID = 1 9 | 10 | INSTALLED_APPS = ( 11 | 'django.contrib.sessions', 12 | 'django.contrib.contenttypes', 13 | 'django.contrib.auth', 14 | 'django.contrib.admin', 15 | 'django.contrib.staticfiles', 16 | 'django.contrib.sites', 17 | 'django.contrib.messages', 18 | 'tests', 19 | 'authtools', 20 | ) 21 | 22 | MIDDLEWARE = [ 23 | 'django.middleware.common.CommonMiddleware', 24 | 'django.middleware.csrf.CsrfViewMiddleware', 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 27 | 'django.contrib.messages.middleware.MessageMiddleware', 28 | ] 29 | 30 | 31 | DATABASES = { 32 | 'default': { 33 | 'ENGINE': 'django.db.backends.sqlite3', 34 | 'NAME': 'sqlite_database', 35 | } 36 | } 37 | 38 | ROOT_URLCONF = 'tests.urls' 39 | 40 | STATIC_URL = '/static/' 41 | DEBUG = True 42 | 43 | AUTH_USER_MODEL = os.environ.get('AUTH_USER_MODEL', 'auth.User') 44 | 45 | print('Using %s as the AUTH_USER_MODEL.' % AUTH_USER_MODEL) 46 | 47 | 48 | TEMPLATES = [ 49 | { 50 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 51 | 'APP_DIRS': True, 52 | 'OPTIONS': { 53 | 'context_processors': [ 54 | 'django.contrib.auth.context_processors.auth', 55 | 'django.contrib.messages.context_processors.messages', 56 | ], 57 | }, 58 | }, 59 | ] 60 | 61 | PASSWORD_HASHERS = [ 62 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 63 | ] 64 | 65 | USE_TZ = False 66 | -------------------------------------------------------------------------------- /tests/tests/sqlite_test_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from tests.settings import * 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': '', 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/tests/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from unittest import skipIf, skipUnless 4 | 5 | from django.core import mail 6 | 7 | from django.test import TestCase 8 | from django.test.utils import override_settings 9 | from django.utils.encoding import force_str 10 | from django.utils.translation import gettext as _ 11 | from django.forms.fields import Field 12 | from django.conf import settings 13 | from django.contrib.auth import get_user_model 14 | 15 | from authtools.admin import BASE_FIELDS 16 | from authtools.forms import ( 17 | UserCreationForm, 18 | UserChangeForm, 19 | CaseInsensitiveUsernameFieldCreationForm, 20 | CaseInsensitiveEmailUserCreationForm, 21 | ) 22 | 23 | User = get_user_model() 24 | 25 | 26 | def skipIfNotCustomUser(test_func): 27 | return skipIf(settings.AUTH_USER_MODEL == 'auth.User', 'Built-in User model in use')(test_func) 28 | 29 | 30 | def skipIfCustomUser(test_func): 31 | """ 32 | Copied from django.contrib.auth.tests.utils, This is deprecated in the future, but we still 33 | need it for some of our tests. 34 | """ 35 | return skipIf(settings.AUTH_USER_MODEL != 'auth.User', 'Custom user model in use')(test_func) 36 | 37 | 38 | class UserCreationFormTest(TestCase): 39 | def setUp(self): 40 | # in built-in UserManager, the order of arguments is: 41 | # username, email, password 42 | # in authtools UserManager, the order of arguments is: 43 | # USERNAME_FIELD, password 44 | User.objects.create_user('testclient@example.com', password='test123') 45 | self.username = User.USERNAME_FIELD 46 | 47 | def test_user_already_exists(self): 48 | # The benefit of the custom validation message is only available if the 49 | # messages are translated. We won't be able to translate all the 50 | # strings if we don't know what the username will be ahead of time. 51 | data = { 52 | self.username: 'testclient@example.com', 53 | 'password1': 'test123', 54 | 'password2': 'test123', 55 | } 56 | form = UserCreationForm(data) 57 | self.assertFalse(form.is_valid()) 58 | self.assertEqual(form[self.username].errors, [ 59 | force_str(form.error_messages['duplicate_username']) % {'username': self.username}]) 60 | 61 | def test_password_verification(self): 62 | # The verification password is incorrect. 63 | data = { 64 | self.username: 'jsmith', 65 | 'password1': 'test123', 66 | 'password2': 'test', 67 | } 68 | form = UserCreationForm(data) 69 | self.assertFalse(form.is_valid()) 70 | self.assertEqual(form["password2"].errors, 71 | [force_str(form.error_messages['password_mismatch'])]) 72 | 73 | def test_both_passwords(self): 74 | # One (or both) passwords weren't given 75 | data = {self.username: 'jsmith'} 76 | form = UserCreationForm(data) 77 | required_error = [force_str(Field.default_error_messages['required'])] 78 | self.assertFalse(form.is_valid()) 79 | self.assertEqual(form['password1'].errors, required_error) 80 | self.assertEqual(form['password2'].errors, required_error) 81 | 82 | data['password2'] = 'test123' 83 | form = UserCreationForm(data) 84 | self.assertFalse(form.is_valid()) 85 | self.assertEqual(form['password1'].errors, required_error) 86 | self.assertEqual(form['password2'].errors, []) 87 | 88 | def test_normalizes_email(self): 89 | data = { 90 | 'password1': 'test123', 91 | 'password2': 'test123', 92 | self.username: 'test@Example.com', 93 | } 94 | if settings.AUTH_USER_MODEL == 'auth.User': 95 | data['email'] = 'test@Example.com' 96 | elif settings.AUTH_USER_MODEL == 'authtools.User': 97 | data['name'] = 'John Smith' 98 | elif settings.AUTH_USER_MODEL != 'tests.User': 99 | assert False, "I don't know your user model" 100 | 101 | form = UserCreationForm(data) 102 | self.assertTrue(form.is_valid()) 103 | u = form.save() 104 | self.assertEqual(u.email, 'test@example.com') 105 | 106 | def test_success(self): 107 | # The success case. 108 | data = { 109 | self.username: 'jsmith@example.com', 110 | 'password1': 'test123', 111 | 'password2': 'test123', 112 | } 113 | 114 | if settings.AUTH_USER_MODEL == 'authtools.User': 115 | data['name'] = 'John Smith' 116 | 117 | form = UserCreationForm(data) 118 | self.assertTrue(form.is_valid()) 119 | u = form.save() 120 | self.assertEqual(getattr(u, self.username), 'jsmith@example.com') 121 | self.assertTrue(u.check_password('test123')) 122 | self.assertEqual(u, User._default_manager.get_by_natural_key('jsmith@example.com')) 123 | 124 | def test_generated_fields_list(self): 125 | if settings.AUTH_USER_MODEL == 'auth.User': 126 | fields = ('username', 'email', 'password1', 'password2') 127 | elif settings.AUTH_USER_MODEL == 'authtools.User': 128 | fields = ('email', 'name', 'password1', 'password2') 129 | elif settings.AUTH_USER_MODEL == 'tests.User': 130 | fields = ('email', 'full_name', 'preferred_name', 'password1', 'password2') 131 | else: 132 | assert False, "I don't know your user model" 133 | 134 | form = UserCreationForm() 135 | self.assertSequenceEqual(list(form.fields.keys()), fields) 136 | 137 | def test_uses_auth_password_validators(self): 138 | with self.settings( 139 | AUTH_PASSWORD_VALIDATORS=[ 140 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'} 141 | ] 142 | ): 143 | data = { 144 | self.username: 'jsmith@example.com', 145 | 'password1': 'a', 146 | 'password2': 'a', 147 | } 148 | 149 | if settings.AUTH_USER_MODEL == 'authtools.User': 150 | data['name'] = 'John Smith' 151 | 152 | form = UserCreationForm(data) 153 | self.assertFalse(form.is_valid()) 154 | 155 | 156 | @skipIfCustomUser 157 | @override_settings(USE_TZ=False) 158 | class UserChangeFormTest(TestCase): 159 | @classmethod 160 | def setUpTestData(cls): 161 | cls.u1 = User.objects.create( 162 | password='pbkdf2_sha256$600000$U3$rhq9d758wGq/JU5/+bkG8OqDVY05d04zKD4cuamR+Sk=', 163 | last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, username='testclient', 164 | first_name='Test', last_name='Client', email='testclient@example.com', is_staff=False, is_active=True, 165 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31) 166 | ) 167 | # cls.u3 = User.objects.create( 168 | # password='sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161', 169 | # last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, username='staff', 170 | # first_name='Staff', last_name='Member', email='staffmember@example.com', is_staff=True, is_active=True, 171 | # date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31) 172 | # ) 173 | cls.u4 = User.objects.create( 174 | password='', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, 175 | username='empty_password', first_name='Empty', last_name='Password', email='empty_password@example.com', 176 | is_staff=False, is_active=True, date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31) 177 | ) 178 | cls.u5 = User.objects.create( 179 | password='$', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, 180 | username='unmanageable_password', first_name='Unmanageable', last_name='Password', 181 | email='unmanageable_password@example.com', is_staff=False, is_active=True, 182 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31) 183 | ) 184 | cls.u6 = User.objects.create( 185 | password='foo$bar', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, 186 | username='unknown_password', first_name='Unknown', last_name='Password', 187 | email='unknown_password@example.com', is_staff=False, is_active=True, 188 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31) 189 | ) 190 | 191 | def test_bug_14242(self): 192 | # A regression test, introduce by adding an optimization for the 193 | # UserChangeForm. 194 | 195 | class MyUserForm(UserChangeForm): 196 | def __init__(self, *args, **kwargs): 197 | super(MyUserForm, self).__init__(*args, **kwargs) 198 | self.fields['groups'].help_text = 'These groups give users different permissions' 199 | 200 | class Meta(UserChangeForm.Meta): 201 | fields = ('groups',) 202 | 203 | # Just check we can create it 204 | MyUserForm({}) 205 | 206 | def test_unsuable_password(self): 207 | user = User.objects.get(username='empty_password') 208 | user.set_unusable_password() 209 | user.save() 210 | form = UserChangeForm(instance=user) 211 | self.assertIn(_("No password set."), form.as_table()) 212 | 213 | def test_bug_17944_empty_password(self): 214 | user = User.objects.get(username='empty_password') 215 | form = UserChangeForm(instance=user) 216 | self.assertIn(_("No password set."), form.as_table()) 217 | 218 | def test_bug_17944_unmanageable_password(self): 219 | user = User.objects.get(username='unmanageable_password') 220 | form = UserChangeForm(instance=user) 221 | self.assertIn(_("Invalid password format or unknown hashing algorithm."), 222 | form.as_table()) 223 | 224 | def test_bug_17944_unknown_password_algorithm(self): 225 | user = User.objects.get(username='unknown_password') 226 | form = UserChangeForm(instance=user) 227 | self.assertIn(_("Invalid password format or unknown hashing algorithm."), 228 | form.as_table()) 229 | 230 | def test_bug_19133(self): 231 | "The change form does not return the password value" 232 | # Use the form to construct the POST data 233 | user = User.objects.get(username='testclient') 234 | form_for_data = UserChangeForm(instance=user) 235 | post_data = form_for_data.initial 236 | 237 | # The password field should be readonly, so anything 238 | # posted here should be ignored; the form will be 239 | # valid, and give back the 'initial' value for the 240 | # password field. 241 | post_data['password'] = 'new password' 242 | form = UserChangeForm(instance=user, data=post_data) 243 | 244 | self.assertTrue(form.is_valid()) 245 | self.assertEqual(form.cleaned_data['password'], 'pbkdf2_sha256$600000$U3$rhq9d758wGq/JU5/+bkG8OqDVY05d04zKD4cuamR+Sk=') 246 | 247 | def test_bug_19349_bound_password_field(self): 248 | user = User.objects.get(username='testclient') 249 | form = UserChangeForm(data={}, instance=user) 250 | # When rendering the bound password field, 251 | # ReadOnlyPasswordHashWidget needs the initial 252 | # value to render correctly 253 | self.assertEqual(form.initial['password'], form['password'].value()) 254 | 255 | def test_better_readonly_password_widget(self): 256 | user = User.objects.get(username='testclient') 257 | form = UserChangeForm(instance=user) 258 | 259 | self.assertIn(_('*************'), form.as_table()) 260 | 261 | 262 | class UserAdminTest(TestCase): 263 | def test_generated_fieldsets(self): 264 | if settings.AUTH_USER_MODEL == 'auth.User': 265 | fields = ('username', 'email', 'password') 266 | elif settings.AUTH_USER_MODEL == 'authtools.User': 267 | fields = ('email', 'name', 'password') 268 | elif settings.AUTH_USER_MODEL == 'tests.User': 269 | fields = ('email', 'full_name', 'preferred_name', 'password') 270 | else: 271 | assert False, "I don't know your user model" 272 | 273 | self.assertSequenceEqual(BASE_FIELDS[1]['fields'], fields) 274 | 275 | 276 | class UserManagerTest(TestCase): 277 | def test_create_user(self): 278 | u = User._default_manager.create_user(**{ 279 | User.USERNAME_FIELD: 'newuser@example.com', 280 | 'password': 'test123', 281 | }) 282 | 283 | self.assertEqual(getattr(u, User.USERNAME_FIELD), 'newuser@example.com') 284 | self.assertTrue(u.check_password('test123')) 285 | self.assertEqual(u, User._default_manager.get_by_natural_key('newuser@example.com')) 286 | self.assertTrue(u.is_active) 287 | self.assertFalse(u.is_staff) 288 | self.assertFalse(u.is_superuser) 289 | 290 | @skipIfNotCustomUser 291 | def test_create_superuser(self): 292 | u = User._default_manager.create_superuser(**{ 293 | User.USERNAME_FIELD: 'newuser@example.com', 294 | 'password': 'test123', 295 | }) 296 | 297 | self.assertTrue(u.is_staff) 298 | self.assertTrue(u.is_superuser) 299 | 300 | 301 | class UserModelTest(TestCase): 302 | @skipUnless(settings.AUTH_USER_MODEL == 'authtools.User', 303 | "only check authuser's ordering") 304 | def test_default_ordering(self): 305 | self.assertSequenceEqual(['name', 'email'], User._meta.ordering) 306 | 307 | def test_send_mail(self): 308 | abstract_user = User(email='foo@bar.com') 309 | abstract_user.email_user(subject="Subject here", 310 | message="This is a message", from_email="from@domain.com") 311 | # Test that one message has been sent. 312 | self.assertEqual(len(mail.outbox), 1) 313 | # Verify that test email contains the correct attributes: 314 | message = mail.outbox[0] 315 | self.assertEqual(message.subject, "Subject here") 316 | self.assertEqual(message.body, "This is a message") 317 | self.assertEqual(message.from_email, "from@domain.com") 318 | self.assertEqual(message.to, [abstract_user.email]) 319 | 320 | 321 | @override_settings(AUTHENTICATION_BACKENDS=['authtools.backends.CaseInsensitiveUsernameFieldModelBackend']) 322 | class CaseInsensitiveTest(TestCase): 323 | form_class = CaseInsensitiveUsernameFieldCreationForm 324 | 325 | def get_form_data(self, data): 326 | base_data = { 327 | 'auth.User': {}, 328 | 'authtools.User': { 329 | 'name': 'Test Name', 330 | }, 331 | 'tests.User': { 332 | 'full_name': 'Francis Underwood', 333 | 'preferred_name': 'Frank', 334 | } 335 | } 336 | defaults = base_data[settings.AUTH_USER_MODEL] 337 | defaults.update(data) 338 | return defaults 339 | 340 | def test_case_insensitive_login_works(self): 341 | password = 'secret' 342 | form = self.form_class(self.get_form_data({ 343 | User.USERNAME_FIELD: 'TEst@exAmPle.Com', 344 | 'password1': password, 345 | 'password2': password, 346 | })) 347 | self.assertTrue(form.is_valid(), form.errors) 348 | form.save() 349 | 350 | self.assertTrue(self.client.login( 351 | username='test@example.com', 352 | password=password, 353 | )) 354 | 355 | self.assertTrue(self.client.login( 356 | username='TEST@EXAMPLE.COM', 357 | password=password, 358 | )) 359 | 360 | 361 | @override_settings(AUTHENTICATION_BACKENDS=['authtools.backends.CaseInsensitiveEmailModelBackend']) 362 | class CaseInsensitiveAliasTest(TestCase): 363 | """Test that the aliases still work as well""" 364 | form_class = CaseInsensitiveEmailUserCreationForm 365 | 366 | 367 | @skipIfNotCustomUser 368 | class NormalizeEmailTestCase(TestCase): 369 | def setUp(self): 370 | self.password = 'secret' 371 | self.user = User.objects.create_user( 372 | email='test@Foo.com', 373 | password=self.password, 374 | ) 375 | 376 | def test_create_user_normalizes_email(self): 377 | self.assertEqual(self.user.email, 'test@foo.com') 378 | 379 | def test_login_email_domain_is_case_insensitive(self): 380 | self.assertTrue(self.client.login( 381 | username='test@foo.com', 382 | password=self.password, 383 | )) 384 | self.assertTrue(self.client.login( 385 | username='test@Foo.com', 386 | password=self.password, 387 | )) 388 | 389 | def test_login_email_local_part_is_case_sensitive(self): 390 | self.assertFalse(self.client.login( 391 | username='Test@foo.com', 392 | password=self.password, 393 | )) 394 | -------------------------------------------------------------------------------- /tests/tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | py37-dj{22,30,32,32} 4 | py{38,39}-dj{22,30,31,32,40,41,42} 5 | py{10}-dj{32,40,41,42,50} 6 | py{11,12}-dj{42,50} 7 | [testenv] 8 | python= 9 | py37: python3.7 10 | py38: python3.8 11 | py39: python3.9 12 | py310: python3.10 13 | py311: python3.11 14 | py312: python3.12 15 | commands= 16 | /usr/bin/env 17 | make test 18 | deps= 19 | dj22: Django>=2.2,<2.3 20 | dj30: Django>=3.0,<3.1 21 | dj31: Django>=3.1,<3.2 22 | dj32: Django>=3.2,<3.3 23 | dj40: Django>=4.0,<4.1 24 | dj41: Django>=4.1,<4.2 25 | dj42: Django>=4.2,<4.3 26 | dj50: Django>=5.0,<5.1 27 | whitelist_externals= 28 | env 29 | make 30 | --------------------------------------------------------------------------------