├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── workflows │ ├── publish.yml │ ├── test-postgres.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_rest_passwordreset ├── __init__.py ├── admin.py ├── locale │ └── pt_BR │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── clearresetpasswodtokens.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_pk_migration.py │ ├── 0003_allow_blank_and_null_fields.py │ ├── 0004_alter_resetpasswordtoken_user_agent.py │ └── __init__.py ├── models.py ├── serializers.py ├── signals.py ├── throttling.py ├── tokens.py ├── urls.py └── views.py ├── docs ├── .gitkeep ├── browsable_api_email_validation.png ├── browsable_api_password_validation.png └── coreapi_docs.png ├── locale ├── en │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po └── es │ └── LC_MESSAGES │ ├── django.mo │ └── django.po ├── setup.cfg ├── setup.py └── tests ├── .gitkeep ├── __init__.py ├── manage.py ├── requirements.txt ├── settings.py ├── settings_postgres.py ├── test ├── __init__.py ├── helpers.py ├── test_auth_test_case.py ├── test_throttle.py └── test_token_generators.py ├── urls.py └── user_id_uuid_testapp ├── __init__.py ├── apps.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── tests ├── __init__.py ├── test_auth_test_case.py └── test_setup.py └── views.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **How to reproduce** 14 | Describe how to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Describe your changes or fixes (please link to an issue if applicable) 3 | 4 | ## Types of changes 5 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 6 | - [ ] New feature (non-breaking change which adds functionality) 7 | - [ ] Bug fix (non-breaking change which fixes an issue) 8 | - [ ] Refactoring (improvements in base code) 9 | - [ ] Add test (adds test coverage to functionality) 10 | 11 | ## Checklist 12 | - [ ] Automated tests 13 | - [ ] Extends CHANGELOG.md 14 | - [ ] Requires migrations? 15 | - [ ] Requires dependency update? 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.10' 17 | architecture: 'x64' 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install setuptools wheel twine 23 | 24 | - name: Build source and binary distribution package 25 | run: | 26 | python setup.py sdist bdist_wheel 27 | env: 28 | PACKAGE_VERSION: ${{ github.ref }} 29 | 30 | - name: Check distribution package 31 | run: | 32 | twine check dist/* 33 | 34 | - name: Publish distribution package 35 | run: | 36 | twine upload dist/* 37 | env: 38 | TWINE_REPOSITORY: ${{ secrets.PYPI_REPOSITORY }} 39 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 40 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 41 | TWINE_NON_INTERACTIVE: yes 42 | -------------------------------------------------------------------------------- /.github/workflows/test-postgres.yml: -------------------------------------------------------------------------------- 1 | name: Run tests on postgres 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | services: 7 | postgres: 8 | image: postgres:13 9 | env: 10 | POSTGRES_USER: user 11 | POSTGRES_PASSWORD: password 12 | POSTGRES_DB: postgres 13 | ports: 14 | - 5432:5432 15 | options: >- 16 | --health-cmd pg_isready 17 | --health-interval 10s 18 | --health-timeout 5s 19 | --health-retries 5 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: 25 | - "3.9" 26 | - "3.10" 27 | - "3.11" 28 | - "3.12" 29 | - "3.13" 30 | django-version: 31 | - "4.2" 32 | - "5.0" 33 | - "5.1" 34 | drf-version: 35 | - "3.15" 36 | exclude: 37 | - python-version: "3.9" 38 | django-version: "5.0" 39 | - python-version: "3.9" 40 | django-version: "5.1" 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip 53 | python -m pip install psycopg[binary] setuptools 54 | 55 | - name: Install Django version 56 | run: | 57 | python -m pip install "Django==${{ matrix.django-version }}.*" 58 | 59 | - name: Install DRF version 60 | run: | 61 | python -m pip install "djangorestframework==${{ matrix.drf-version }}.*" 62 | 63 | - name: Python, Django and DRF versions 64 | run: | 65 | echo "Python ${{ matrix.python-version }} -> Django ${{ matrix.django-version }} -> DRF ${{ matrix.drf-version }}" 66 | python --version 67 | echo "Django: `django-admin --version`" 68 | echo "DRF: `pip show djangorestframework|grep Version|sed s/Version:\ //`" 69 | 70 | - name: Setup environment 71 | run: | 72 | pip install -e . 73 | python setup.py install 74 | 75 | - name: Run tests 76 | working-directory: ./tests 77 | run: python manage.py test --settings=settings_postgres 78 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run linter and tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | python-version: 11 | - "3.9" 12 | - "3.10" 13 | - "3.11" 14 | - "3.12" 15 | - "3.13" 16 | django-version: 17 | - "4.2" 18 | - "5.0" 19 | - "5.1" 20 | drf-version: 21 | - "3.15" 22 | exclude: 23 | - python-version: "3.9" 24 | django-version: "5.0" 25 | - python-version: "3.9" 26 | django-version: "5.1" 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install flake8 codecov setuptools 40 | 41 | - name: Lint with flake8 42 | run: | 43 | # stop the build if there are Python syntax errors or undefined names 44 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 45 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 46 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 47 | 48 | - name: Install Django version 49 | run: | 50 | python -m pip install "Django==${{ matrix.django-version }}.*" 51 | 52 | - name: Install DRF version 53 | run: | 54 | python -m pip install "djangorestframework==${{ matrix.drf-version }}.*" 55 | 56 | - name: Python, Django and DRF versions 57 | run: | 58 | echo "Python ${{ matrix.python-version }} -> Django ${{ matrix.django-version }} -> DRF ${{ matrix.drf-version }}" 59 | python --version 60 | echo "Django: `django-admin --version`" 61 | echo "DRF: `pip show djangorestframework|grep Version|sed s/Version:\ //`" 62 | 63 | - name: Setup environment 64 | run: | 65 | pip install -e . 66 | python setup.py install 67 | 68 | - name: Run tests 69 | working-directory: ./tests 70 | run: | 71 | ln -s ../django_rest_passwordreset django_rest_passwordreset 72 | coverage run --source='./django_rest_passwordreset' manage.py test 73 | coverage xml -o ../coverage.xml 74 | 75 | - name: Upload coverage to Codecov 76 | uses: codecov/codecov-action@v4 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | *.pyo 4 | dist/** 5 | *.egg-info* 6 | .idea/ 7 | venv/ 8 | build/ 9 | .tox/ 10 | db.sqlite3 11 | dist/ 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | PyPi: [https://pypi.org/project/django-rest-passwordreset/](https://pypi.org/project/django-rest-passwordreset/). 8 | 9 | ## [Unreleased] 10 | 11 | ## [1.5.0] 12 | 13 | - Added Python 3.13 support 14 | - Added Django 5.1 support 15 | - Added Django Rest Framework 3.15 support 16 | - Removed Python 3.8 support 17 | - Removed Django 3.2 support 18 | - Removed Django Rest Framework 3.14 support 19 | 20 | ## [1.4.2] 21 | 22 | ### Fixed 23 | 24 | - X-Forwarded-For containing multiple IPs does not respect inet data type (#191) 25 | 26 | ## [1.4.1] 27 | 28 | ### Fixed 29 | - Fix the reset_password_token_created signal to be fired even when no token have been created. (#188) 30 | 31 | ## [1.4.0] 32 | 33 | ### Added 34 | - `pre_password_reset` and `post_password_reset` signals now provide `reset_password_token 35 | - Add translations to Brazilian Portuguese 36 | - Possibility to return the username and email address when validating a token 37 | - Generating and clearing tokens programmatically 38 | - Support for Python 3.11, 3.12 39 | - Support for Django 4.2, 5.0 40 | - Support for DRF 3.14 41 | 42 | ### Changed 43 | - Increase max_length of user_agent to 512 44 | - Dropped support for Django 4.0, 4.1 45 | - Dropped support for DRF 3.12, 3.13 46 | - Dropped support for Python 3.7 47 | 48 | ## [1.3.0] 49 | 50 | ### Added 51 | - Support for Python 3.10 52 | - Support for Django 3.2, 4.0, 4.1 53 | - Support for DRF 3.12, 3.13 54 | ### Changed 55 | - Dropped support for Python 3.5, 3.6 56 | - Dropped support Django 2.2, 3.0, 3.1 57 | - Dropped support form DRF 3.11, 3.12 58 | 59 | ## [1.2.1] 60 | ### Fixed 61 | - CVE-2019-19844 potentials 62 | 63 | ## [1.2.0] 64 | ### Added 65 | - Support for Django 3.x, DRF 3.1x 66 | ### Changed 67 | - Dropped support for Python 2.7 and 3.4, Django 1.11, 2.0 and 2.1, DRF < 3.10 68 | 69 | ## [1.1.0] 70 | ### Added 71 | - Token validation endpoint (#45, #59, #60) 72 | - Dynamic lookup field for email (#31) 73 | ### Changed 74 | - Fixes #34 75 | - PRs #40, #51, #54, #55 76 | 77 | ## [1.0.0] 78 | ### Added 79 | - Browseable API support, validations (#24) 80 | - Customized token generation (#20) 81 | - Clear expired tokens (#18) 82 | 83 | ## [0.9.7] 84 | - Fixes #8 (again), #11 85 | ## [0.9.6] 86 | - Fixes #8 87 | ## [0.9.5] 88 | - Fixes #4 89 | ## [0.9.4] 90 | - PR #1 91 | ## [0.9.3] 92 | - Maintenance Release 93 | ## [0.9.1] 94 | - Maintenance Release 95 | ## [0.9.0] 96 | - Initial Release 97 | 98 | [Unreleased]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.5.0...HEAD 99 | [1.5.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.4.2...1.5.0 100 | [1.4.2]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.4.1...1.4.2 101 | [1.4.1]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.4.0...1.4.1 102 | [1.4.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.3.0...1.4.0 103 | [1.3.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.2.1...1.3.0 104 | [1.2.1]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.2.0...1.2.1 105 | [1.2.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.1.0...1.2.0 106 | [1.1.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/1.0.0...1.1.0 107 | [1.0.0]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.7...1.0.0 108 | [0.9.7]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.6...0.9.7 109 | [0.9.6]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.5...0.9.6 110 | [0.9.5]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.4...0.9.5 111 | [0.9.4]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.3...0.9.4 112 | [0.9.3]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.1...0.9.3 113 | [0.9.1]: https://github.com/anexia-it/django-rest-passwordreset/compare/0.9.0...0.9.1 114 | [0.9.0]: https://github.com/anexia-it/django-rest-passwordreset/0.9.0/ 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016-2018, Christian Kreuzberger, Anexia Internetdienstleistungs GmbH 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include docs * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Rest Password Reset 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/django-rest-passwordreset.svg)](https://pypi.org/project/django-rest-passwordreset/) 4 | [![build-and-test actions status](https://github.com/anexia-it/django-rest-passwordreset/actions/workflows/test.yml/badge.svg)](https://github.com/anexia-it/django-rest-passwordreset/actions) 5 | [![Codecov](https://img.shields.io/codecov/c/gh/anexia-it/django-rest-passwordreset)](https://codecov.io/gh/anexia-it/django-rest-passwordreset) 6 | 7 | This python package provides a simple password reset strategy for django rest framework, where users can request password 8 | reset tokens via their registered e-mail address. 9 | 10 | The main idea behind this package is to not make any assumptions about how the token is delivered to the end-user (e-mail, text-message, etc...). 11 | Instead, this package provides a signal that can be reacted on (e.g., by sending an e-mail or a text message). 12 | 13 | This package basically provides two REST endpoints: 14 | 15 | * Request a token 16 | * Verify (confirm) a token (and change the password) 17 | 18 | ## Quickstart 19 | 20 | 1. Install the package from pypi using pip: 21 | ```bash 22 | pip install django-rest-passwordreset 23 | ``` 24 | 25 | 2. Add ``django_rest_passwordreset`` to your ``INSTALLED_APPS`` (after ``rest_framework``) within your Django settings file: 26 | ```python 27 | INSTALLED_APPS = ( 28 | ... 29 | 'django.contrib.auth', 30 | ... 31 | 'rest_framework', 32 | ... 33 | 'django_rest_passwordreset', 34 | ... 35 | ) 36 | ``` 37 | 38 | 3. This package stores tokens in a separate database table (see [django_rest_passwordreset/models.py](django_rest_passwordreset/models.py)). Therefore, you have to run django migrations: 39 | ```bash 40 | python manage.py migrate 41 | ``` 42 | 43 | 4. This package provides three endpoints, which can be included by including ``django_rest_passwordreset.urls`` in your ``urls.py`` as follows: 44 | ```python 45 | from django.urls import path, include 46 | 47 | urlpatterns = [ 48 | ... 49 | path(r'^api/password_reset/', include('django_rest_passwordreset.urls', namespace='password_reset')), 50 | ... 51 | ] 52 | ``` 53 | **Note**: You can adapt the URL to your needs. 54 | 55 | ### Endpoints 56 | 57 | The following endpoints are provided: 58 | 59 | * `POST ${API_URL}/` - request a reset password token by using the ``email`` parameter 60 | * `POST ${API_URL}/confirm/` - using a valid ``token``, the users password is set to the provided ``password`` 61 | * `POST ${API_URL}/validate_token/` - will return a 200 if a given ``token`` is valid 62 | 63 | where `${API_URL}/` is the url specified in your *urls.py* (e.g., `api/password_reset/` as in the example above) 64 | 65 | 66 | ### Signals 67 | 68 | * ``reset_password_token_created(sender, instance, reset_password_token)`` Fired when a reset password token is generated 69 | * ``pre_password_reset(sender, user, reset_password_token)`` - fired just before a password is being reset 70 | * ``post_password_reset(sender, user, reset_password_token)`` - fired after a password has been reset 71 | 72 | ### Example for sending an e-mail 73 | 74 | 1. Create two new django templates: `email/user_reset_password.html` and `email/user_reset_password.txt`. Those templates will contain the e-mail message sent to the user, aswell as the password reset link (or token). 75 | Within the templates, you can access the following context variables: `current_user`, `username`, `email`, `reset_password_url`. Feel free to adapt this to your needs. 76 | 77 | 2. Add the following code, which contains a Django Signal Receiver (`@receiver(...)`), to your application. Take care where to put this code, as it needs to be executed by the python interpreter (see the section *The `reset_password_token_created` signal is not fired* below, aswell as [this part of the django documentation](https://docs.djangoproject.com/en/1.11/topics/signals/#connecting-receiver-functions) and [How to Create Django Signals Tutorial](https://simpleisbetterthancomplex.com/tutorial/2016/07/28/how-to-create-django-signals.html) for more information). 78 | ```python 79 | from django.core.mail import EmailMultiAlternatives 80 | from django.dispatch import receiver 81 | from django.template.loader import render_to_string 82 | from django.urls import reverse 83 | 84 | from django_rest_passwordreset.signals import reset_password_token_created 85 | 86 | 87 | @receiver(reset_password_token_created) 88 | def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): 89 | """ 90 | Handles password reset tokens 91 | When a token is created, an e-mail needs to be sent to the user 92 | :param sender: View Class that sent the signal 93 | :param instance: View Instance that sent the signal 94 | :param reset_password_token: Token Model Object 95 | :param args: 96 | :param kwargs: 97 | :return: 98 | """ 99 | # send an e-mail to the user 100 | context = { 101 | 'current_user': reset_password_token.user, 102 | 'username': reset_password_token.user.username, 103 | 'email': reset_password_token.user.email, 104 | 'reset_password_url': "{}?token={}".format( 105 | instance.request.build_absolute_uri(reverse('password_reset:reset-password-confirm')), 106 | reset_password_token.key) 107 | } 108 | 109 | # render email text 110 | email_html_message = render_to_string('email/user_reset_password.html', context) 111 | email_plaintext_message = render_to_string('email/user_reset_password.txt', context) 112 | 113 | msg = EmailMultiAlternatives( 114 | # title: 115 | "Password Reset for {title}".format(title="Some website title"), 116 | # message: 117 | email_plaintext_message, 118 | # from: 119 | "noreply@somehost.local", 120 | # to: 121 | [reset_password_token.user.email] 122 | ) 123 | msg.attach_alternative(email_html_message, "text/html") 124 | msg.send() 125 | 126 | ``` 127 | 128 | 3. You should now be able to use the endpoints to request a password reset token via your e-mail address. 129 | If you want to test this locally, I recommend using some kind of fake mailserver (such as maildump). 130 | 131 | 132 | 133 | # Configuration / Settings 134 | 135 | The following settings can be set in Django ``settings.py`` file: 136 | 137 | * `DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME` - time in hours about how long the token is active (Default: 24) 138 | 139 | **Please note**: expired tokens are automatically cleared based on this setting in every call of ``ResetPasswordRequestToken.post``. 140 | 141 | * `DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE` - will cause a 200 to be returned on `POST ${API_URL}/reset_password/` 142 | even if the user doesn't exist in the databse (Default: False) 143 | 144 | * `DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD` - allows password reset for a user that does not 145 | [have a usable password](https://docs.djangoproject.com/en/2.2/ref/contrib/auth/#django.contrib.auth.models.User.has_usable_password) (Default: True) 146 | 147 | ## Custom Email Lookup 148 | 149 | By default, `email` lookup is used to find the user instance. You can change that by adding 150 | ```python 151 | DJANGO_REST_LOOKUP_FIELD = 'custom_email_field' 152 | ``` 153 | into Django settings.py file. 154 | 155 | ## Custom Remote IP Address and User Agent Header Lookup 156 | 157 | If your setup demands that the IP adress of the user is in another header (e.g., 'X-Forwarded-For'), you can configure that (using Django Request Headers): 158 | 159 | ```python 160 | DJANGO_REST_PASSWORDRESET_IP_ADDRESS_HEADER = 'HTTP_X_FORWARDED_FOR' 161 | ``` 162 | 163 | The same is true for the user agent: 164 | 165 | ```python 166 | DJANGO_REST_PASSWORDRESET_HTTP_USER_AGENT_HEADER = 'HTTP_USER_AGENT' 167 | ``` 168 | 169 | ## Custom Token Generator 170 | 171 | By default, a random string token of length 10 to 50 is generated using the ``RandomStringTokenGenerator`` class. 172 | This library offers a possibility to configure the params of ``RandomStringTokenGenerator`` as well as switch to 173 | another token generator, e.g. ``RandomNumberTokenGenerator``. You can also generate your own token generator class. 174 | 175 | You can change that by adding 176 | ```python 177 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 178 | "CLASS": ..., 179 | "OPTIONS": {...} 180 | } 181 | ``` 182 | into Django settings.py file. 183 | 184 | 185 | ### RandomStringTokenGenerator 186 | This is the default configuration. 187 | ```python 188 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 189 | "CLASS": "django_rest_passwordreset.tokens.RandomStringTokenGenerator" 190 | } 191 | ``` 192 | 193 | You can configure the length as follows: 194 | ```python 195 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 196 | "CLASS": "django_rest_passwordreset.tokens.RandomStringTokenGenerator", 197 | "OPTIONS": { 198 | "min_length": 20, 199 | "max_length": 30 200 | } 201 | } 202 | ``` 203 | 204 | It uses `os.urandom()` to generate a good random string. 205 | 206 | 207 | ### RandomNumberTokenGenerator 208 | ```python 209 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 210 | "CLASS": "django_rest_passwordreset.tokens.RandomNumberTokenGenerator" 211 | } 212 | ``` 213 | 214 | You can configure the minimum and maximum number as follows: 215 | ```python 216 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 217 | "CLASS": "django_rest_passwordreset.tokens.RandomNumberTokenGenerator", 218 | "OPTIONS": { 219 | "min_number": 1500, 220 | "max_number": 9999 221 | } 222 | } 223 | ``` 224 | 225 | It uses `random.SystemRandom().randint()` to generate a good random number. 226 | 227 | 228 | ### Write your own Token Generator 229 | 230 | Please see [token_configuration/django_rest_passwordreset/tokens.py](token_configuration/django_rest_passwordreset/tokens.py) for example implementation of number and string token generator. 231 | 232 | The basic idea is to create a new class that inherits from BaseTokenGenerator, takes arbitrary arguments (`args` and `kwargs`) 233 | in the ``__init__`` function as well as implementing a `generate_token` function. 234 | 235 | ```python 236 | from django_rest_passwordreset.tokens import BaseTokenGenerator 237 | 238 | 239 | class RandomStringTokenGenerator(BaseTokenGenerator): 240 | """ 241 | Generates a random string with min and max length using os.urandom and binascii.hexlify 242 | """ 243 | 244 | def __init__(self, min_length=10, max_length=50, *args, **kwargs): 245 | self.min_length = min_length 246 | self.max_length = max_length 247 | 248 | def generate_token(self, *args, **kwargs): 249 | """ generates a pseudo random code using os.urandom and binascii.hexlify """ 250 | # determine the length based on min_length and max_length 251 | length = random.randint(self.min_length, self.max_length) 252 | 253 | # generate the token using os.urandom and hexlify 254 | return binascii.hexlify( 255 | os.urandom(self.max_length) 256 | ).decode()[0:length] 257 | ``` 258 | 259 | 260 | ### Throttling 261 | 262 | The endpoint to request a reset password token provides throttling. 263 | Per default the throttling rate is `3/day` per IP address. 264 | 265 | The throttling rate can be customized using the `REST_FRAMEWORK` setting and the scope `"django-rest-passwordreset-request-token"`: 266 | 267 | ``` 268 | REST_FRAMEWORK = {"DEFAULT_THROTTLE_RATES": {"django-rest-passwordreset-request-token": "5/hour"}} 269 | ``` 270 | 271 | See also: https://www.django-rest-framework.org/api-guide/throttling/#setting-the-throttling-policy 272 | 273 | 274 | ## Compatibility Matrix 275 | 276 | This library should be compatible with the latest Django and Django Rest Framework Versions. For reference, here is 277 | a matrix showing the guaranteed and tested compatibility. 278 | 279 | django-rest-passwordreset Version | Django Versions | Django Rest Framework Versions | Python | 280 | --------------------------------- |---------------------| ------------------------------ | ------ | 281 | 0.9.7 | 1.8, 1.11, 2.0, 2.1 | 3.6 - 3.9 | 2.7 282 | 1.0 | 1.11, 2.0, 2.2 | 3.6 - 3.9 | 2.7 283 | 1.1 | 1.11, 2.2 | 3.6 - 3.9 | 2.7 284 | 1.2 | 2.2, 3.0, 3.1 | 3.10, 3.11 | 3.5 - 3.8 285 | 1.3 | 3.2, 4.0, 4.1 | 3.12, 3.13, 3.14 | 3.7 - 3.10 286 | 1.4 | 3.2, 4.2, 5.0 | 3.13, 3.14 | 3.8 - 3.12 287 | 1.5 | 4.2, 5.0, 5.1 | 3.15 | 3.9 - 3.13 288 | 289 | ## Documentation / Browsable API 290 | 291 | This package supports the [DRF auto-generated documentation](https://www.django-rest-framework.org/topics/documenting-your-api/) (via `coreapi`) as well as the [DRF browsable API](https://www.django-rest-framework.org/topics/browsable-api/). 292 | 293 | To add the endpoints to the browsable API, you can use a helper function in your `urls.py` file: 294 | ```python 295 | from rest_framework.routers import DefaultRouter 296 | from django_rest_passwordreset.urls import add_reset_password_urls_to_router 297 | 298 | router = DefaultRouter() 299 | add_reset_password_urls_to_router(router, base_path='api/auth/passwordreset') 300 | ``` 301 | 302 | Alternatively you can import the ViewSets manually and customize the routes for your setup: 303 | ```python 304 | from rest_framework.routers import DefaultRouter 305 | from django_rest_passwordreset.views import ResetPasswordValidateTokenViewSet, ResetPasswordConfirmViewSet, \ 306 | ResetPasswordRequestTokenViewSet 307 | 308 | router = DefaultRouter() 309 | router.register( 310 | r'api/auth/passwordreset/validate_token', 311 | ResetPasswordValidateTokenViewSet, 312 | basename='reset-password-validate' 313 | ) 314 | router.register( 315 | r'api/auth/passwordreset/confirm', 316 | ResetPasswordConfirmViewSet, 317 | basename='reset-password-confirm' 318 | ) 319 | router.register( 320 | r'api/auth/passwordreset/', 321 | ResetPasswordRequestTokenViewSet, 322 | basename='reset-password-request' 323 | ) 324 | ``` 325 | 326 | ![drf_browsable_email_validation](docs/browsable_api_email_validation.png "Browsable API E-Mail Validation") 327 | 328 | ![drf_browsable_password_validation](docs/browsable_api_password_validation.png "Browsable API E-Mail Validation") 329 | 330 | ![coreapi_docs](docs/coreapi_docs.png "Core API Docs") 331 | 332 | 333 | ## Known Issues / FAQ 334 | 335 | ### Django 2.1 Migrations - Multiple Primary keys for table ... 336 | Django 2.1 introduced a breaking change for migrations (see [Django Issue #29790](https://code.djangoproject.com/ticket/29790)). We therefore had to rewrite the migration [0002_pk_migration.py](django_rest_passwordreset/migrations/0002_pk_migration.py) such that it covers Django versions before (`<`) 2.1 and later (`>=`) 2.1. 337 | 338 | Some information is written down in Issue #8. 339 | 340 | ### The `reset_password_token_created` signal is not fired 341 | You need to make sure that the code with `@receiver(reset_password_token_created)` is executed by the python interpreter. To ensure this, you have two options: 342 | 343 | 1. Put the code at a place that is automatically loaded by Django (e.g., models.py, views.py), or 344 | 345 | 2. Import the file that contains the signal within your app.py `ready` function: 346 | 347 | *some_app/signals.py* 348 | ```python 349 | from django.core.mail import EmailMultiAlternatives 350 | from django.dispatch import receiver 351 | from django.template.loader import render_to_string 352 | from django.urls import reverse 353 | 354 | from django_rest_passwordreset.signals import reset_password_token_created 355 | 356 | 357 | @receiver(reset_password_token_created) 358 | def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): 359 | # ... 360 | ``` 361 | 362 | *some_app/app.py* 363 | ```python 364 | from django.apps import AppConfig 365 | 366 | class SomeAppConfig(AppConfig): 367 | name = 'your_django_project.some_app' 368 | verbose_name = 'Some App' 369 | 370 | def ready(self): 371 | import your_django_project.some_app.signals # noqa 372 | ``` 373 | 374 | *some_app/__init__.py* 375 | ```python 376 | default_app_config = 'your_django_project.some_app.SomeAppConfig' 377 | ``` 378 | 379 | ### MongoDB not working 380 | 381 | Apparently, the following piece of code in the Django Model prevents MongodB from working: 382 | 383 | ```python 384 | id = models.AutoField( 385 | primary_key=True 386 | ) 387 | ``` 388 | 389 | See issue #49 for details. 390 | 391 | ## Contributions 392 | 393 | This library tries to follow the unix philosophy of "do one thing and do it well" (which is providing a basic password reset endpoint for Django Rest Framework). Contributions are welcome in the form of pull requests and issues! If you create a pull request, please make sure that you are not introducing breaking changes. 394 | 395 | ## Tests 396 | 397 | See folder [tests/](tests/). Basically, all endpoints are covered with multiple 398 | unit tests. 399 | 400 | Follow below instructions to run the tests. 401 | You may exchange the installed Django and DRF versions according to your requirements. 402 | :warning: Depending on your local environment settings you might need to explicitly call `python3` instead of `python`. 403 | ```bash 404 | # install dependencies 405 | python -m pip install --upgrade pip 406 | pip install -r tests/requirements.txt 407 | 408 | # setup environment 409 | pip install -e . 410 | 411 | # run tests 412 | cd tests && python manage.py test 413 | ``` 414 | 415 | ## Release on PyPi 416 | 417 | To release this package on pypi, the following steps are used: 418 | 419 | ```bash 420 | rm -rf dist/ build/ 421 | python setup.py sdist 422 | twine upload dist/* 423 | ``` 424 | -------------------------------------------------------------------------------- /django_rest_passwordreset/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/django_rest_passwordreset/__init__.py -------------------------------------------------------------------------------- /django_rest_passwordreset/admin.py: -------------------------------------------------------------------------------- 1 | """ contains basic admin views for MultiToken """ 2 | from django.contrib import admin 3 | from django_rest_passwordreset.models import ResetPasswordToken 4 | 5 | 6 | @admin.register(ResetPasswordToken) 7 | class ResetPasswordTokenAdmin(admin.ModelAdmin): 8 | list_display = ('user', 'key', 'created_at', 'ip_address', 'user_agent') 9 | -------------------------------------------------------------------------------- /django_rest_passwordreset/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/django_rest_passwordreset/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_rest_passwordreset/locale/pt_BR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Translation to Brazilian Portuguese. 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # Silas Vasconcelos , 2022. 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2022-05-05 01:23-0300\n" 10 | "PO-Revision-Date: 2022-05-05 01:41-0300\n" 11 | "Last-Translator: \n" 12 | "Language-Team: \n" 13 | "Language: pt_BR\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 18 | "X-Generator: Poedit 3.0.1\n" 19 | 20 | #: django_rest_passwordreset/models.py:28 21 | msgid "Password Reset Token" 22 | msgstr "Token de redefinição de senha" 23 | 24 | #: django_rest_passwordreset/models.py:29 25 | msgid "Password Reset Tokens" 26 | msgstr "Tokens de redefinição de senha" 27 | 28 | #: django_rest_passwordreset/models.py:44 29 | msgid "The User which is associated to this password reset token" 30 | msgstr "O usuário que está associado a este token de redefinição de senha" 31 | 32 | #: django_rest_passwordreset/models.py:49 33 | msgid "When was this token generated" 34 | msgstr "Quando este token foi gerado" 35 | 36 | #: django_rest_passwordreset/models.py:54 37 | msgid "Key" 38 | msgstr "Chave" 39 | 40 | #: django_rest_passwordreset/models.py:61 41 | msgid "The IP address of this session" 42 | msgstr "O endereço IP desta sessão" 43 | 44 | #: django_rest_passwordreset/models.py:68 45 | msgid "HTTP User Agent" 46 | msgstr "O navegador do usuário" 47 | 48 | #: django_rest_passwordreset/serializers.py:36 49 | msgid "The OTP password entered is not valid. Please check and try again." 50 | msgstr "" 51 | "O Token de validação não é válido. Por favor verifique e tente " 52 | "novamente." 53 | 54 | #: django_rest_passwordreset/serializers.py:45 55 | msgid "The token has expired" 56 | msgstr "O token expirou" 57 | 58 | #: django_rest_passwordreset/serializers.py:50 59 | msgid "Password" 60 | msgstr "Senha" 61 | 62 | #: django_rest_passwordreset/views.py:149 63 | msgid "" 64 | "We couldn't find an account associated with that email. Please try a " 65 | "different e-mail address." 66 | msgstr "" 67 | "Não foi possível encontrar uma conta associada a esse e-mail. Tente um " 68 | "endereço de e-mail diferente." 69 | -------------------------------------------------------------------------------- /django_rest_passwordreset/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/django_rest_passwordreset/management/__init__.py -------------------------------------------------------------------------------- /django_rest_passwordreset/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/django_rest_passwordreset/management/commands/__init__.py -------------------------------------------------------------------------------- /django_rest_passwordreset/management/commands/clearresetpasswodtokens.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.utils import timezone 3 | import datetime 4 | 5 | from django_rest_passwordreset.models import clear_expired, get_password_reset_token_expiry_time 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Can be run as a cronjob or directly to clean out expired tokens" 10 | 11 | def handle(self, *args, **options): 12 | # datetime.now minus expiry hours 13 | now_minus_expiry_time = timezone.now() - datetime.timedelta(hours=get_password_reset_token_expiry_time()) 14 | clear_expired(now_minus_expiry_time) 15 | -------------------------------------------------------------------------------- /django_rest_passwordreset/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-13 17:53 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='ResetPasswordToken', 21 | fields=[ 22 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='When was this token generated')), 23 | ('key', models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='Key')), 24 | ('ip_address', models.GenericIPAddressField(default='127.0.0.1', verbose_name='The IP address of this session')), 25 | ('user_agent', models.CharField(default='', max_length=256, verbose_name='HTTP User Agent')), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL, verbose_name='The User which is associated to this password reset token')), 27 | ], 28 | options={ 29 | 'verbose_name_plural': 'Password Reset Tokens', 30 | 'verbose_name': 'Password Reset Token', 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /django_rest_passwordreset/migrations/0002_pk_migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | def populate_auto_incrementing_pk_field(apps, schema_editor): 11 | ResetPasswordToken = apps.get_model('django_rest_passwordreset', 'ResetPasswordToken') 12 | 13 | # Generate values for the new id column 14 | for i, o in enumerate(ResetPasswordToken.objects.all()): 15 | o.id = i + 1 16 | o.save() 17 | 18 | 19 | def get_migrations_for_django_before_21(): 20 | return [ 21 | # add a new id field (without primary key information) 22 | migrations.AddField( 23 | model_name='resetpasswordtoken', 24 | name='id', 25 | field=models.IntegerField(null=True), 26 | preserve_default=True, 27 | ), 28 | # fill the new pk field 29 | migrations.RunPython( 30 | populate_auto_incrementing_pk_field, 31 | migrations.RunPython.noop 32 | ), 33 | # add primary key information to id field 34 | migrations.AlterField( 35 | model_name='resetpasswordtoken', 36 | name='id', 37 | field=models.AutoField(primary_key=True, serialize=False) 38 | ), 39 | # remove primary key information from 'key' field 40 | migrations.AlterField( 41 | model_name='resetpasswordtoken', 42 | name='key', 43 | field=models.CharField(db_index=True, max_length=64, unique=True, verbose_name='Key'), 44 | ), 45 | ] 46 | 47 | 48 | def get_migrations_for_django_21_and_newer(): 49 | return [ 50 | # remove primary key information from 'key' field 51 | migrations.AlterField( 52 | model_name='resetpasswordtoken', 53 | name='key', 54 | field=models.CharField(db_index=True, primary_key=False, max_length=64, unique=True, verbose_name='Key'), 55 | ), 56 | # add a new id field 57 | migrations.AddField( 58 | model_name='resetpasswordtoken', 59 | name='id', 60 | field=models.AutoField(primary_key=True, serialize=False), 61 | preserve_default=False, 62 | ), 63 | migrations.RunPython( 64 | populate_auto_incrementing_pk_field, 65 | migrations.RunPython.noop 66 | ), 67 | ] 68 | 69 | 70 | def get_migrations_based_on_django_version(): 71 | """ 72 | Returns the proper migrations based on the current Django Version 73 | 74 | Unfortunatley, Django 2.1 introduced a breaking change with switching PK from one model to another, see 75 | https://code.djangoproject.com/ticket/29790 76 | :return: 77 | """ 78 | django_version = django.VERSION 79 | 80 | if (django_version[0] >= 2 and django_version[1] >= 1) or django_version[0] >= 3: 81 | return get_migrations_for_django_21_and_newer() 82 | 83 | return get_migrations_for_django_before_21() 84 | 85 | 86 | class Migration(migrations.Migration): 87 | 88 | dependencies = [ 89 | ('django_rest_passwordreset', '0001_initial',), 90 | ] 91 | 92 | operations = get_migrations_based_on_django_version() 93 | -------------------------------------------------------------------------------- /django_rest_passwordreset/migrations/0003_allow_blank_and_null_fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.20 on 2019-08-02 12:39 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 | ('django_rest_passwordreset', '0002_pk_migration'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='resetpasswordtoken', 17 | name='ip_address', 18 | field=models.GenericIPAddressField(blank=True, default='', null=True, verbose_name='The IP address of this session'), 19 | ), 20 | migrations.AlterField( 21 | model_name='resetpasswordtoken', 22 | name='user_agent', 23 | field=models.CharField(blank=True, default='', max_length=256, verbose_name='HTTP User Agent'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_rest_passwordreset/migrations/0004_alter_resetpasswordtoken_user_agent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.22 on 2023-10-13 12:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_rest_passwordreset', '0003_allow_blank_and_null_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='resetpasswordtoken', 15 | name='user_agent', 16 | field=models.CharField(blank=True, default='', max_length=512, verbose_name='HTTP User Agent'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_rest_passwordreset/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/django_rest_passwordreset/migrations/__init__.py -------------------------------------------------------------------------------- /django_rest_passwordreset/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | from django.contrib.auth import get_user_model 5 | 6 | from django_rest_passwordreset.tokens import get_token_generator 7 | 8 | # Prior to Django 1.5, the AUTH_USER_MODEL setting does not exist. 9 | # Note that we don't perform this code in the compat module due to 10 | # bug report #1297 11 | # See: https://github.com/tomchristie/django-rest-framework/issues/1297 12 | 13 | AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 14 | 15 | # get the token generator class 16 | TOKEN_GENERATOR_CLASS = get_token_generator() 17 | 18 | __all__ = [ 19 | 'ResetPasswordToken', 20 | 'get_password_reset_token_expiry_time', 21 | 'get_password_reset_lookup_field', 22 | 'clear_expired', 23 | ] 24 | 25 | 26 | class ResetPasswordToken(models.Model): 27 | class Meta: 28 | verbose_name = _("Password Reset Token") 29 | verbose_name_plural = _("Password Reset Tokens") 30 | 31 | @staticmethod 32 | def generate_key(): 33 | """ generates a pseudo random code using os.urandom and binascii.hexlify """ 34 | return TOKEN_GENERATOR_CLASS.generate_token() 35 | 36 | id = models.AutoField( 37 | primary_key=True 38 | ) 39 | 40 | user = models.ForeignKey( 41 | AUTH_USER_MODEL, 42 | related_name='password_reset_tokens', 43 | on_delete=models.CASCADE, 44 | verbose_name=_("The User which is associated to this password reset token") 45 | ) 46 | 47 | created_at = models.DateTimeField( 48 | auto_now_add=True, 49 | verbose_name=_("When was this token generated") 50 | ) 51 | 52 | # Key field, though it is not the primary key of the model 53 | key = models.CharField( 54 | _("Key"), 55 | max_length=64, 56 | db_index=True, 57 | unique=True 58 | ) 59 | 60 | ip_address = models.GenericIPAddressField( 61 | _("The IP address of this session"), 62 | default="", 63 | blank=True, 64 | null=True, 65 | ) 66 | 67 | user_agent = models.CharField( 68 | max_length=512, 69 | verbose_name=_("HTTP User Agent"), 70 | default="", 71 | blank=True, 72 | ) 73 | 74 | def save(self, *args, **kwargs): 75 | if not self.key: 76 | self.key = self.generate_key() 77 | return super(ResetPasswordToken, self).save(*args, **kwargs) 78 | 79 | def __str__(self): 80 | return "Password reset token for user {user}".format(user=self.user) 81 | 82 | 83 | def get_password_reset_token_expiry_time(): 84 | """ 85 | Returns the password reset token expirty time in hours (default: 24) 86 | Set Django SETTINGS.DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME to overwrite this time 87 | :return: expiry time 88 | """ 89 | # get token validation time 90 | return getattr(settings, 'DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME', 24) 91 | 92 | 93 | def get_password_reset_lookup_field(): 94 | """ 95 | Returns the password reset lookup field (default: email) 96 | Set Django SETTINGS.DJANGO_REST_LOOKUP_FIELD to overwrite this time 97 | :return: lookup field 98 | """ 99 | return getattr(settings, 'DJANGO_REST_LOOKUP_FIELD', 'email') 100 | 101 | 102 | def clear_expired(expiry_time): 103 | """ 104 | Remove all expired tokens 105 | :param expiry_time: Token expiration time 106 | """ 107 | ResetPasswordToken.objects.filter(created_at__lte=expiry_time).delete() 108 | 109 | def eligible_for_reset(self): 110 | if not self.is_active: 111 | # if the user is active we dont bother checking 112 | return False 113 | 114 | if getattr(settings, 'DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD', True): 115 | # if we require a usable password then return the result of has_usable_password() 116 | return self.has_usable_password() 117 | else: 118 | # otherwise return True because we dont care about the result of has_usable_password() 119 | return True 120 | 121 | # add eligible_for_reset to the user class 122 | UserModel = get_user_model() 123 | UserModel.add_to_class("eligible_for_reset", eligible_for_reset) 124 | -------------------------------------------------------------------------------- /django_rest_passwordreset/serializers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.http import Http404 5 | from django.shortcuts import get_object_or_404 as _get_object_or_404 6 | from django.utils import timezone 7 | from django.utils.translation import gettext_lazy as _ 8 | from rest_framework import serializers 9 | 10 | from django_rest_passwordreset.models import get_password_reset_token_expiry_time 11 | from . import models 12 | 13 | __all__ = [ 14 | 'EmailSerializer', 15 | 'PasswordTokenSerializer', 16 | 'ResetTokenSerializer', 17 | ] 18 | 19 | 20 | class EmailSerializer(serializers.Serializer): 21 | email = serializers.EmailField() 22 | 23 | 24 | class PasswordValidateMixin: 25 | def validate(self, data): 26 | token = data.get('token') 27 | 28 | # get token validation time 29 | password_reset_token_validation_time = get_password_reset_token_expiry_time() 30 | 31 | # find token 32 | try: 33 | reset_password_token = _get_object_or_404(models.ResetPasswordToken, key=token) 34 | except (TypeError, ValueError, ValidationError, Http404, 35 | models.ResetPasswordToken.DoesNotExist): 36 | raise Http404(_("The OTP password entered is not valid. Please check and try again.")) 37 | 38 | # check expiry date 39 | expiry_date = reset_password_token.created_at + timedelta( 40 | hours=password_reset_token_validation_time) 41 | 42 | if timezone.now() > expiry_date: 43 | # delete expired token 44 | reset_password_token.delete() 45 | raise Http404(_("The token has expired")) 46 | return data 47 | 48 | 49 | class PasswordTokenSerializer(PasswordValidateMixin, serializers.Serializer): 50 | password = serializers.CharField(label=_("Password"), style={'input_type': 'password'}) 51 | token = serializers.CharField() 52 | 53 | 54 | class ResetTokenSerializer(PasswordValidateMixin, serializers.Serializer): 55 | token = serializers.CharField() 56 | -------------------------------------------------------------------------------- /django_rest_passwordreset/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | __all__ = [ 4 | 'reset_password_token_created', 5 | 'pre_password_reset', 6 | 'post_password_reset', 7 | ] 8 | 9 | """ 10 | Signal arguments: instance, reset_password_token 11 | """ 12 | reset_password_token_created = Signal() 13 | 14 | """ 15 | Signal arguments: user, reset_password_token 16 | """ 17 | pre_password_reset = Signal() 18 | 19 | """ 20 | Signal arguments: user, reset_password_token 21 | """ 22 | post_password_reset = Signal() 23 | -------------------------------------------------------------------------------- /django_rest_passwordreset/throttling.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from rest_framework.throttling import UserRateThrottle 3 | 4 | 5 | __all__ = ("ResetPasswordRequestTokenThrottle",) 6 | 7 | 8 | class ResetPasswordRequestTokenThrottle(UserRateThrottle): 9 | scope = "django-rest-passwordreset-request-token" 10 | default_rate = "3/day" 11 | 12 | def get_rate(self): 13 | try: 14 | return super().get_rate() 15 | except ImproperlyConfigured: 16 | return self.default_rate 17 | -------------------------------------------------------------------------------- /django_rest_passwordreset/tokens.py: -------------------------------------------------------------------------------- 1 | import os 2 | import binascii 3 | import random 4 | from importlib import import_module 5 | 6 | from django.conf import settings 7 | 8 | 9 | def get_token_generator(): 10 | """ 11 | Returns the token generator class based on the configuration in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG.CLASS and 12 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG.OPTIONS 13 | :return: 14 | """ 15 | # by default, we are using the String Token Generator 16 | token_class = RandomStringTokenGenerator 17 | options = {} 18 | 19 | # get the settings object 20 | DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = getattr(settings, 'DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG', None) 21 | 22 | # check if something is in the settings object, and work with it 23 | if DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG: 24 | if "CLASS" in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG: 25 | class_path_name = DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG["CLASS"] 26 | module_name, class_name = class_path_name.rsplit('.', 1) 27 | 28 | mod = import_module(module_name) 29 | token_class = getattr(mod, class_name) 30 | 31 | if "OPTIONS" in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG: 32 | options = DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG["OPTIONS"] 33 | 34 | # initialize the token class and pass options 35 | return token_class(**options) 36 | 37 | 38 | class BaseTokenGenerator: 39 | """ 40 | Base Class for the Token Generators 41 | 42 | - Can take arbitrary args/kwargs and work with those 43 | - Needs to implement the "generate_token" Method 44 | """ 45 | def __init__(self, *args, **kwargs): 46 | pass 47 | 48 | def generate_token(self, *args, **kwargs): 49 | raise NotImplementedError 50 | 51 | 52 | class RandomStringTokenGenerator(BaseTokenGenerator): 53 | """ 54 | Generates a random string with min and max length using os.urandom and binascii.hexlify 55 | """ 56 | 57 | def __init__(self, min_length=10, max_length=50, *args, **kwargs): 58 | self.min_length = min_length 59 | self.max_length = max_length 60 | 61 | def generate_token(self, *args, **kwargs): 62 | """ generates a pseudo random code using os.urandom and binascii.hexlify """ 63 | # determine the length based on min_length and max_length 64 | length = random.randint(self.min_length, self.max_length) 65 | 66 | # generate the token using os.urandom and hexlify 67 | return binascii.hexlify( 68 | os.urandom(self.max_length) 69 | ).decode()[0:length] 70 | 71 | 72 | class RandomNumberTokenGenerator(BaseTokenGenerator): 73 | """ 74 | Generates a random number using random.SystemRandom() (which uses urandom in the background) 75 | """ 76 | def __init__(self, min_number=10000, max_number=99999, *args, **kwargs): 77 | self.min_number = min_number 78 | self.max_number = max_number 79 | 80 | def generate_token(self, *args, **kwargs): 81 | r = random.SystemRandom() 82 | 83 | # generate a random number between min_number and max_number 84 | return str(r.randint(self.min_number, self.max_number)) 85 | -------------------------------------------------------------------------------- /django_rest_passwordreset/urls.py: -------------------------------------------------------------------------------- 1 | """ URL Configuration for core auth """ 2 | from django.urls import path 3 | 4 | from django_rest_passwordreset.views import ResetPasswordConfirmViewSet, ResetPasswordRequestTokenViewSet, \ 5 | ResetPasswordValidateTokenViewSet, reset_password_confirm, reset_password_request_token, \ 6 | reset_password_validate_token 7 | 8 | app_name = 'password_reset' 9 | 10 | 11 | def add_reset_password_urls_to_router(router, base_path=''): 12 | router.register( 13 | base_path + "/validate_token", 14 | ResetPasswordValidateTokenViewSet, 15 | basename='reset-password-validate' 16 | ) 17 | router.register( 18 | base_path + "/confirm", 19 | ResetPasswordConfirmViewSet, 20 | basename='reset-password-confirm' 21 | ) 22 | router.register( 23 | base_path, 24 | ResetPasswordRequestTokenViewSet, 25 | basename='reset-password-request' 26 | ) 27 | 28 | 29 | urlpatterns = [ 30 | path("validate_token/", reset_password_validate_token, name="reset-password-validate"), 31 | path("confirm/", reset_password_confirm, name="reset-password-confirm"), 32 | path("", reset_password_request_token, name="reset-password-request"), 33 | ] 34 | -------------------------------------------------------------------------------- /django_rest_passwordreset/views.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | from datetime import timedelta 3 | 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.password_validation import validate_password, get_password_validators 7 | from django.core.exceptions import ValidationError 8 | from django.utils import timezone 9 | from django.utils.translation import gettext_lazy as _ 10 | from rest_framework import exceptions 11 | from rest_framework.generics import GenericAPIView 12 | from rest_framework.response import Response 13 | from rest_framework.viewsets import GenericViewSet 14 | 15 | from django_rest_passwordreset.models import ResetPasswordToken, clear_expired, get_password_reset_token_expiry_time, \ 16 | get_password_reset_lookup_field 17 | from django_rest_passwordreset.serializers import EmailSerializer, PasswordTokenSerializer, ResetTokenSerializer 18 | from django_rest_passwordreset.signals import reset_password_token_created, pre_password_reset, post_password_reset 19 | from django_rest_passwordreset.throttling import ResetPasswordRequestTokenThrottle 20 | 21 | User = get_user_model() 22 | 23 | __all__ = [ 24 | 'ResetPasswordValidateToken', 25 | 'ResetPasswordConfirm', 26 | 'ResetPasswordRequestToken', 27 | 'reset_password_validate_token', 28 | 'reset_password_confirm', 29 | 'reset_password_request_token', 30 | 'ResetPasswordValidateTokenViewSet', 31 | 'ResetPasswordConfirmViewSet', 32 | 'ResetPasswordRequestTokenViewSet' 33 | ] 34 | 35 | HTTP_USER_AGENT_HEADER = getattr(settings, 'DJANGO_REST_PASSWORDRESET_HTTP_USER_AGENT_HEADER', 'HTTP_USER_AGENT') 36 | HTTP_IP_ADDRESS_HEADER = getattr(settings, 'DJANGO_REST_PASSWORDRESET_IP_ADDRESS_HEADER', 'REMOTE_ADDR') 37 | 38 | 39 | def _unicode_ci_compare(s1, s2): 40 | """ 41 | Perform case-insensitive comparison of two identifiers, using the 42 | recommended algorithm from Unicode Technical Report 36, section 43 | 2.11.2(B)(2). 44 | """ 45 | normalized1 = unicodedata.normalize('NFKC', s1) 46 | normalized2 = unicodedata.normalize('NFKC', s2) 47 | 48 | return normalized1.casefold() == normalized2.casefold() 49 | 50 | 51 | def clear_expired_tokens(): 52 | """ 53 | Delete all existing expired tokens 54 | """ 55 | password_reset_token_validation_time = get_password_reset_token_expiry_time() 56 | 57 | # datetime.now minus expiry hours 58 | now_minus_expiry_time = timezone.now() - timedelta(hours=password_reset_token_validation_time) 59 | 60 | # delete all tokens where created_at < now - 24 hours 61 | clear_expired(now_minus_expiry_time) 62 | 63 | 64 | def generate_token_for_email(email, user_agent='', ip_address=''): 65 | # find a user by email address (case-insensitive search) 66 | users = User.objects.filter(**{'{}__iexact'.format(get_password_reset_lookup_field()): email}) 67 | 68 | active_user_found = False 69 | 70 | # iterate over all users and check if there is any user that is active 71 | # also check whether the password can be changed (is usable), as there could be users that are not allowed 72 | # to change their password (e.g., LDAP user) 73 | for user in users: 74 | if user.eligible_for_reset(): 75 | active_user_found = True 76 | break 77 | 78 | # No active user found, raise a ValidationError 79 | # but not if DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE == True, in that case we return None 80 | if not active_user_found: 81 | if not getattr(settings, 'DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE', False): 82 | raise exceptions.ValidationError({ 83 | 'email': [_( 84 | "We couldn't find an account associated with that email. Please try a different e-mail address.")], 85 | }) 86 | return None 87 | 88 | # last but not least: iterate over all users that are active and can change their password 89 | # and create a Reset Password Token and send a signal with the created token 90 | for user in users: 91 | if user.eligible_for_reset() and _unicode_ci_compare(email, getattr(user, get_password_reset_lookup_field())): 92 | password_reset_tokens = user.password_reset_tokens.all() 93 | 94 | # check if the user already has a token 95 | if password_reset_tokens.count(): 96 | # yes, already has a token, re-use this token 97 | return password_reset_tokens.first() 98 | 99 | # no token exists, generate a new token 100 | return ResetPasswordToken.objects.create( 101 | user=user, 102 | user_agent=user_agent, 103 | ip_address=ip_address.split(",")[0], 104 | ) 105 | 106 | 107 | class ResetPasswordValidateToken(GenericAPIView): 108 | """ 109 | An Api View which provides a method to verify that a token is valid 110 | """ 111 | throttle_classes = () 112 | permission_classes = () 113 | serializer_class = ResetTokenSerializer 114 | authentication_classes = () 115 | 116 | def post(self, request, *args, **kwargs): 117 | serializer = self.serializer_class(data=request.data) 118 | serializer.is_valid(raise_exception=True) 119 | 120 | return_data = {'status': 'OK'} 121 | 122 | if getattr(settings, 'DJANGO_REST_PASSWORDRESET_USER_DETAILS_ON_VALIDATION', False): 123 | token = ResetPasswordToken.objects.get(key=serializer.validated_data['token']) 124 | 125 | return_data['username'] = token.user.username 126 | return_data['email'] = token.user.email 127 | 128 | return Response(return_data) 129 | 130 | 131 | class ResetPasswordConfirm(GenericAPIView): 132 | """ 133 | An Api View which provides a method to reset a password based on a unique token 134 | """ 135 | throttle_classes = () 136 | permission_classes = () 137 | serializer_class = PasswordTokenSerializer 138 | authentication_classes = () 139 | 140 | def post(self, request, *args, **kwargs): 141 | serializer = self.serializer_class(data=request.data) 142 | serializer.is_valid(raise_exception=True) 143 | password = serializer.validated_data['password'] 144 | token = serializer.validated_data['token'] 145 | 146 | # find token 147 | reset_password_token = ResetPasswordToken.objects.filter(key=token).first() 148 | 149 | # change users password (if we got to this code it means that the user is_active) 150 | if reset_password_token.user.eligible_for_reset(): 151 | pre_password_reset.send( 152 | sender=self.__class__, 153 | user=reset_password_token.user, 154 | reset_password_token=reset_password_token, 155 | ) 156 | try: 157 | # validate the password against existing validators 158 | validate_password( 159 | password, 160 | user=reset_password_token.user, 161 | password_validators=get_password_validators(settings.AUTH_PASSWORD_VALIDATORS) 162 | ) 163 | except ValidationError as e: 164 | # raise a validation error for the serializer 165 | raise exceptions.ValidationError({ 166 | 'password': e.messages 167 | }) 168 | 169 | reset_password_token.user.set_password(password) 170 | reset_password_token.user.save() 171 | post_password_reset.send( 172 | sender=self.__class__, 173 | user=reset_password_token.user, 174 | reset_password_token=reset_password_token, 175 | ) 176 | 177 | # Delete all password reset tokens for this user 178 | ResetPasswordToken.objects.filter(user=reset_password_token.user).delete() 179 | 180 | return Response({'status': 'OK'}) 181 | 182 | 183 | class ResetPasswordRequestToken(GenericAPIView): 184 | """ 185 | An Api View which provides a method to request a password reset token based on an e-mail address 186 | 187 | Sends a signal reset_password_token_created when a reset token was created 188 | """ 189 | throttle_classes = (ResetPasswordRequestTokenThrottle,) 190 | permission_classes = () 191 | serializer_class = EmailSerializer 192 | authentication_classes = () 193 | 194 | def post(self, request, *args, **kwargs): 195 | serializer = self.serializer_class(data=request.data) 196 | serializer.is_valid(raise_exception=True) 197 | 198 | clear_expired_tokens() 199 | token = generate_token_for_email( 200 | email=serializer.validated_data['email'], 201 | user_agent=request.META.get(HTTP_USER_AGENT_HEADER, ''), 202 | ip_address=request.META.get(HTTP_IP_ADDRESS_HEADER, ''), 203 | ) 204 | 205 | if token: 206 | # send a signal that the password token was created 207 | # let whoever receives this signal handle sending the email for the password reset 208 | reset_password_token_created.send( 209 | sender=self.__class__, 210 | instance=self, reset_password_token=token 211 | ) 212 | 213 | return Response({'status': 'OK'}) 214 | 215 | 216 | class ResetPasswordValidateTokenViewSet(ResetPasswordValidateToken, GenericViewSet): 217 | """ 218 | An Api ViewSet which provides a method to verify that a token is valid 219 | """ 220 | 221 | def create(self, request, *args, **kwargs): 222 | return super(ResetPasswordValidateTokenViewSet, self).post(request, *args, **kwargs) 223 | 224 | 225 | class ResetPasswordConfirmViewSet(ResetPasswordConfirm, GenericViewSet): 226 | """ 227 | An Api ViewSet which provides a method to reset a password based on a unique token 228 | """ 229 | 230 | def create(self, request, *args, **kwargs): 231 | return super(ResetPasswordConfirmViewSet, self).post(request, *args, **kwargs) 232 | 233 | 234 | class ResetPasswordRequestTokenViewSet(ResetPasswordRequestToken, GenericViewSet): 235 | """ 236 | An Api ViewSet which provides a method to request a password reset token based on an e-mail address 237 | 238 | Sends a signal reset_password_token_created when a reset token was created 239 | """ 240 | 241 | def create(self, request, *args, **kwargs): 242 | return super(ResetPasswordRequestTokenViewSet, self).post(request, *args, **kwargs) 243 | 244 | 245 | reset_password_validate_token = ResetPasswordValidateToken.as_view() 246 | reset_password_confirm = ResetPasswordConfirm.as_view() 247 | reset_password_request_token = ResetPasswordRequestToken.as_view() 248 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/docs/.gitkeep -------------------------------------------------------------------------------- /docs/browsable_api_email_validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/docs/browsable_api_email_validation.png -------------------------------------------------------------------------------- /docs/browsable_api_password_validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/docs/browsable_api_password_validation.png -------------------------------------------------------------------------------- /docs/coreapi_docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/docs/coreapi_docs.png -------------------------------------------------------------------------------- /locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-02-15 09:18+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:28 21 | msgid "Password Reset Token" 22 | msgstr "" 23 | 24 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:29 25 | msgid "Password Reset Tokens" 26 | msgstr "" 27 | 28 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:44 29 | msgid "The User which is associated to this password reset token" 30 | msgstr "" 31 | 32 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:49 33 | msgid "When was this token generated" 34 | msgstr "" 35 | 36 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:54 37 | msgid "Key" 38 | msgstr "" 39 | 40 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:61 41 | msgid "The IP address of this session" 42 | msgstr "" 43 | 44 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:68 45 | msgid "HTTP User Agent" 46 | msgstr "" 47 | 48 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:36 49 | msgid "The OTP password entered is not valid. Please check and try again." 50 | msgstr "" 51 | 52 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:45 53 | msgid "The token has expired" 54 | msgstr "" 55 | 56 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:50 57 | msgid "Password" 58 | msgstr "" 59 | 60 | #: django-rest-passwordreset/django_rest_passwordreset/views.py:130 61 | msgid "" 62 | "We couldn't find an account associated with that email. Please try a " 63 | "different e-mail address." 64 | msgstr "" 65 | -------------------------------------------------------------------------------- /locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-02-15 09:18+0000\n" 12 | "PO-Revision-Date: 2021-02-15 10:00+0000\n" 13 | "Last-Translator: jorge correa \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:28 21 | msgid "Password Reset Token" 22 | msgstr "Clave para reestablecer la Contraseña" 23 | 24 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:29 25 | msgid "Password Reset Tokens" 26 | msgstr "Claves para reestablecer la contraseña" 27 | 28 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:44 29 | msgid "The User which is associated to this password reset token" 30 | msgstr "Usuario asociado a la clave para reestablecer la contraseña" 31 | 32 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:49 33 | msgid "When was this token generated" 34 | msgstr "Cuando fué generada la clave" 35 | 36 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:54 37 | msgid "Key" 38 | msgstr "Clave" 39 | 40 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:61 41 | msgid "The IP address of this session" 42 | msgstr "La dirección IP de esta sesión" 43 | 44 | #: django-rest-passwordreset/django_rest_passwordreset/models.py:68 45 | msgid "HTTP User Agent" 46 | msgstr "Agente de usuario HTTP" 47 | 48 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:36 49 | msgid "The OTP password entered is not valid. Please check and try again." 50 | msgstr "La contraseña OTP introducida no es válida. Por favor compruebe y pruebe otra vez." 51 | 52 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:45 53 | msgid "The token has expired" 54 | msgstr "La clave ha caducado" 55 | 56 | #: django-rest-passwordreset/django_rest_passwordreset/serializers.py:50 57 | msgid "Password" 58 | msgstr "Contraseña" 59 | 60 | #: django-rest-passwordreset/django_rest_passwordreset/views.py:130 61 | msgid "" 62 | "We couldn't find an account associated with that email. Please try a " 63 | "different e-mail address." 64 | msgstr "" 65 | "No pudimos encontrar una cuenta asociada con ese correo electrónico. " 66 | "Por favor Intente con otra dirección de correo electrónico" 67 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | license_file = LICENSE 4 | 5 | 6 | [pycodestyle] 7 | ignore = E402,E722 8 | max-line-length = 120 9 | exclude = *migrations*, *tests*, *settings* 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | setup( 12 | name='django-rest-passwordreset', 13 | version=os.getenv('PACKAGE_VERSION', '0.0.0').replace('refs/tags/', ''), 14 | packages=find_packages(), 15 | include_package_data=True, 16 | license='BSD License', 17 | description='An extension of django rest framework, providing a configurable password reset strategy', 18 | long_description=README, 19 | long_description_content_type='text/markdown', # This is important for README.md in markdown format 20 | url='https://github.com/anexia-it/django-rest-passwordreset', 21 | author='Harald Nezbeda', 22 | author_email='HNezbeda@anexia-it.com', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 4.2', 28 | 'Framework :: Django :: 5.0', 29 | 'Framework :: Django :: 5.1', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: BSD License', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Programming Language :: Python :: 3.10', 37 | 'Programming Language :: Python :: 3.11', 38 | 'Programming Language :: Python :: 3.12', 39 | 'Programming Language :: Python :: 3.13', 40 | 'Topic :: Internet :: WWW/HTTP', 41 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/.gitkeep -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/__init__.py -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=2.2,<3.3 2 | djangorestframework>=3.12.4,<3.13 3 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests2 project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '-ca82+nb!ay6-gq=6t_cps(*9tlp+t#sd%trg3avne!8v4x9#b' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | # include django rest framework 42 | 'rest_framework', 43 | 44 | # include multi token auth 45 | 'django_rest_passwordreset', 46 | 47 | # test app 48 | 'user_id_uuid_testapp', 49 | ] 50 | 51 | MIDDLEWARE = [ 52 | 'django.middleware.security.SecurityMiddleware', 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.middleware.csrf.CsrfViewMiddleware', 56 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 57 | 'django.contrib.messages.middleware.MessageMiddleware', 58 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 59 | ] 60 | 61 | ROOT_URLCONF = 'urls' 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'DIRS': [], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 82 | 83 | DATABASES = { 84 | 'default': { 85 | 'ENGINE': 'django.db.backends.sqlite3', 86 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 106 | }, 107 | ] 108 | 109 | 110 | # Internationalization 111 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 112 | 113 | LANGUAGE_CODE = 'en-us' 114 | 115 | TIME_ZONE = 'UTC' 116 | 117 | USE_I18N = True 118 | 119 | USE_L10N = True 120 | 121 | USE_TZ = True 122 | 123 | 124 | # Static files (CSS, JavaScript, Images) 125 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 126 | 127 | STATIC_URL = '/static/' 128 | 129 | AUTH_USER_MODEL = "user_id_uuid_testapp.User" 130 | 131 | REST_FRAMEWORK = { 132 | "DEFAULT_THROTTLE_RATES": { 133 | "django-rest-passwordreset-request-token": "99999/second", 134 | "django-rest-passwordreset-request-token-test-scope": "1/day", 135 | }, 136 | } 137 | -------------------------------------------------------------------------------- /tests/settings_postgres.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for github action postgres tests. 3 | 4 | """ 5 | 6 | from settings import * 7 | 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": "django.db.backends.postgresql_psycopg2", 11 | "HOST": "localhost", 12 | "PORT": "5432", 13 | "NAME": "postgres", 14 | "USER": "user", 15 | "PASSWORD": "password", 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/test/__init__.py -------------------------------------------------------------------------------- /tests/test/helpers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | User = get_user_model() 4 | 5 | try: 6 | from unittest.mock import patch 7 | except: 8 | # Python 2.7 fallback 9 | from mock import patch 10 | 11 | # try getting reverse from django.urls 12 | try: 13 | # Django 1.10 + 14 | from django.urls import reverse 15 | except: 16 | # Django 1.8 and 1.9 17 | from django.core.urlresolvers import reverse 18 | 19 | 20 | __all__ = [ 21 | "HelperMixin", 22 | "patch" 23 | ] 24 | 25 | 26 | class HelperMixin: 27 | """ 28 | Mixin which encapsulates methods for login, logout, request reset password and reset password confirm 29 | """ 30 | def setUpUrls(self): 31 | """ set up urls by using djangos reverse function """ 32 | self.reset_password_request_url = reverse('password_reset:reset-password-request') 33 | self.reset_password_confirm_url = reverse('password_reset:reset-password-confirm') 34 | self.reset_password_validate_token_url = reverse('password_reset:reset-password-validate') 35 | 36 | def django_check_login(self, username, password): 37 | """ 38 | Checks the django login by querying the user from the database and calling check_password() 39 | :param username: 40 | :param password: 41 | :return: 42 | """ 43 | user = User.objects.filter(username=username).first() 44 | 45 | return user.check_password(password) 46 | 47 | def rest_do_request_reset_token(self, email, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): 48 | """ REST API wrapper for requesting a password reset token """ 49 | data = { 50 | 'email': email 51 | } 52 | 53 | return self.client.post( 54 | self.reset_password_request_url, 55 | data, 56 | format='json', 57 | HTTP_USER_AGENT=HTTP_USER_AGENT, 58 | REMOTE_ADDR=REMOTE_ADDR 59 | ) 60 | 61 | def rest_do_reset_password_with_token(self, token, new_password, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): 62 | """ REST API wrapper for resetting a user's password using a token """ 63 | data = { 64 | 'token': token, 65 | 'password': new_password 66 | } 67 | 68 | return self.client.post( 69 | self.reset_password_confirm_url, 70 | data, 71 | format='json', 72 | HTTP_USER_AGENT=HTTP_USER_AGENT, 73 | REMOTE_ADDR=REMOTE_ADDR 74 | ) 75 | 76 | def rest_do_validate_token(self, token, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): 77 | """ REST API wrapper for validating a token """ 78 | data = { 79 | 'token': token 80 | } 81 | 82 | return self.client.post( 83 | self.reset_password_validate_token_url, 84 | data, 85 | format='json', 86 | HTTP_USER_AGENT=HTTP_USER_AGENT, 87 | REMOTE_ADDR=REMOTE_ADDR 88 | ) 89 | -------------------------------------------------------------------------------- /tests/test/test_auth_test_case.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.test import override_settings 6 | from django.utils import timezone 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | 10 | from django_rest_passwordreset.models import ResetPasswordToken, get_password_reset_token_expiry_time 11 | from django_rest_passwordreset.views import clear_expired_tokens, generate_token_for_email 12 | from tests.test.helpers import HelperMixin, patch 13 | 14 | User = get_user_model() 15 | 16 | 17 | class AuthTestCase(APITestCase, HelperMixin): 18 | """ 19 | Several Test Cases for the Multi Auth Token Django App 20 | """ 21 | 22 | def setUp(self): 23 | self.setUpUrls() 24 | self.user1 = User.objects.create_user("user1", "user1@mail.com", "secret1") 25 | self.user2 = User.objects.create_user("user2", "user2@mail.com", "secret2") 26 | self.user3 = User.objects.create_user("user3@mail.com", "not-that-mail@mail.com", "secret3") 27 | self.user4 = User.objects.create_user("user4", "user4@mail.com") 28 | self.user5 = User.objects.create_user("user5", "uѕer5@mail.com", "secret5") # email contains kyrillic s 29 | 30 | def test_try_reset_password_email_does_not_exist(self): 31 | """ Tests requesting a token for an email that does not exist """ 32 | response = self.rest_do_request_reset_token(email="foobar@doesnotexist.com") 33 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 34 | decoded_response = json.loads(response.content.decode()) 35 | # response should have "email" in it 36 | self.assertTrue("email" in decoded_response) 37 | 38 | def test_unicode_email_reset(self): 39 | response = self.rest_do_request_reset_token(email="uѕer5@mail.com") 40 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 41 | 42 | decoded_response = json.loads(response.content.decode()) 43 | self.assertEqual(decoded_response.get("email")[0], 'Enter a valid email address.') 44 | 45 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 46 | def test_validate_token(self, mock_reset_password_token_created): 47 | """ Tests validate token """ 48 | 49 | # there should be zero tokens 50 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 51 | 52 | response = self.rest_do_request_reset_token(email="user1@mail.com") 53 | self.assertEqual(response.status_code, status.HTTP_200_OK) 54 | # check that the signal was sent once 55 | self.assertTrue(mock_reset_password_token_created.called) 56 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 57 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 58 | self.assertNotEqual(last_reset_password_token.key, "") 59 | 60 | # there should be one token 61 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 62 | # and it should be assigned to user1 63 | self.assertEqual( 64 | ResetPasswordToken.objects.filter(key=last_reset_password_token.key).first().user.username, 65 | "user1" 66 | ) 67 | 68 | # try to validate token 69 | response = self.rest_do_validate_token(last_reset_password_token.key) 70 | self.assertEqual(response.status_code, status.HTTP_200_OK) 71 | 72 | # there should be no user details in the response 73 | self.assertEqual(response.data.get("username"), None) 74 | self.assertEqual(response.data.get("email"), None) 75 | 76 | # there should be one token 77 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 78 | 79 | # try to log in with the old username/password (should work) 80 | self.assertTrue( 81 | self.django_check_login("user1", "secret1"), 82 | msg="User 1 should still be able to login with the old credentials" 83 | ) 84 | 85 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 86 | @override_settings(DJANGO_REST_PASSWORDRESET_USER_DETAILS_ON_VALIDATION=True) 87 | def test_validate_token_with_user_details(self, mock_reset_password_token_created): 88 | """ Tests validate token with user details in the response """ 89 | 90 | # there should be zero tokens 91 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 92 | 93 | response = self.rest_do_request_reset_token(email="user1@mail.com") 94 | self.assertEqual(response.status_code, status.HTTP_200_OK) 95 | 96 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 97 | self.assertNotEqual(last_reset_password_token.key, "") 98 | 99 | # there should be one token 100 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 101 | 102 | # try to validate token 103 | response = self.rest_do_validate_token(last_reset_password_token.key) 104 | self.assertEqual(response.status_code, status.HTTP_200_OK) 105 | 106 | # there should be user details in the response 107 | self.assertEqual(response.data.get("username"), "user1") 108 | self.assertEqual(response.data.get("email"), "user1@mail.com") 109 | 110 | def test_validate_bad_token(self): 111 | """ Tests validate an invalid token """ 112 | 113 | # there should be zero tokens 114 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 115 | 116 | # try to validate an invalid token 117 | response = self.rest_do_validate_token("not_a_valid_token") 118 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 119 | 120 | # there should be zero tokens 121 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 122 | 123 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 124 | @override_settings(DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME=-1) 125 | def test_validate_expired_token(self, mock_reset_password_token_created): 126 | """ Tests validate an expired token """ 127 | 128 | # there should be zero tokens 129 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 130 | 131 | response = self.rest_do_request_reset_token(email="user1@mail.com") 132 | self.assertEqual(response.status_code, status.HTTP_200_OK) 133 | # check that the signal was sent once 134 | self.assertTrue(mock_reset_password_token_created.called) 135 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 136 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 137 | self.assertNotEqual(last_reset_password_token.key, "") 138 | 139 | # there should be one token 140 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 141 | # and it should be assigned to user1 142 | self.assertEqual( 143 | ResetPasswordToken.objects.filter(key=last_reset_password_token.key).first().user.username, 144 | "user1" 145 | ) 146 | 147 | # try to validate token 148 | response = self.rest_do_validate_token(last_reset_password_token.key) 149 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 150 | 151 | # there should be zero tokens 152 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 153 | 154 | # try to login with the old username/password (should work) 155 | self.assertTrue( 156 | self.django_check_login("user1", "secret1"), 157 | msg="User 1 should still be able to login with the old credentials" 158 | ) 159 | 160 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 161 | def test_reset_password(self, mock_reset_password_token_created): 162 | """ Tests resetting a password """ 163 | 164 | # there should be zero tokens 165 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 166 | 167 | response = self.rest_do_request_reset_token(email="user1@mail.com") 168 | self.assertEqual(response.status_code, status.HTTP_200_OK) 169 | # check that the signal was sent once 170 | self.assertTrue(mock_reset_password_token_created.called) 171 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 172 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 173 | self.assertNotEqual(last_reset_password_token.key, "") 174 | 175 | # there should be one token 176 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 177 | 178 | # if the same user tries to reset again, the user will get the same token again 179 | response = self.rest_do_request_reset_token(email="user1@mail.com") 180 | self.assertEqual(response.status_code, status.HTTP_200_OK) 181 | self.assertEqual(mock_reset_password_token_created.call_count, 2) 182 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 183 | self.assertNotEqual(last_reset_password_token.key, "") 184 | 185 | # there should be one token 186 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 187 | # and it should be assigned to user1 188 | self.assertEqual( 189 | ResetPasswordToken.objects.filter(key=last_reset_password_token.key).first().user.username, 190 | "user1" 191 | ) 192 | 193 | # try to reset the password 194 | response = self.rest_do_reset_password_with_token(last_reset_password_token.key, "new_secret") 195 | self.assertEqual(response.status_code, status.HTTP_200_OK) 196 | 197 | # there should be zero tokens 198 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 199 | 200 | # try to login with the old username/password (should fail) 201 | self.assertFalse( 202 | self.django_check_login("user1", "secret1"), 203 | msg="User 1 should not be able to login with the old credentials" 204 | ) 205 | 206 | # try to login with the new username/Password (should work) 207 | self.assertTrue( 208 | self.django_check_login("user1", "new_secret"), 209 | msg="User 1 should be able to login with the modified credentials" 210 | ) 211 | 212 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 213 | @override_settings(DJANGO_REST_LOOKUP_FIELD='username') 214 | def test_reset_password_different_lookup(self, mock_reset_password_token_created): 215 | """ Tests resetting a password """ 216 | 217 | # there should be zero tokens 218 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 219 | 220 | response = self.rest_do_request_reset_token(email="user3@mail.com") 221 | self.assertEqual(response.status_code, status.HTTP_200_OK) 222 | # check that the signal was sent once 223 | self.assertTrue(mock_reset_password_token_created.called) 224 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 225 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 226 | self.assertNotEqual(last_reset_password_token.key, "") 227 | 228 | # there should be one token 229 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 230 | 231 | # if the same user tries to reset again, the user will get the same token again 232 | response = self.rest_do_request_reset_token(email="user3@mail.com") 233 | self.assertEqual(response.status_code, status.HTTP_200_OK) 234 | self.assertEqual(mock_reset_password_token_created.call_count, 2) 235 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 236 | self.assertNotEqual(last_reset_password_token.key, "") 237 | 238 | # there should be one token 239 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 240 | # and it should be assigned to user1 241 | self.assertEqual( 242 | ResetPasswordToken.objects.filter(key=last_reset_password_token.key).first().user.username, 243 | "user3@mail.com" 244 | ) 245 | 246 | # try to reset the password 247 | response = self.rest_do_reset_password_with_token(last_reset_password_token.key, "new_secret") 248 | self.assertEqual(response.status_code, status.HTTP_200_OK) 249 | 250 | # there should be zero tokens 251 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 252 | 253 | # try to login with the old username/password (should fail) 254 | self.assertFalse( 255 | self.django_check_login("user3@mail.com", "secret3"), 256 | msg="User 3 should not be able to login with the old credentials" 257 | ) 258 | 259 | # try to login with the new username/Password (should work) 260 | self.assertTrue( 261 | self.django_check_login("user3@mail.com", "new_secret"), 262 | msg="User 3 should be able to login with the modified credentials" 263 | ) 264 | 265 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 266 | def test_reset_password_multiple_users(self, mock_reset_password_token_created): 267 | """ Checks whether multiple password reset tokens can be created for different users """ 268 | # connect signal 269 | # we need to check whether the signal is getting called 270 | 271 | # create a token for user 1 272 | response = self.rest_do_request_reset_token(email="user1@mail.com") 273 | self.assertEqual(response.status_code, status.HTTP_200_OK) 274 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 275 | self.assertTrue(mock_reset_password_token_created.called) 276 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 277 | token1 = mock_reset_password_token_created.call_args[1]['reset_password_token'] 278 | 279 | # create another token for user 2 280 | response = self.rest_do_request_reset_token(email="user2@mail.com") 281 | self.assertEqual(response.status_code, status.HTTP_200_OK) 282 | tokens = ResetPasswordToken.objects.all() 283 | self.assertEqual(tokens.count(), 2) 284 | self.assertEqual(mock_reset_password_token_created.call_count, 2) 285 | token2 = mock_reset_password_token_created.call_args[1]['reset_password_token'] 286 | 287 | # validate that those two tokens are different 288 | self.assertNotEqual(tokens[0].key, tokens[1].key) 289 | 290 | # try to request another token, there should still always be two keys 291 | response = self.rest_do_request_reset_token(email="user1@mail.com") 292 | self.assertEqual(response.status_code, status.HTTP_200_OK) 293 | self.assertEqual(ResetPasswordToken.objects.all().count(), 2) 294 | 295 | # create another token for user 2 296 | response = self.rest_do_request_reset_token(email="user2@mail.com") 297 | self.assertEqual(response.status_code, status.HTTP_200_OK) 298 | self.assertEqual(ResetPasswordToken.objects.all().count(), 2) 299 | 300 | # try to reset password of user2 301 | response = self.rest_do_reset_password_with_token(token2.key, "secret2_new") 302 | self.assertEqual(response.status_code, status.HTTP_200_OK) 303 | 304 | # now there should only be one token left (token1) 305 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 306 | self.assertEqual(ResetPasswordToken.objects.filter(key=token1.key).count(), 1) 307 | 308 | # user 2 should be able to login with "secret2_new" now 309 | self.assertTrue( 310 | self.django_check_login("user2", "secret2_new"), 311 | ) 312 | 313 | # try to reset again with token2 (should not work) 314 | response = self.rest_do_reset_password_with_token(token2.key, "secret2_fake_new") 315 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 316 | 317 | # user 2 should still be able to login with "secret2_new" now 318 | self.assertTrue( 319 | self.django_check_login("user2", "secret2_new"), 320 | ) 321 | 322 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 323 | @patch('django_rest_passwordreset.signals.pre_password_reset.send') 324 | @patch('django_rest_passwordreset.signals.post_password_reset.send') 325 | def test_signals(self, 326 | mock_post_password_reset, 327 | mock_pre_password_reset, 328 | mock_reset_password_token_created 329 | ): 330 | # check that all mocks have not been called yet 331 | self.assertFalse(mock_reset_password_token_created.called) 332 | self.assertFalse(mock_post_password_reset.called) 333 | self.assertFalse(mock_pre_password_reset.called) 334 | 335 | # request token for user1 336 | response = self.rest_do_request_reset_token(email="user1@mail.com") 337 | self.assertEqual(response.status_code, status.HTTP_200_OK) 338 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 339 | 340 | # verify that the reset_password_token_created signal was fired 341 | self.assertTrue(mock_reset_password_token_created.called) 342 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 343 | 344 | token1 = mock_reset_password_token_created.call_args[1]['reset_password_token'] 345 | self.assertNotEqual(token1.key, "", 346 | msg="Verify that the reset_password_token of the reset_password_Token_created signal is not empty") 347 | 348 | # verify that the other two signals have not yet been called 349 | self.assertFalse(mock_post_password_reset.called) 350 | self.assertFalse(mock_pre_password_reset.called) 351 | 352 | # reset password 353 | response = self.rest_do_reset_password_with_token(token1.key, "new_secret") 354 | self.assertEqual(response.status_code, status.HTTP_200_OK) 355 | 356 | # now the other two signals should have been called 357 | self.assertTrue(mock_post_password_reset.called) 358 | self.assertIn('reset_password_token', mock_post_password_reset.call_args[1]) 359 | self.assertEqual(mock_post_password_reset.call_args[1]['reset_password_token'], token1) 360 | self.assertTrue(mock_pre_password_reset.called) 361 | self.assertIn('reset_password_token', mock_pre_password_reset.call_args[1]) 362 | self.assertEqual(mock_pre_password_reset.call_args[1]['reset_password_token'], token1) 363 | 364 | @override_settings(DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE=True) 365 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 366 | def test_try_reset_password_email_does_not_exist_no_leakage_enabled(self, mock_reset_signal): 367 | """ 368 | Tests requesting a token for an email that does not exist when 369 | DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE == True 370 | """ 371 | response = self.rest_do_request_reset_token(email="foobar@doesnotexist.com") 372 | self.assertEqual(response.status_code, status.HTTP_200_OK) 373 | self.assertFalse(mock_reset_signal.called) 374 | 375 | def test_user_without_password(self): 376 | """ Tests requesting a token for an email without a password doesn't work""" 377 | response = self.rest_do_request_reset_token(email="user4@mail.com") 378 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 379 | decoded_response = json.loads(response.content.decode()) 380 | # response should have "email" in it 381 | self.assertTrue("email" in decoded_response) 382 | 383 | @override_settings(DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD=False) 384 | @patch('django_rest_passwordreset.signals.reset_password_token_created.send') 385 | def test_user_without_password_where_not_required(self, mock_reset_password_token_created): 386 | """ Tests requesting a token for an email without a password works when not required""" 387 | response = self.rest_do_request_reset_token(email="user4@mail.com") 388 | self.assertEqual(response.status_code, status.HTTP_200_OK) 389 | # check that the signal was sent once 390 | self.assertTrue(mock_reset_password_token_created.called) 391 | self.assertEqual(mock_reset_password_token_created.call_count, 1) 392 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 393 | self.assertNotEqual(last_reset_password_token.key, "") 394 | 395 | # there should be one token 396 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 397 | 398 | # if the same user tries to reset again, the user will get the same token again 399 | response = self.rest_do_request_reset_token(email="user4@mail.com") 400 | self.assertEqual(response.status_code, status.HTTP_200_OK) 401 | self.assertEqual(mock_reset_password_token_created.call_count, 2) 402 | last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token'] 403 | self.assertNotEqual(last_reset_password_token.key, "") 404 | 405 | # there should be one token 406 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 407 | # and it should be assigned to user1 408 | self.assertEqual( 409 | ResetPasswordToken.objects.filter(key=last_reset_password_token.key).first().user.username, 410 | "user4" 411 | ) 412 | 413 | # try to reset the password 414 | response = self.rest_do_reset_password_with_token(last_reset_password_token.key, "new_secret") 415 | self.assertEqual(response.status_code, status.HTTP_200_OK) 416 | 417 | # there should be zero tokens 418 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 419 | 420 | # try to login with the new username/Password (should work) 421 | self.assertTrue( 422 | self.django_check_login("user4", "new_secret"), 423 | msg="User 4 should be able to login with the modified credentials" 424 | ) 425 | 426 | def test_clear_expired_tokens(self): 427 | """ Tests clearance of expired tokens """ 428 | 429 | password_reset_token_validation_time = get_password_reset_token_expiry_time() 430 | 431 | # there should be zero tokens 432 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 433 | 434 | # request a new token 435 | response = self.rest_do_request_reset_token(email="user1@mail.com") 436 | self.assertEqual(response.status_code, status.HTTP_200_OK) 437 | 438 | # there should be one token 439 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 440 | 441 | # let the token expire 442 | token = ResetPasswordToken.objects.all().first() 443 | token.created_at = timezone.now() - timedelta(hours=password_reset_token_validation_time) 444 | token.save() 445 | 446 | # clear expired tokens 447 | clear_expired_tokens() 448 | 449 | # there should be zero tokens 450 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 451 | 452 | def test_generate_token_for_email_with_multiple_ip_address(self): 453 | """ 454 | Test generating tokens with multiple ip address will keep only the first 455 | one to match inet type 456 | https://www.postgresql.org/docs/current/datatype-net-types.html#DATATYPE-INET 457 | https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#syntax 458 | """ 459 | # request a new token with multiple ips 460 | generate_token_for_email(email="user1@mail.com", ip_address="1.1.1.1, 2.2.2.2") 461 | 462 | # there should be one token with only the first ip adress 463 | self.assertEqual(ResetPasswordToken.objects.get().ip_address, "1.1.1.1") 464 | 465 | def test_generate_token_for_email(self): 466 | """ Tests generating tokens for a specific email address programmatically """ 467 | 468 | # there should be zero tokens 469 | self.assertEqual(ResetPasswordToken.objects.all().count(), 0) 470 | 471 | # request a new token 472 | generate_token_for_email(email="user1@mail.com") 473 | 474 | # there should be one token 475 | self.assertEqual(ResetPasswordToken.objects.all().count(), 1) 476 | -------------------------------------------------------------------------------- /tests/test/test_throttle.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from rest_framework.test import APIClient, APITestCase 4 | from django.contrib.auth import get_user_model 5 | from django.urls import reverse 6 | 7 | from django_rest_passwordreset.models import ResetPasswordToken 8 | 9 | 10 | User = get_user_model() 11 | 12 | 13 | class TestThrottle(APITestCase): 14 | def setUp(self): 15 | self.client = APIClient() 16 | 17 | self.user_1 = User.objects.create_user(username='user_1', password='password', email='user@test.local') 18 | self.user_admin = User.objects.create_superuser(username='admin', password='password', email='admin@test.local') 19 | 20 | def _reset_password_logged_in(self): 21 | 22 | # generate token 23 | url = reverse('password_reset:reset-password-request') 24 | data = {'email': self.user_1.email} 25 | response = self.client.post(url, data).json() 26 | 27 | self.assertIn('status', response) 28 | self.assertEqual(response['status'], 'OK') 29 | 30 | # test validity of the token 31 | token = ResetPasswordToken.objects.filter(user=self.user_1).first() 32 | url = reverse('password_reset:reset-password-validate') 33 | data = {'token': token.key} 34 | response = self.client.post(url, data).json() 35 | 36 | self.assertIn('status', response) 37 | self.assertEqual(response['status'], 'OK') 38 | 39 | # reset password 40 | url = reverse('password_reset:reset-password-confirm') 41 | data = {'token': token.key, 'password': 'new_password'} 42 | response = self.client.post(url, data).json() 43 | 44 | # check if new password was set 45 | self.assertTrue(token.user.check_password('new_password')) 46 | self.assertFalse(token.user.check_password('password')) 47 | 48 | @mock.patch( 49 | "django_rest_passwordreset.throttling.ResetPasswordRequestTokenThrottle.scope", 50 | "django-rest-passwordreset-request-token-test-scope", 51 | ) 52 | def test_throttle(self,): 53 | # first run on _reset_password_logged_in 54 | # x number of runs (adjust number of calls to the throttle rate) 55 | for _ in range(1): 56 | self._reset_password_logged_in() 57 | # last run should raise an exception 58 | self.assertRaises(Exception, self._reset_password_logged_in) 59 | 60 | @mock.patch( 61 | "django_rest_passwordreset.throttling.ResetPasswordRequestTokenThrottle.scope", 62 | "django-rest-passwordreset-request-token-does-not-exist", 63 | ) 64 | def test_throttle_default_rate(self): 65 | # first run on _reset_password_logged_in 66 | # x number of runs (adjust number of calls to the throttle rate) 67 | for _ in range(3): 68 | self._reset_password_logged_in() 69 | # last run should raise an exception 70 | self.assertRaises(Exception, self._reset_password_logged_in) 71 | -------------------------------------------------------------------------------- /tests/test/test_token_generators.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase 3 | 4 | from django_rest_passwordreset.tokens import RandomNumberTokenGenerator, RandomStringTokenGenerator, get_token_generator 5 | 6 | 7 | class TokenGeneratorTestCase(TestCase): 8 | """ 9 | Tests that the token generators work as expected 10 | """ 11 | 12 | def test_string_token_generator(self): 13 | token_generator = RandomStringTokenGenerator(min_length=10, max_length=15) 14 | 15 | tokens = [] 16 | 17 | # generate 100 tokens 18 | for _ in range(0, 100): 19 | tokens.append(token_generator.generate_token()) 20 | 21 | # validate that those 100 tokens are unique 22 | unique_tokens = list(set(tokens)) 23 | 24 | self.assertEqual( 25 | len(tokens), len(unique_tokens), msg="StringTokenGenerator must create unique tokens" 26 | ) 27 | ################################################################################################################ 28 | # Please note: The above does not guarantee true randomness, it's just a necessity to make sure that we do not 29 | # return the same token all the time (by accident) 30 | ################################################################################################################ 31 | 32 | # validate that each token is between 10 and 15 characters 33 | for token in tokens: 34 | self.assertGreaterEqual( 35 | len(token), 10, msg="StringTokenGenerator must create tokens of min. length of 10" 36 | ) 37 | self.assertLessEqual( 38 | len(token), 15, msg="StringTokenGenerator must create tokens of max. length of 15" 39 | ) 40 | 41 | def test_number_token_generator(self): 42 | token_generator = RandomNumberTokenGenerator(min_number=1000000000, max_number=9999999999) 43 | 44 | tokens = [] 45 | 46 | # generate 100 tokens 47 | for _ in range(0, 100): 48 | tokens.append(token_generator.generate_token()) 49 | 50 | # validate that those 100 tokens are unique 51 | unique_tokens = list(set(tokens)) 52 | 53 | self.assertEqual( 54 | len(tokens), len(unique_tokens), msg="RandomNumberTokenGenerator must create unique tokens" 55 | ) 56 | ################################################################################################################ 57 | # Please note: The above does not guarantee true randomness, it's just a necessity to make sure that we do not 58 | # return the same token all the time (by accident) 59 | ################################################################################################################ 60 | 61 | # validate that each token is a number between 100000 and 999999 62 | for token in tokens: 63 | try: 64 | num = int(token) 65 | except: 66 | self.fail("RandomNumberTokenGenerator must return a number, but returned " + token) 67 | 68 | self.assertGreaterEqual(num, 1000000000, 69 | msg="RandomNumberTokenGenerator must return a number greater or equal to 1000000000") 70 | self.assertLess(num, 9999999999, 71 | msg="RandomNumberTokenGenerator must return a number less or equal to 9999999999") 72 | 73 | def test_generate_token_generator_from_empty_settings(self): 74 | """ 75 | If there is no setting for DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG, a RandomStringTokenGenerator should 76 | be created automatically by get_token_generator() 77 | :return: 78 | """ 79 | # patch settings 80 | settings.DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = None 81 | 82 | token_generator = get_token_generator() 83 | 84 | self.assertEqual( 85 | token_generator.__class__, RandomStringTokenGenerator, 86 | msg="If no class is set in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG, a RandomStringTokenGenerator should" 87 | "be created" 88 | ) 89 | 90 | def test_generate_token_generator_from_settings_string_token_generator(self): 91 | """ 92 | Checks if the get_token_generator() function uses the "CLASS" setting in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG 93 | :return: 94 | """ 95 | # patch settings 96 | settings.DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 97 | "CLASS": "django_rest_passwordreset.tokens.RandomStringTokenGenerator" 98 | } 99 | 100 | token_generator = get_token_generator() 101 | 102 | self.assertEqual( 103 | token_generator.__class__, RandomStringTokenGenerator, 104 | msg="get_token_generator() should return an instance of RandomStringTokenGenerator " 105 | "if configured in settings" 106 | ) 107 | 108 | def test_generate_token_generator_from_settings_number_token_generator(self): 109 | """ 110 | Checks if the get_token_generator() function uses the "CLASS" setting in DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG 111 | :return: 112 | """ 113 | # patch settings 114 | settings.DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { 115 | "CLASS": "django_rest_passwordreset.tokens.RandomNumberTokenGenerator" 116 | } 117 | 118 | token_generator = get_token_generator() 119 | 120 | self.assertEqual( 121 | token_generator.__class__, RandomNumberTokenGenerator, 122 | msg="get_token_generator() should return an instance of RandomNumberTokenGenerator " 123 | "if configured in settings" 124 | ) 125 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """ Tests App URL Config """ 2 | from django.conf.urls import include 3 | from django.contrib import admin 4 | from django.urls import path 5 | 6 | urlpatterns = [ 7 | path("api/password_reset/", include('django_rest_passwordreset.urls', namespace='password_reset')), 8 | path("admin/", admin.site.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/user_id_uuid_testapp/__init__.py -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | name = "user_id_uuid_testapp" 6 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2024-10-25 12:00 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('auth', '0012_alter_user_first_name_max_length'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='User', 21 | fields=[ 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('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')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 33 | ('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')), 34 | ('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')), 35 | ], 36 | options={ 37 | 'verbose_name': 'user', 38 | 'verbose_name_plural': 'users', 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/user_id_uuid_testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.auth.models import AbstractUser 4 | from django.db import models 5 | 6 | 7 | class User(AbstractUser): 8 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 9 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anexia-it/django-rest-passwordreset/09517bacfa688593c102c4959339f3438cb2eff5/tests/user_id_uuid_testapp/tests/__init__.py -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/tests/test_auth_test_case.py: -------------------------------------------------------------------------------- 1 | from test.test_auth_test_case import AuthTestCase 2 | 3 | 4 | class AuthTestCase(AuthTestCase): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/tests/test_setup.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.db.models import UUIDField 4 | from django.test import SimpleTestCase 5 | 6 | from django_rest_passwordreset.models import ResetPasswordToken 7 | from user_id_uuid_testapp.models import User 8 | 9 | 10 | class TestSetup(SimpleTestCase): 11 | def test_installed_apps(self): 12 | self.assertIn("django_rest_passwordreset", settings.INSTALLED_APPS) 13 | 14 | def test_models(self): 15 | self.assertIs( 16 | apps.get_model("django_rest_passwordreset", "ResetPasswordToken"), ResetPasswordToken 17 | ) 18 | self.assertIs(apps.get_model("user_id_uuid_testapp", "User"), User) 19 | for field in User._meta.fields: 20 | if field.name == "id": 21 | self.assertTrue(isinstance(field, UUIDField)) 22 | -------------------------------------------------------------------------------- /tests/user_id_uuid_testapp/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | 3 | from drf_anonymous_login.views import AnonymousLoginAuthenticationModelViewSet 4 | 5 | from .models import PrivateModel, PublicModel 6 | from .serializers import PrivateModelSerializer, PublicModelSerializer 7 | 8 | 9 | class PublicModelViewSet(viewsets.ModelViewSet): 10 | queryset = PublicModel.objects.all() 11 | serializer_class = PublicModelSerializer 12 | 13 | 14 | class PrivateModelViewSet(AnonymousLoginAuthenticationModelViewSet): 15 | queryset = PrivateModel.objects.all() 16 | serializer_class = PrivateModelSerializer 17 | --------------------------------------------------------------------------------