├── .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 | [](https://pypi.org/project/django-email-verification/)
4 | [](https://github.com/LeoneBacciu/django-email-verification/blob/version-0.1.0/LICENSE)
5 | [](https://github.com/LeoneBacciu/django-email-verification/actions)
6 | [](https://codecov.io/gh/LeoneBacciu/django-email-verification)
7 |
8 |
9 |
10 |
11 |
12 |
13 | Do you like my work and want to support me?
14 |
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 | [](https://pypi.org/project/django-email-verification/)
4 | [](https://github.com/LeoneBacciu/django-email-verification/blob/version-0.1.0/LICENSE)
5 | [](https://github.com/LeoneBacciu/django-email-verification/actions)
6 | [](https://codecov.io/gh/LeoneBacciu/django-email-verification)
7 |
8 |
9 |
10 |
11 |
12 |
13 | Do you like my work and want to support me?
14 |
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 |
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 |
477 |
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 |
--------------------------------------------------------------------------------