├── .github └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── LICENSE ├── LONG_DESCRIPTION.md ├── MANIFEST.in ├── README.md ├── django_email_verification ├── __init__.py ├── apps.py ├── confirm.py ├── errors.py ├── tests │ ├── __init__.py │ ├── settings.py │ ├── templates │ │ ├── confirm.html │ │ ├── mail.html │ │ ├── password.html │ │ ├── password_change.html │ │ ├── password_changed.html │ │ ├── plainmail.txt │ │ └── plainpassword.txt │ ├── tests.py │ ├── urls.py │ ├── urls_test_1.py │ └── urls_test_2.py ├── token_utils.py ├── urls.py └── views.py ├── icon.png ├── pytest.ini ├── requirements.txt └── setup.py /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: '3.10' 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install -r requirements.txt 19 | 20 | - name: Run tests 21 | run: coverage run --source=django_email_verification -m pytest 22 | 23 | - uses: codecov/codecov-action@v3 24 | with: 25 | name: codecov-umbrella 26 | fail_ci_if_error: true 27 | verbose: true 28 | 29 | publish: 30 | runs-on: ubuntu-latest 31 | needs: [ test ] 32 | environment: 33 | name: pypi 34 | url: https://pypi.org/p/django-email-verification/ 35 | permissions: 36 | id-token: write 37 | 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Set up Python 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: '3.10' 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install setuptools wheel twine 48 | - name: Build 49 | run: python setup.py sdist bdist_wheel 50 | - name: Publish 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: [pull_request, workflow_dispatch] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - name: Set up Python 10 | uses: actions/setup-python@v4 11 | with: 12 | python-version: '3.10' 13 | - name: Install dependencies 14 | run: | 15 | python -m pip install --upgrade pip 16 | pip install -r requirements.txt 17 | 18 | - name: Run tests 19 | run: coverage run --source=django_email_verification -m pytest 20 | 21 | - uses: codecov/codecov-action@v3 22 | with: 23 | name: codecov-umbrella 24 | fail_ci_if_error: true 25 | verbose: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | ### VirtualEnv template 93 | # Virtualenv 94 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 95 | [Bb]in 96 | [Ii]nclude 97 | [Ll]ib 98 | [Ll]ib64 99 | [Ll]ocal 100 | [Ss]cripts 101 | pyvenv.cfg 102 | .venv 103 | pip-selfcheck.json 104 | ### JetBrains template 105 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 106 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 107 | 108 | # User-specific stuff: 109 | .idea/workspace.xml 110 | .idea/tasks.xml 111 | .idea/dictionaries 112 | .idea/vcs.xml 113 | .idea/jsLibraryMappings.xml 114 | 115 | # Sensitive or high-churn files: 116 | .idea/dataSources.ids 117 | .idea/dataSources.xml 118 | .idea/dataSources.local.xml 119 | .idea/sqlDataSources.xml 120 | .idea/dynamic.xml 121 | .idea/uiDesigner.xml 122 | 123 | # Gradle: 124 | .idea/gradle.xml 125 | .idea/libraries 126 | 127 | # Mongo Explorer plugin: 128 | .idea/mongoSettings.xml 129 | 130 | .idea/ 131 | 132 | ## File-based project format: 133 | *.iws 134 | 135 | ## Plugin-specific files: 136 | 137 | # IntelliJ 138 | /out/ 139 | 140 | # mpeltonen/sbt-idea plugin 141 | .idea_modules/ 142 | 143 | # JIRA plugin 144 | atlassian-ide-plugin.xml 145 | 146 | # Crashlytics plugin (for Android Studio and IntelliJ) 147 | com_crashlytics_export_strings.xml 148 | crashlytics.properties 149 | crashlytics-build.properties 150 | fabric.properties 151 | 152 | #pytest 153 | .pytest_cache/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2021 by Leone Bacciu, leonebacciu@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LONG_DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | # Django Email Verification 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/django-email-verification?style=flat-square&logo=pypi&color=yellow)](https://pypi.org/project/django-email-verification/) 4 | [![PyPI - License](https://img.shields.io/pypi/l/django-email-verification?style=flat-square&logo=open-source-initiative)](https://github.com/LeoneBacciu/django-email-verification/blob/version-0.1.0/LICENSE) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LeoneBacciu/django-email-verification/test.yaml?style=flat-square&logo=github-actions)](https://github.com/LeoneBacciu/django-email-verification/actions) 6 | [![codecov](https://img.shields.io/codecov/c/github/LeoneBacciu/django-email-verification?token=97DDVD3MGW&style=flat-square&logo=codecov)](https://codecov.io/gh/LeoneBacciu/django-email-verification) 7 | 8 |

9 | icon 10 |

11 | 12 |

13 | Do you like my work and want to support me?

14 | Buy Me A Coffee 15 |

16 | 17 |

18 | Check out the full up-to-date documentation on GitHub! 19 |

-------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Email Verification 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/django-email-verification?style=flat-square&logo=pypi&color=yellow)](https://pypi.org/project/django-email-verification/) 4 | [![PyPI - License](https://img.shields.io/pypi/l/django-email-verification?style=flat-square&logo=open-source-initiative)](https://github.com/LeoneBacciu/django-email-verification/blob/version-0.1.0/LICENSE) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LeoneBacciu/django-email-verification/test.yaml?style=flat-square&logo=github-actions)](https://github.com/LeoneBacciu/django-email-verification/actions) 6 | [![codecov](https://img.shields.io/codecov/c/github/LeoneBacciu/django-email-verification?token=97DDVD3MGW&style=flat-square&logo=codecov)](https://codecov.io/gh/LeoneBacciu/django-email-verification) 7 | 8 |

9 | icon 10 |

11 | 12 |

13 | Do you like my work and want to support me?

14 | Buy Me A Coffee 15 |

16 | 17 | ## Requirements 18 | 19 | + Python >= 3.8 20 | + Django >= 4.2 21 | 22 | ## General concept 23 | 24 | Here is a simple Sequence Diagram of the email verification process: 25 | 26 | ```mermaid 27 | sequenceDiagram 28 | actor U as User 29 | participant D as django-email-verification 30 | participant C as Your Code 31 | U -->> C: Creates an Account 32 | note over C: Set User as inactive 33 | C ->> D: Call send_email 34 | D -)+ U: Email with Activation Link 35 | U -)- C: Link clicked 36 | C ->> D: Request forwarded 37 | critical Token Validation 38 | option Valid 39 | D ->> C: Run Callback 40 | D ->> U: Render Success Page 41 | option Invalid 42 | D ->> U: Render Error Page 43 | end 44 | ``` 45 | 46 | And here is a simple Sequence Diagram of the password recovery process: 47 | 48 | ```mermaid 49 | sequenceDiagram 50 | actor U as User 51 | participant D as django-email-verification 52 | participant C as Your Code 53 | U -->> C: Click on Recover Password 54 | C ->> D: Call send_password 55 | D -)+ U: Email with Password Change Link 56 | U -)- C: Link clicked 57 | C ->> D: Request forwarded 58 | critical Token Validation 59 | option Valid 60 | D ->> U: Render Password Change View 61 | U ->> D: Submit new Password 62 | D ->> C: Run Callback 63 | D ->> U: Render Success Page 64 | option Invalid 65 | D ->> U: Render Error Page 66 | end 67 | ``` 68 | 69 | The app is build to be as little opinionated as possible, every action it can perform can be replaced by custom code, 70 | and everything else will continue working just the same.\ 71 | For both Email Verification and Password Recovery, the features can be divided into: 72 | 73 | 1. [Email Sending](#email-sending) 74 | 2. [Verification / Recovery View](#verification--recovery-view) 75 | 76 | ## Installation 77 | 78 | You can install by: 79 | 80 | ```commandline 81 | pip3 install django-email-verification 82 | ``` 83 | 84 | and import by: 85 | 86 | ```python 87 | INSTALLED_APPS = [ 88 | 'django.contrib.admin', 89 | 'django.contrib.auth', 90 | ... 91 | 'django_email_verification', # you have to add this 92 | ] 93 | ``` 94 | 95 | and add the following to your `urls.py` file: 96 | 97 | ``` 98 | urlpatterns = [ 99 | ... 100 | path('verify/', include('django_email_verification.urls')), 101 | ... 102 | ] 103 | ``` 104 | ## Settings parameters 105 | 106 | You have to add these parameters to the settings, you have to include all of them except the last one: 107 | 108 | ```python 109 | # settings.py 110 | 111 | def email_verified_callback(user): 112 | user.is_active = True 113 | 114 | 115 | def password_change_callback(user, password): 116 | user.set_password(password) 117 | 118 | 119 | # Global Package Settings 120 | EMAIL_FROM_ADDRESS = 'noreply@aliasaddress.com' # mandatory 121 | EMAIL_PAGE_DOMAIN = 'https://mydomain.com/' # mandatory (unless you use a custom link) 122 | EMAIL_MULTI_USER = False # optional (defaults to False) 123 | 124 | # Email Verification Settings (mandatory for email sending) 125 | EMAIL_MAIL_SUBJECT = 'Confirm your email {{ user.username }}' 126 | EMAIL_MAIL_HTML = 'mail_body.html' 127 | EMAIL_MAIL_PLAIN = 'mail_body.txt' 128 | EMAIL_MAIL_TOKEN_LIFE = 60 * 60 # one hour 129 | 130 | # Email Verification Settings (mandatory for builtin view) 131 | EMAIL_MAIL_PAGE_TEMPLATE = 'email_success_template.html' 132 | EMAIL_MAIL_CALLBACK = email_verified_callback 133 | 134 | # Password Recovery Settings (mandatory for email sending) 135 | EMAIL_PASSWORD_SUBJECT = 'Change your password {{ user.username }}' 136 | EMAIL_PASSWORD_HTML = 'password_body.html' 137 | EMAIL_PASSWORD_PLAIN = 'password_body.txt' 138 | EMAIL_PASSWORD_TOKEN_LIFE = 60 * 10 # 10 minutes 139 | 140 | # Password Recovery Settings (mandatory for builtin view) 141 | EMAIL_PASSWORD_PAGE_TEMPLATE = 'password_changed_template.html' 142 | EMAIL_PASSWORD_CHANGE_PAGE_TEMPLATE = 'password_change_template.html' 143 | EMAIL_PASSWORD_CALLBACK = password_change_callback 144 | 145 | # For Django Email Backend 146 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 147 | EMAIL_HOST = 'smtp.gmail.com' 148 | EMAIL_PORT = 587 149 | EMAIL_HOST_USER = 'mymail@gmail.com' 150 | EMAIL_HOST_PASSWORD = 'mYC00lP4ssw0rd' # os.environ['password_key'] suggested 151 | EMAIL_USE_TLS = True 152 | ``` 153 | 154 | For simplicity, I will refer to both `XX_MAIL_XX` and `XX_PASSWORD_XX` by writing `XX_{MAIL|PASSWORD}_XX`. 155 | 156 | In detail: 157 | 158 | + `EMAIL_FROM_ADDRESS`: this can be the same as `EMAIL_HOST_USER` or an alias address if required. 159 | + `EMAIL_PAGE_DOMAIN`: the domain of the confirmation link (usually your site's domain). 160 | + `EMAIL_MULTI_USER`: (optional) if `True` an error won't be thrown if multiple users with the same email are present ( 161 | just one will be activated) 162 | + `EMAIL_MAIL_CALLBACK`: will be called when the user successfully verifies the email. Can be a function (taking the 163 | user object as a parameter) or a method on the user object (no arguments) [^1]. 164 | + `EMAIL_PASSWORD_CALLBACK`: will be called when the user successfully submits a new password. Can be a function (taking the 165 | user object and the new password as parameters) or a method on the user object (taking the new password as a parameter)[^1]. 166 | + `EMAIL_{MAIL|PASSWORD}_`: are all django templates: 167 | * `SUBJECT`: the mail default subject. 168 | * `HTML`: the mail body template in form of html. 169 | * `PLAIN`: the mail body template in form of .txt file. 170 | + `EMAIL_{MAIL|PASSWORD}_TOKEN_LIFE`: the lifespan of the email link (in seconds). 171 | + `EMAIL_{MAIL|PASSWORD}_PAGE_TEMPLATE`: the template of the success/error view. Takes `{success: bool, user: Model, request: WSGIRequest}` as parameters. 172 | + `EMAIL_PASSWORD_CHANGE_TEMPLATE`: the template for the page with the form to submit a new password. Must send a POST request to the same address, with the field `password` in the payload. 173 | 174 | For the Django Email Backend fields look at the 175 | official [documentation](https://docs.djangoproject.com/en/4.2/topics/email/). 176 | 177 | 178 | ## Email Sending 179 | 180 | The functions in charge of sending the emails are the following: 181 | 182 | ```python 183 | send_email(user, thread=True, expiry=None, context=None) 184 | send_password(user, thread=True, expiry=None, context=None) 185 | ``` 186 | 187 | The fields are: 188 | - `user` (`Model`): the user you want to send the email to 189 | - `thread` (`bool`): whether to send the email asynchronously or not 190 | - `expiry` (`datetime`): custom token expiry date (different from `datetime.now() + EMAIL_{MAIL|PASSWORD}_TOKEN_LIFE`) 191 | - `context` (`dict`): additional context for the email template 192 | 193 | > **NOTE**: By default the email is sent asynchronously, which is the suggested behaviour, if this is a problem (for 194 | > example if you are running synchronous tests), you can pass the parameter `thread=False`. 195 | 196 | ```python 197 | # views.py 198 | 199 | from django.shortcuts import render 200 | from django.contrib.auth import get_user_model 201 | from django_email_verification import send_email 202 | 203 | 204 | def create_account_functional_view(request): 205 | ... 206 | user = get_user_model().objects.create(username=username, password=password, email=email) 207 | user.is_active = False # Example 208 | send_email(user) 209 | return render(...) 210 | 211 | 212 | def recover_password_functional_view(request): 213 | ... 214 | send_password(user) 215 | return render(...) 216 | ``` 217 | 218 | `send_email(user)` and `send_password(user)` send an email with the defined template (and the pseudo-random generated token) to the user. 219 | 220 | > **_IMPORTANT:_** For email verification, you have to manually set the user to inactive before sending the email. 221 | 222 | If you are using class based views, then it is necessary to call the superclass before calling the `send_email` 223 | method. 224 | 225 | ```python 226 | # views.py 227 | 228 | from django.views.generic.edit import FormView 229 | from django_email_verification import send_email 230 | 231 | 232 | class CreateAccountClassView(FormView): 233 | 234 | def form_valid(self, form): 235 | user = form.save() 236 | return_val = super(CreateAccountClassView, self).form_valid(form) 237 | send_email(user) 238 | return return_val 239 | ``` 240 | 241 | 242 | ### Templates examples 243 | 244 | The `EMAIL_{MAIL|PASSWORD}_SUBJECT` is a template that receives `{{ token }}`(`str`), `{{ link }}`(`str`), `{{ expiry }}`(`datetime`) and `user`(`Model`) (plus your custom context) as arguments, 245 | it might look something like this: 246 | 247 | ```python 248 | EMAIL_MAIL_SUBJECT = 'Confirm your email {{ user.username }}' 249 | EMAIL_PASSWORD_SUBJECT = 'Change password request for {{ user.username }}' 250 | ``` 251 | 252 | The `EMAIL_{MAIL|PASSWORD}_HTML` is a template that receives `{{ token }}`(`str`), `{{ link }}`(`str`), `{{ expiry }}`(`datetime`) and `user`(`Model`) (plus your custom contex) as arguments, 253 | it might look something like this: 254 | 255 | ```html 256 |

You are almost there, {{ user.username }}!


257 |

Please click here to confirm your account

258 |

The token expires on {{ expiry|time:"TIME_FORMAT" }}

259 | ``` 260 | 261 | The `EMAIL_{MAIL|PASSWORD}_PLAIN` is a template that receives `{{ token }}`(`str`), `{{ link }}`(`str`), `{{ expiry }}`(`datetime`) and `user`(`Model`) (plus your custom contex) as arguments, 262 | it might look something like this: 263 | 264 | ```text 265 | You are almost there, {{ user.username }}! 266 | Please click the following link to confirm your account: {{ link }} 267 | The token expires on {{ expiry|time:"TIME_FORMAT" }} 268 | ``` 269 | 270 | ## Verification / Recovery View 271 | 272 | ### Builtin Method 273 | 274 | The easiest way to recieve the token is to use the builtin views. 275 | To do so you just need to include the application's urls and define the necessary Django templates. 276 | 277 | ```python 278 | # urls.py 279 | 280 | from django.contrib import admin 281 | from django.urls import path, include 282 | from django_email_verification import urls as email_urls # include the urls 283 | 284 | urlpatterns = [ 285 | ... 286 | path('email/', include(email_urls)), # connect them to an arbitrary path 287 | ... 288 | ] 289 | ``` 290 | When a request arrives to `https.//mydomain.com/email/email/` the package verifies the token and: 291 | + if it corresponds to a pending token it renders the `EMAIL_MAIL_PAGE_TEMPLATE` passing `success=True` 292 | + if it doesn't correspond it renders the `EMAIL_MAIL_PAGE_TEMPLATE` passing `success=False` 293 | 294 | If the token is correct, `EMAIL_MAIL_CALLBACK` is called before the page is returned. 295 | 296 | The `EMAIL_MAIL_PAGE_TEMPLATE` is a template that receives `{{ success }}`(`bool`), `{{ user }}`(`Model`) and `{{ request }}`(`WSGIRequest`) as arguments, 297 | it might look something like this: 298 | 299 | ```html 300 | 301 | 302 | 303 | 304 | Confirmation 305 | 306 | 307 | {% if success %} 308 | {{ user.username }}, your account is confirmed! 309 | {% else %} 310 | Error, invalid token! 311 | {% endif %} 312 | 313 | 314 | ``` 315 | 316 | When a request arrives to `https.//mydomain.com/email/password/` the package renders `EMAIL_PASSWORD_CHANGE_TEMPLATE`. 317 | This view should present a form that submits a POST request to the same url, passing a `password` field in the body. 318 | 319 | The `EMAIL_PASSWORD_CHANGE_TEMPLATE` is a template that receives `{{ user }}`(`Model`) and `{{ request }}`(`WSGIRequest`) as arguments, 320 | it might look something like this: 321 | 322 | ```html 323 | 324 | 325 | 326 | 327 | Password Change 328 | 329 | 330 | {{ user.username }}, set your new password: 331 |
332 | 333 | 334 | 335 |
336 | 337 | 338 | ``` 339 | 340 | Once the POST request it's submitted, the server verifies the token and: 341 | + if it corresponds to a pending token it renders the `EMAIL_PASSWORD_TEMPLATE` 342 | + if it doesn't correspond it renders the `EMAIL_PASSWORD_TEMPLATE` passing `success=False` 343 | 344 | If the token is correct, `EMAIL_PASSWORD_CALLBACK` is called before the page is returned. 345 | 346 | The `EMAIL_MAIL_PAGE_TEMPLATE` is a template that receives `{{ success }}`(`bool`), `{{ user }}`(`Model`) and `{{ request }}`(`WSGIRequest`) as arguments, 347 | it might look something like this: 348 | 349 | ```html 350 | 351 | 352 | 353 | 354 | Password Changed 355 | 356 | 357 | {% if success %} 358 | {{ user.username }}, your password has been changed! 359 | {% else %} 360 | Error, invalid token! 361 | {% endif %} 362 | 363 | 364 | ``` 365 | 366 | ### Custom View Method 367 | 368 | If you want to use your custom Django view for the verification of the token (if you need a more complex behaviour) you can do the following: 369 | 370 | 1. Add your view to the `urls.py` file, using the correct url argument 371 | 2. Mark your view using the corresponding decorator 372 | 3. Call the token verification function 373 | 374 | Here is the code: 375 | 376 | ```python 377 | # urls.py 378 | 379 | from django.urls import path 380 | from .views import confirm_view, password_view 381 | 382 | urlpatterns = [ 383 | ... 384 | path('email//', confirm_view), # remember to set the "token" parameter in the url! 385 | path('password//', password_view), # remember to set the "token" parameter in the url! 386 | ... 387 | ] 388 | ``` 389 | 390 | > **_IMPORTANT:_** the path must **NOT** have the `name` attribute set 391 | 392 | ```python 393 | # views.py 394 | 395 | from django.http import HttpResponse 396 | from django_email_verification import verify_email, verify_password, verify_email_view, verify_password_view 397 | 398 | 399 | @verify_email_view 400 | def confirm_view(request, token): 401 | success, user = verify_email(token) 402 | return HttpResponse(f'Account verified, {user.username}' if success else 'Invalid token') 403 | 404 | 405 | @verify_password_view 406 | def password_view(request, token): 407 | if request.method == 'POST' and (pwd := request.POST.get('password')) is not None: 408 | success, user = verify_password(token, pwd) 409 | return HttpResponse(f'Password Changed, {user.username}' if success else 'Invalid token') 410 | return HttpResponse('Wrong Method') 411 | ``` 412 | The decorators allow the app to automatically generate a url with the correct link to the view, as long as there is only one view per decorator and it has the correct arguments. 413 | 414 | The functions `verify_email(token)` and `verify_password(token, password)` verify the token and, if it is correct, call the corresponding callback (`EMAIL_MAIL_CALLBACK` and `EMAIL_PASSWORD_CALLBACK` respectively). 415 | 416 | #### Manual Token Verification 417 | 418 | If you only need to check the token, you can use the following code: 419 | ```python 420 | from django_email_verification import default_token_generator 421 | 422 | valid, user = default_token_generator.check_token(token, kind='MAIL') # For an email token 423 | valid, user = default_token_generator.check_token(token, kind='PASSWORD') # For a password token 424 | ``` 425 | 426 | ## Testing 427 | 428 | If you are using django-email-verification and you want to test the email, if settings.DEBUG == True, then two items 429 | will be added to the email headers. 430 | You can obtain these by checking the django.core.mail outbox, which will have a non-zero length if an email has been 431 | sent. Retrieve the email and obtain the link (includes token) or the token to use in your code. 432 | 433 | ```python 434 | from django.core import mail 435 | 436 | ... 437 | test body 438 | ... 439 | 440 | try: 441 | email = mail.outbox[0] 442 | link = mail.extra_headers['LINK'] 443 | token = mail.extra_headers['TOKEN'] 444 | browser.visit(link) # verifies token... 445 | except AttributeError: 446 | logger.warn("no email") 447 | ``` 448 | 449 | For the email to be in the inbox, you will need to use the correct email backend. Use either: 450 | 451 | ```python 452 | EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' 453 | ``` 454 | 455 | or: 456 | 457 | ```python 458 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 459 | ``` 460 | 461 | You can use any Django email backend and also your custom one. 462 | 463 | If you want to run the builtin tests, clone the project and execute: 464 | 465 | ```commandline 466 | coverage run --source=django_email_verification -m pytest && coverage report -m 467 | ``` 468 | 469 | (You will need [coverage](https://pypi.org/project/coverage/), [pytest](https://pypi.org/project/pytest/) 470 | and [pytest-django](https://pypi.org/project/pytest-django/)) 471 | 472 | ### Logo copyright: 473 | 474 | Logo by Filippo Veggo 475 |
"Django and the Django logo are registered trademarks of Django Software Foundation.
Usage of the Django trademarks are subject to the Django Trademark licensing Agreement."
476 |
Icons made by Kiranshastry from www.flaticon.com
477 |
Icons made by Pixel perfect from www.flaticon.com
478 | 479 | [^1]: The `EMAIL_{MAIL|PASSWORD}_CALLBACK` can be a function on the `AUTH_USER_MODEL`, for example: 480 | ```python 481 | EMAIL_{MAIL|PASSWORD}_CALLBACK = get_user_model().callback 482 | ``` 483 | -------------------------------------------------------------------------------- /django_email_verification/__init__.py: -------------------------------------------------------------------------------- 1 | from .confirm import send_email, send_password, verify_email, verify_password, verify_token, verify_email_view, \ 2 | verify_password_view, verify_view 3 | from .views import verify_email_page, verify_password_page 4 | from .token_utils import default_token_generator 5 | -------------------------------------------------------------------------------- /django_email_verification/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoEmailConfirmConfig(AppConfig): 5 | name = 'django_email_verification' 6 | -------------------------------------------------------------------------------- /django_email_verification/confirm.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | from threading import Thread 4 | from typing import Callable 5 | 6 | import deprecation 7 | import validators 8 | from django.conf import settings 9 | from django.core.mail import EmailMultiAlternatives 10 | from django.template import Template, Context 11 | from django.template.loader import render_to_string 12 | from django.urls import get_resolver 13 | 14 | from .errors import InvalidUserModel, NotAllFieldCompiled 15 | from .token_utils import default_token_generator 16 | 17 | logger = logging.getLogger('django_email_verification') 18 | DJANGO_EMAIL_VERIFICATION_URL_ROUTE_ERROR = 'ERROR: no path found url.py' 19 | DJANGO_EMAIL_VERIFICATION_MORE_VIEWS_ERROR = 'ERROR: more than one verify view found' 20 | DJANGO_EMAIL_VERIFICATION_MALFORMED_URL = 'WARNING: the URL seems to be malformed' 21 | 22 | 23 | def send_email(user, thread=True, expiry=None, context=None): 24 | send_inner(user, thread, expiry, 'MAIL', context) 25 | 26 | 27 | def send_password(user, thread=True, expiry=None, context=None): 28 | send_inner(user, thread, expiry, 'PASSWORD', context) 29 | 30 | 31 | def send_inner(user, thread, expiry, kind, context=None): 32 | try: 33 | user.save() 34 | 35 | exp = expiry if expiry is not None else _get_validated_field(f'EMAIL_{kind}_TOKEN_LIFE', 36 | default_type=int) + default_token_generator.now() 37 | token, expiry = default_token_generator.make_token(user, exp, kind=kind) 38 | 39 | sender = _get_validated_field('EMAIL_FROM_ADDRESS') 40 | domain = _get_validated_field('EMAIL_PAGE_DOMAIN', default='', use_default=True) 41 | subject = _get_validated_field(f'EMAIL_{kind}_SUBJECT') 42 | mail_plain = _get_validated_field(f'EMAIL_{kind}_PLAIN') 43 | mail_html = _get_validated_field(f'EMAIL_{kind}_HTML') 44 | debug = _get_validated_field('DEBUG', default_type=bool) 45 | 46 | args = (user, kind, token, expiry, sender, domain, subject, mail_plain, mail_html, debug, context) 47 | if thread: 48 | t = Thread(target=send_inner_thread, args=args) 49 | t.start() 50 | else: 51 | send_inner_thread(*args) 52 | except AttributeError: 53 | raise InvalidUserModel('The user model you provided is invalid') 54 | except NotAllFieldCompiled as e: 55 | raise e 56 | except Exception as e: 57 | logger.error(repr(e)) 58 | 59 | 60 | def send_inner_thread(user, kind, token, expiry, sender, domain, subject, mail_plain, mail_html, debug, context): 61 | domain += '/' if not domain.endswith('/') else '' 62 | 63 | if context is None: 64 | context = {} 65 | 66 | context.update({'token': token, 'expiry': expiry, 'user': user}) 67 | 68 | def has_decorator(k): 69 | if callable(k): 70 | return k.__dict__.get(f'django_email_verification_{kind.lower()}_view_id', False) 71 | return False 72 | 73 | d = [v[0][0] for k, v in get_resolver(None).reverse_dict.items() if has_decorator(k)] 74 | d = [a[0][:a[0].index('%')] for a in d if len(a[1])] 75 | 76 | if len(d) == 0: 77 | logger.error(DJANGO_EMAIL_VERIFICATION_URL_ROUTE_ERROR) 78 | return 79 | 80 | if len(d) > 1: 81 | logger.error(f'{DJANGO_EMAIL_VERIFICATION_MORE_VIEWS_ERROR}: {d}') 82 | return 83 | 84 | if len(d) >= 1: 85 | context['link'] = domain + d[0] + token 86 | if not validators.url(context['link']): 87 | logger.warning(f'{DJANGO_EMAIL_VERIFICATION_MALFORMED_URL} - {context["link"]}') 88 | 89 | subject = Template(subject).render(Context(context)) 90 | 91 | text = render_to_string(mail_plain, context) 92 | 93 | html = render_to_string(mail_html, context) 94 | 95 | msg = EmailMultiAlternatives(subject, text, sender, [user.email]) 96 | 97 | if debug: 98 | msg.extra_headers['LINK'] = context['link'] 99 | msg.extra_headers['TOKEN'] = token 100 | 101 | msg.attach_alternative(html, 'text/html') 102 | msg.send() 103 | 104 | 105 | def _get_validated_field(field, default=None, use_default=False, default_type=None): 106 | if default_type is None: 107 | default_type = str 108 | try: 109 | d = getattr(settings, field) 110 | if d == "" or d is None or not isinstance(d, default_type): 111 | raise AttributeError(f'Wrong value for field {field}') 112 | return d 113 | except AttributeError: 114 | if use_default: 115 | return default 116 | raise NotAllFieldCompiled(f'Field {field} missing or invalid') 117 | 118 | 119 | def verify_email(token): 120 | valid, user = default_token_generator.check_token(token, kind='MAIL') 121 | if valid: 122 | callback = _get_validated_field('EMAIL_MAIL_CALLBACK', default_type=Callable) 123 | if hasattr(user, callback.__name__): 124 | getattr(user, callback.__name__)() 125 | else: 126 | callback(user) 127 | user.save() 128 | return valid, user 129 | return False, None 130 | 131 | 132 | def verify_password(token, password): 133 | valid, user = default_token_generator.check_token(token, kind='PASSWORD') 134 | if valid: 135 | callback = _get_validated_field('EMAIL_PASSWORD_CALLBACK', default_type=Callable) 136 | if hasattr(user, callback.__name__): 137 | getattr(user, callback.__name__)(password) 138 | else: 139 | callback(user, password) 140 | user.save() 141 | return valid, user 142 | return False, None 143 | 144 | 145 | @deprecation.deprecated(deprecated_in='0.3.0', details='use either verify_email() or verify_password()') 146 | def verify_token(token): # pragma: no cover 147 | return verify_email(token) 148 | 149 | 150 | def verify_email_view(func): 151 | func.django_email_verification_mail_view_id = True 152 | 153 | @functools.wraps(func) 154 | def verify_function_wrapper(*args, **kwargs): 155 | return func(*args, **kwargs) 156 | 157 | return verify_function_wrapper 158 | 159 | 160 | def verify_password_view(func): 161 | func.django_email_verification_password_view_id = True 162 | 163 | @functools.wraps(func) 164 | def verify_function_wrapper(*args, **kwargs): 165 | return func(*args, **kwargs) 166 | 167 | return verify_function_wrapper 168 | 169 | 170 | @deprecation.deprecated(deprecated_in='0.3.0', details='use either verify_email_view() or verify_password_view()') 171 | def verify_view(func): # pragma: no cover 172 | func.django_email_verification_mail_view_id = True 173 | 174 | @functools.wraps(func) 175 | def verify_function_wrapper(*args, **kwargs): 176 | return func(*args, **kwargs) 177 | 178 | return verify_function_wrapper 179 | -------------------------------------------------------------------------------- /django_email_verification/errors.py: -------------------------------------------------------------------------------- 1 | class InvalidUserModel(Exception): 2 | """The user model you provided is invalid""" 3 | pass 4 | 5 | 6 | class EmailTemplateNotFound(Exception): 7 | """No email template found""" 8 | pass 9 | 10 | 11 | class NotAllFieldCompiled(Exception): 12 | """Compile all the fields in the settings""" 13 | pass 14 | -------------------------------------------------------------------------------- /django_email_verification/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoneBacciu/django-email-verification/49e841b96e8bd39f0ad359a75be4711508ac4879/django_email_verification/tests/__init__.py -------------------------------------------------------------------------------- /django_email_verification/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | SECRET_KEY = 'i6)fwiz^ru7hj^gzk4t=i9la-gi6)s4++4um6+drg^m(g-5c_x' 6 | 7 | DEBUG = True 8 | 9 | ALLOWED_HOSTS = [] 10 | 11 | # Application definition 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django_email_verification', 17 | ] 18 | 19 | ROOT_URLCONF = 'django_email_verification.tests.urls' 20 | 21 | TEMPLATES = [ 22 | { 23 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 24 | 'DIRS': [os.path.join(BASE_DIR, 'tests/templates')] 25 | , 26 | 'APP_DIRS': True, 27 | 'OPTIONS': { 28 | 'context_processors': [ 29 | 'django.template.context_processors.debug', 30 | 'django.template.context_processors.request', 31 | 'django.contrib.auth.context_processors.auth', 32 | 'django.contrib.messages.context_processors.messages', 33 | ], 34 | }, 35 | }, 36 | ] 37 | 38 | DATABASES = { 39 | 'default': { 40 | 'ENGINE': 'django.db.backends.sqlite3', 41 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 42 | } 43 | } 44 | 45 | LANGUAGE_CODE = 'en-us' 46 | 47 | TIME_ZONE = 'UTC' 48 | 49 | USE_I18N = True 50 | 51 | USE_TZ = True 52 | 53 | 54 | def verified(user): 55 | user.is_active = True 56 | 57 | 58 | def changePassword(user, password): 59 | user.set_password(password) 60 | 61 | 62 | EMAIL_MAIL_CALLBACK = verified 63 | EMAIL_PASSWORD_CALLBACK = changePassword 64 | 65 | EMAIL_FROM_ADDRESS = 'rousseau.platform@gmail.com' 66 | 67 | EMAIL_MAIL_SUBJECT = 'Confirm your email {{ user.username }}' 68 | EMAIL_MAIL_HTML = 'mail.html' 69 | EMAIL_MAIL_PLAIN = 'plainmail.txt' 70 | 71 | EMAIL_MAIL_PAGE_TEMPLATE = 'confirm.html' 72 | 73 | EMAIL_PASSWORD_SUBJECT = 'Confirm your password change {{ user.username }}' 74 | EMAIL_PASSWORD_HTML = 'password.html' 75 | EMAIL_PASSWORD_PLAIN = 'plainpassword.txt' 76 | 77 | EMAIL_PASSWORD_PAGE_TEMPLATE = 'password_changed.html' 78 | EMAIL_PASSWORD_CHANGE_PAGE_TEMPLATE = 'password_change.html' 79 | 80 | EMAIL_MAIL_TOKEN_LIFE = 60 * 60 81 | EMAIL_PASSWORD_TOKEN_LIFE = 60 * 5 82 | EMAIL_PAGE_DOMAIN = 'https://test.com/' 83 | -------------------------------------------------------------------------------- /django_email_verification/tests/templates/confirm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if success %}Email confirmed{% else %}Error{% endif %} 6 | 7 | 8 |
9 | {% if success %} 10 | {{ user.username }}, your account was confirmed! 11 | {% else %} 12 | Error, invalid token! 13 | {% endif %} 14 |
15 | 16 | -------------------------------------------------------------------------------- /django_email_verification/tests/templates/mail.html: -------------------------------------------------------------------------------- 1 |

You are almost there, {{ user.username }}! {{ expiry|time:"TIME_FORMAT" }}


2 |

Click here to verify your email

-------------------------------------------------------------------------------- /django_email_verification/tests/templates/password.html: -------------------------------------------------------------------------------- 1 |

You are almost there, {{ user.username }}! {{ expiry|time:"TIME_FORMAT" }}


2 |

Click here to verify your password change

-------------------------------------------------------------------------------- /django_email_verification/tests/templates/password_change.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ token }} 6 | 7 | 8 |
9 | {{ token }} 10 |
11 | 12 | -------------------------------------------------------------------------------- /django_email_verification/tests/templates/password_changed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if success %}Email confirmed{% else %}Error{% endif %} 6 | 7 | 8 |
9 | {% if success %} 10 | {{ user.username }}, your account was confirmed! 11 | {% else %} 12 | Error, invalid token! 13 | {% endif %} 14 |
15 | 16 | -------------------------------------------------------------------------------- /django_email_verification/tests/templates/plainmail.txt: -------------------------------------------------------------------------------- 1 | You are almost there, {{ user.username }}! {{ expiry|time:"TIME_FORMAT" }} 2 | Click on the link below to verify your email: 3 | {{ link }} -------------------------------------------------------------------------------- /django_email_verification/tests/templates/plainpassword.txt: -------------------------------------------------------------------------------- 1 | You are almost there, {{ user.username }}! {{ expiry|time:"TIME_FORMAT" }} 2 | Click on the link below to verify your password change: 3 | {{ link }} -------------------------------------------------------------------------------- /django_email_verification/tests/tests.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import time 4 | from datetime import datetime 5 | 6 | import jwt 7 | import pytest 8 | from django.conf import settings 9 | from django.contrib.auth import get_user_model 10 | from django.template.loader import render_to_string 11 | from django.test import Client 12 | 13 | from django_email_verification import send_password, send_email 14 | from django_email_verification.confirm import DJANGO_EMAIL_VERIFICATION_MORE_VIEWS_ERROR, \ 15 | DJANGO_EMAIL_VERIFICATION_MALFORMED_URL, DJANGO_EMAIL_VERIFICATION_URL_ROUTE_ERROR 16 | from django_email_verification.errors import NotAllFieldCompiled, InvalidUserModel 17 | 18 | 19 | class LogHandler(logging.StreamHandler): 20 | def __init__(self, levelname, match, callback): 21 | super().__init__() 22 | self.levelname = levelname 23 | self.match = match 24 | self.callback = callback 25 | 26 | def emit(self, record): 27 | msg = self.format(record) 28 | if record.levelname == self.levelname and msg.startswith(self.match): 29 | self.callback() 30 | 31 | 32 | def get_mail_params(content): 33 | expiry = re.findall(r'\d{1,2}:\d{1,2}', content)[0] 34 | url = re.findall(r'(http|https)://([\w_-]+(?:\.[\w_-]+)+)([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?', 35 | content) 36 | url = url[0][-1] if len(url) > 0 else '' 37 | return url, expiry 38 | 39 | 40 | def check_email_verification(test_user, mailoutbox, client): 41 | send_email(test_user, thread=False) 42 | email = mailoutbox[0] 43 | email_content = email.alternatives[0][0] 44 | time.sleep(1) 45 | url, _ = get_mail_params(email_content) 46 | response = client.get(url) 47 | match = render_to_string('confirm.html', {'success': True, 'user': test_user}) 48 | assert response.content.decode() == match 49 | assert get_user_model().objects.get(email='test@test.com').is_active 50 | 51 | 52 | def check_password_change(test_user, mailoutbox, client): 53 | send_password(test_user, thread=False) 54 | email = mailoutbox[0] 55 | email_content = email.alternatives[0][0] 56 | url, _ = get_mail_params(email_content) 57 | token = url.split('/')[-1] 58 | response = client.get(url) 59 | match = render_to_string('password_change.html', {'token': token}) 60 | assert response.content.decode() == match 61 | 62 | new_password = 'new_password' 63 | response = client.post(url, {'password': new_password}) 64 | match = render_to_string('confirm.html', {'success': True, 'user': test_user}) 65 | assert response.content.decode() == match 66 | assert get_user_model().objects.get(email='test@test.com').check_password(new_password) 67 | 68 | 69 | @pytest.fixture 70 | def client(): 71 | return Client(enforce_csrf_checks=True) 72 | 73 | 74 | @pytest.fixture 75 | def test_user(): 76 | user = get_user_model()(username='test_user', password='test_passwd', email='test@test.com') 77 | return user 78 | 79 | 80 | @pytest.fixture 81 | def wrong_token_template(): 82 | match = render_to_string('confirm.html', {'success': False, 'user': None}) 83 | return match 84 | 85 | 86 | @pytest.fixture 87 | def wrong_password_token_template(): 88 | match = render_to_string('password_changed.html', {'success': False, 'user': None}) 89 | return match 90 | 91 | 92 | @pytest.fixture 93 | def test_user_with_class_method(settings): 94 | def verified_callback(self): 95 | self.is_active = True 96 | 97 | def password_changed(self, password): 98 | self.set_password(password) 99 | 100 | get_user_model().add_to_class('verified_callback', verified_callback) 101 | get_user_model().add_to_class('password_changed', password_changed) 102 | settings.EMAIL_MAIL_CALLBACK = get_user_model().verified_callback 103 | settings.EMAIL_PASSWORD_CALLBACK = get_user_model().password_changed 104 | user = get_user_model()(username='test_user_with_class_method', password='test_passwd', email='test@test.com') 105 | return user 106 | 107 | 108 | @pytest.mark.django_db 109 | def test_params_missing(test_user, settings, client): 110 | with pytest.raises(NotAllFieldCompiled): 111 | settings.EMAIL_FROM_ADDRESS = None 112 | send_email(test_user, thread=False) 113 | with pytest.raises(InvalidUserModel): 114 | send_email(None, thread=False) 115 | with pytest.raises(NotAllFieldCompiled): 116 | settings.EMAIL_MAIL_PAGE_TEMPLATE = None 117 | settings.EMAIL_PAGE_TEMPLATE = None 118 | client.get('/confirm/email/_') 119 | with pytest.raises(NotAllFieldCompiled): 120 | settings.EMAIL_MAIL_TOKEN_LIFE = None 121 | settings.EMAIL_TOKEN_LIFE = None 122 | send_email(test_user) 123 | with pytest.raises(NotAllFieldCompiled): 124 | settings.EMAIL_PASSWORD_CHANGE_PAGE_TEMPLATE = None 125 | client.get('/confirm/password/_') 126 | 127 | 128 | @pytest.mark.django_db 129 | def test_email_content(test_user, mailoutbox, settings): 130 | test_user.is_active = False 131 | send_email(test_user, thread=True) 132 | time.sleep(0.5) 133 | email = mailoutbox[0] 134 | email_content = email.alternatives[0][0] 135 | url, expiry = get_mail_params(email_content) 136 | 137 | assert email.subject == re.sub(r'({{.*}})', test_user.username, settings.EMAIL_MAIL_SUBJECT), "The subject changed" 138 | assert email.from_email == settings.EMAIL_FROM_ADDRESS, "The from_address changed" 139 | assert email.to == [test_user.email], "The to_address changed" 140 | assert len(expiry) > 0, f"No expiry time detected, {email_content}" 141 | assert len(url) > 0, "No link detected" 142 | 143 | 144 | @pytest.mark.django_db 145 | def test_email_custom_params(test_user, mailoutbox): 146 | s_expiry = datetime.now() 147 | test_user.is_active = False 148 | send_email(test_user, thread=False, expiry=s_expiry) 149 | email = mailoutbox[0] 150 | email_content = email.alternatives[0][0] 151 | _, expiry = get_mail_params(email_content) 152 | expiry = expiry.split(':') 153 | assert s_expiry.time().hour == int(expiry[0]) or s_expiry.time().hour - 12 == int(expiry[0]) 154 | assert s_expiry.time().minute == int(expiry[1]) 155 | 156 | 157 | @pytest.mark.django_db 158 | def test_email_extra_headers(test_user, settings, mailoutbox): 159 | settings.DEBUG = True 160 | s_expiry = datetime.now() 161 | test_user.is_active = False 162 | send_email(test_user, thread=False, expiry=s_expiry) 163 | email = mailoutbox[0] 164 | email_content = email.alternatives[0][0] 165 | link = email.extra_headers['LINK'] 166 | token = email.extra_headers['TOKEN'] 167 | assert link in email_content 168 | assert token in email_content 169 | 170 | 171 | @pytest.mark.django_db 172 | def test_email_correct(test_user, mailoutbox, client): 173 | test_user.is_active = False 174 | check_email_verification(test_user, mailoutbox, client) 175 | 176 | 177 | @pytest.mark.django_db 178 | def test_email_correct_user_model_method(test_user_with_class_method, mailoutbox, client): 179 | test_user_with_class_method.is_active = False 180 | assert hasattr(get_user_model(), settings.EMAIL_MAIL_CALLBACK.__name__) 181 | check_email_verification(test_user_with_class_method, mailoutbox, client) 182 | 183 | 184 | @pytest.mark.django_db 185 | def test_email_correct_multi_user(mailoutbox, settings, client): 186 | setattr(settings, 'EMAIL_MULTI_USER', True) 187 | test_user_1 = get_user_model().objects.create(username='test_user_1', password='test_passwd_1', 188 | email='test@test.com') 189 | test_user_2 = get_user_model().objects.create(username='test_user_2', password='test_passwd_2', 190 | email='test@test.com') 191 | test_user_1.is_active = False 192 | test_user_2.is_active = False 193 | test_user_1.save() 194 | test_user_2.save() 195 | send_email(test_user_1, thread=False) 196 | email = mailoutbox[0] 197 | email_content = email.alternatives[0][0] 198 | url, _ = get_mail_params(email_content) 199 | response = client.get(url) 200 | match = render_to_string('confirm.html', {'success': True, 'user': test_user_1}) 201 | assert response.content.decode() == match 202 | assert list(get_user_model().objects.filter(email='test@test.com').values_list('is_active')) == [(True,), (False,)] 203 | 204 | 205 | @pytest.mark.django_db 206 | def test_email_wrong_link(client, wrong_token_template): 207 | url = '/confirm/email/dGVzdEB0ZXN0LmNvbE-agax3s-00348f02fabc98235547361a0fe69129b3b750f5' 208 | response = client.get(url) 209 | assert response.content.decode() == wrong_token_template, "Invalid token accepted" 210 | url = '/confirm/email/_' 211 | response = client.get(url) 212 | assert response.content.decode() == wrong_token_template, "Short token accepted" 213 | url = '/confirm/email/dGVzdEB0ZXN0LmNvbE++-agax3sert-00=00348f02fabc98235547361a0fe69129b3b750f5' 214 | response = client.get(url) 215 | assert response.content.decode() == wrong_token_template, "Long token accepted" 216 | 217 | 218 | @pytest.mark.django_db 219 | def test_email_wrong_different_timestamp(test_user, mailoutbox, client, wrong_token_template): 220 | test_user.is_active = False 221 | send_email(test_user, thread=False) 222 | email = mailoutbox[0] 223 | email_content = email.alternatives[0][0] 224 | url, _ = get_mail_params(email_content) 225 | 226 | token = url.split('/') 227 | token[-1] = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJleHAiOjE2NDc3MDgwODUuNzQ4NTM' \ 228 | '5fQ.eubT3GdPIMKXefQeJ8ZWVTjnm5nzt2ehwh9nkdpoCes' 229 | url = '/'.join(token) 230 | 231 | response = client.get(url) 232 | assert response.content.decode() == wrong_token_template 233 | 234 | 235 | @pytest.mark.django_db 236 | def test_email_wrong_user(test_user, client, mailoutbox, wrong_token_template, settings): 237 | test_user.is_active = False 238 | send_email(test_user, thread=False) 239 | email = mailoutbox[0] 240 | email_content = email.alternatives[0][0] 241 | url, _ = get_mail_params(email_content) 242 | 243 | url = url.split('/') 244 | payload = jwt.decode(url[-1], settings.SECRET_KEY, algorithms=['HS256']) 245 | payload.update({'email': 'noemail@test.com'}) 246 | url[-1] = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') 247 | url = '/'.join(url) 248 | 249 | response = client.get(url) 250 | assert response.content.decode() == wrong_token_template 251 | 252 | settings.EMAIL_MULTI_USER = True 253 | test_user.is_active = False 254 | send_email(test_user, thread=False) 255 | email = mailoutbox[0] 256 | email_content = email.alternatives[0][0] 257 | url, _ = get_mail_params(email_content) 258 | 259 | url = url.split('/') 260 | payload = jwt.decode(url[-1], settings.SECRET_KEY, algorithms=['HS256']) 261 | payload.update({'email': 'noemail@test.com'}) 262 | url[-1] = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') 263 | url = '/'.join(url) 264 | 265 | response = client.get(url) 266 | assert response.content.decode() == wrong_token_template 267 | 268 | 269 | @pytest.mark.django_db 270 | def test_email_wrong_expired(test_user, mailoutbox, settings, client, wrong_token_template): 271 | settings.EMAIL_MAIL_TOKEN_LIFE = 1 272 | test_user.is_active = False 273 | send_email(test_user, thread=False) 274 | email = mailoutbox[0] 275 | email_content = email.alternatives[0][0] 276 | url, _ = get_mail_params(email_content) 277 | time.sleep(2) 278 | response = client.get(url) 279 | assert response.content.decode() == wrong_token_template 280 | 281 | 282 | @pytest.mark.urls('django_email_verification.tests.urls_test_1') 283 | @pytest.mark.django_db 284 | def test_log_too_many_verify_view(test_user): 285 | error_raised = False 286 | 287 | def raise_error(): 288 | nonlocal error_raised 289 | error_raised = True 290 | 291 | handler = LogHandler('ERROR', DJANGO_EMAIL_VERIFICATION_MORE_VIEWS_ERROR, raise_error) 292 | logger = logging.getLogger('django_email_verification') 293 | logger.addHandler(handler) 294 | test_user.is_active = False 295 | send_email(test_user, thread=False) 296 | assert error_raised, 'No error raised if multiple views are found' 297 | 298 | 299 | @pytest.mark.urls('django_email_verification.tests.urls_test_2') 300 | @pytest.mark.django_db 301 | def test_log_urls_are_not_setup(test_user): 302 | error_raised = False 303 | 304 | def raise_error(): 305 | nonlocal error_raised 306 | error_raised = True 307 | 308 | handler = LogHandler('ERROR', DJANGO_EMAIL_VERIFICATION_URL_ROUTE_ERROR, raise_error) 309 | logger = logging.getLogger('django_email_verification') 310 | logger.addHandler(handler) 311 | test_user.is_active = False 312 | send_email(test_user, thread=False) 313 | assert error_raised, 'No error raised if url path is missing' 314 | 315 | 316 | @pytest.mark.django_db 317 | def test_log_malformed_link(test_user, settings): 318 | setattr(settings, 'EMAIL_PAGE_DOMAIN', 'abcd') 319 | warning_raised = False 320 | 321 | def raise_warning(): 322 | nonlocal warning_raised 323 | warning_raised = True 324 | 325 | handler = LogHandler('WARNING', DJANGO_EMAIL_VERIFICATION_MALFORMED_URL, raise_warning) 326 | logger = logging.getLogger('django_email_verification') 327 | logger.addHandler(handler) 328 | test_user.is_active = False 329 | send_email(test_user, thread=False) 330 | assert warning_raised, 'No warning raised if malformed url is not detected' 331 | 332 | 333 | @pytest.mark.django_db 334 | def test_password_content(test_user, mailoutbox, settings): 335 | send_password(test_user, thread=True) 336 | time.sleep(0.5) 337 | email = mailoutbox[0] 338 | email_content = email.alternatives[0][0] 339 | url, expiry = get_mail_params(email_content) 340 | 341 | assert email.subject == re.sub(r'({{.*}})', test_user.username, 342 | settings.EMAIL_PASSWORD_SUBJECT), "The subject changed" 343 | assert email.from_email == settings.EMAIL_FROM_ADDRESS, "The from_address changed" 344 | assert email.to == [test_user.email], "The to_address changed" 345 | assert len(expiry) > 0, f"No expiry time detected, {email_content}" 346 | assert len(url) > 0, "No link detected" 347 | 348 | 349 | @pytest.mark.django_db 350 | def test_password_correct(test_user, mailoutbox, client): 351 | check_password_change(test_user, mailoutbox, client) 352 | 353 | 354 | @pytest.mark.django_db 355 | def test_password_correct_user_model_method(test_user_with_class_method, mailoutbox, client): 356 | test_user_with_class_method.is_active = False 357 | assert hasattr(get_user_model(), settings.EMAIL_PASSWORD_CALLBACK.__name__) 358 | check_password_change(test_user_with_class_method, mailoutbox, client) 359 | 360 | 361 | @pytest.mark.django_db 362 | def test_password_wrong_link(client, wrong_password_token_template): 363 | url = '/confirm/password/dGVzdEB0ZXN0LmNvbE-agax3s-00348f02fabc98235547361a0fe69129b3b750f5' 364 | response = client.post(url, {'password': 'test'}) 365 | assert response.content.decode() == wrong_password_token_template, "Invalid token accepted" 366 | 367 | 368 | @pytest.mark.django_db 369 | def test_password_wrong_email_link_used(test_user, mailoutbox, client): 370 | send_email(test_user, thread=False) 371 | email = mailoutbox[0] 372 | email_content = email.alternatives[0][0] 373 | url, _ = get_mail_params(email_content) 374 | url = url.replace('email', 'password') 375 | new_password = 'new_password' 376 | response = client.post(url, {'password': new_password}) 377 | match = render_to_string('confirm.html', {'success': False, 'user': None}) 378 | assert response.content.decode() == match 379 | assert not get_user_model().objects.get(email='test@test.com').check_password(new_password) 380 | 381 | 382 | def test_app_config(): 383 | from .. import apps 384 | assert apps.DjangoEmailConfirmConfig.name == 'django_email_verification', "Wrong App name" 385 | -------------------------------------------------------------------------------- /django_email_verification/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from django_email_verification import urls, verify_email_view 4 | 5 | urlpatterns = [ 6 | path('confirm/', include(urls)), 7 | path('confirm/', verify_email_view(lambda request: None)), 8 | ] 9 | -------------------------------------------------------------------------------- /django_email_verification/tests/urls_test_1.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from django_email_verification import urls, verify_email_view 4 | 5 | urlpatterns = [ 6 | path('confirm/', include(urls)), 7 | path('confirm//', verify_email_view(lambda request, token: None)), 8 | path('named_view/', lambda request: None, name='named_view_name'), 9 | ] 10 | -------------------------------------------------------------------------------- /django_email_verification/tests/urls_test_2.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [ 2 | # url's not included 3 | ] 4 | -------------------------------------------------------------------------------- /django_email_verification/token_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import jwt 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | 7 | 8 | class EmailVerificationTokenGenerator: 9 | """ 10 | Strategy object used to generate and check tokens for the password 11 | reset mechanism. 12 | """ 13 | try: 14 | key_salt = settings.CUSTOM_SALT 15 | except AttributeError: 16 | key_salt = "django-email-verification.token" 17 | algorithm = None 18 | secret = settings.SECRET_KEY 19 | 20 | def make_token(self, user, expiry, **kwargs): 21 | """ 22 | Return a token that can be used once to do a password reset 23 | for the given user. 24 | 25 | Args: 26 | user (Model): the user 27 | expiry (datetime | int): optional forced expiry date 28 | kwargs: extra payload for the token 29 | 30 | Returns: 31 | (tuple): tuple containing: 32 | token (str): the token 33 | expiry (datetime): the expiry datetime 34 | """ 35 | exp = int(expiry.timestamp()) if isinstance(expiry, datetime) else expiry 36 | payload = {'email': user.email, 'exp': exp} 37 | payload.update(**kwargs) 38 | return jwt.encode(payload, self.secret, algorithm='HS256'), datetime.fromtimestamp(exp) 39 | 40 | def check_token(self, token, **kwargs): 41 | """ 42 | Check that a password reset token is correct. 43 | Args: 44 | token (str): the token from the url 45 | kwargs: the extra required payload 46 | 47 | Returns: 48 | (tuple): tuple containing: 49 | valid (bool): True if the token is valid 50 | user (Model): the user model if the token is valid 51 | """ 52 | 53 | try: 54 | payload = jwt.decode(token, self.secret, algorithms=['HS256']) 55 | email, exp = payload['email'], payload['exp'] 56 | 57 | for k, v in kwargs.items(): 58 | if payload[k] != v: 59 | return False, None 60 | 61 | if hasattr(settings, 'EMAIL_MULTI_USER') and settings.EMAIL_MULTI_USER: 62 | users = get_user_model().objects.filter(email=email) 63 | else: 64 | users = [get_user_model().objects.get(email=email)] 65 | except (ValueError, get_user_model().DoesNotExist, jwt.DecodeError, jwt.ExpiredSignatureError): 66 | return False, None 67 | 68 | if not len(users) or users[0] is None: 69 | return False, None 70 | 71 | return True, users[0] 72 | 73 | @staticmethod 74 | def now(): 75 | return datetime.now().timestamp() 76 | 77 | 78 | default_token_generator = EmailVerificationTokenGenerator() 79 | -------------------------------------------------------------------------------- /django_email_verification/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from .views import verify_email_page, verify_password_page 5 | 6 | urlpatterns = [ 7 | path('email/', csrf_exempt(verify_email_page)), 8 | path('password/', csrf_exempt(verify_password_page)), 9 | ] 10 | -------------------------------------------------------------------------------- /django_email_verification/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.handlers.wsgi import WSGIRequest 3 | from django.shortcuts import render 4 | 5 | from .confirm import verify_email_view, verify_email, verify_password_view, verify_password 6 | from .errors import NotAllFieldCompiled 7 | 8 | 9 | @verify_email_view 10 | def verify(request: WSGIRequest, token): 11 | try: 12 | template = settings.EMAIL_MAIL_PAGE_TEMPLATE 13 | success, user = verify_email(token) 14 | return render(request, template, {'success': success, 'user': user, 'request': request}) 15 | except (AttributeError, TypeError): 16 | raise NotAllFieldCompiled('EMAIL_MAIL_PAGE_TEMPLATE field not found') 17 | 18 | 19 | verify_email_page = verify 20 | 21 | 22 | @verify_password_view 23 | def verify_password_page(request: WSGIRequest, token): 24 | try: 25 | if request.method == 'POST' and (pwd := request.POST.get('password')) is not None: 26 | success, user = verify_password(token, pwd) 27 | return render(request, settings.EMAIL_PASSWORD_PAGE_TEMPLATE, 28 | {'success': success, 'user': user, 'request': request}) 29 | return render(request, settings.EMAIL_PASSWORD_CHANGE_PAGE_TEMPLATE, {'token': token, 'request': request}) 30 | except (AttributeError, TypeError): 31 | raise NotAllFieldCompiled('EMAIL_PASSWORD templates field not found') 32 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoneBacciu/django-email-verification/49e841b96e8bd39f0ad359a75be4711508ac4879/icon.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = django_email_verification.tests.settings 3 | python_files = tests.py 4 | addopts = -vv -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.7.2 2 | coverage==7.3.0 3 | deprecation==2.1.0 4 | Django==4.2.5 5 | iniconfig==2.0.0 6 | packaging==23.1 7 | pluggy==1.3.0 8 | PyJWT==2.8.0 9 | pytest==7.4.1 10 | pytest-django==4.5.2 11 | sqlparse==0.4.4 12 | validators==0.22.0 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("LONG_DESCRIPTION.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="django-email-verification", 8 | version="0.3.3", 9 | author="Leone Bacciu", 10 | author_email="leonebacciu@gmail.com", 11 | description="Email confirmation app for django", 12 | license='MIT', 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/LeoneBacciu/django-email-verification", 16 | packages=setuptools.find_packages(exclude=['django_email_verification.tests']), 17 | install_requires=[ 18 | 'deprecation', 19 | 'PyJWT', 20 | 'validators' 21 | ], 22 | classifiers=[ 23 | "Environment :: Web Environment", 24 | "Framework :: Django", 25 | "Intended Audience :: Developers", 26 | "Programming Language :: Python", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: OS Independent", 29 | ], 30 | python_requires='>=3.8', 31 | ) 32 | --------------------------------------------------------------------------------