├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .ruff.toml ├── LICENSE ├── README.md ├── magic_link ├── __init__.py ├── admin.py ├── apps.py ├── exceptions.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_magiclink_logged_in_at.py │ ├── 0003_magiclink_accessed_at.py │ ├── 0004_remove_magiclinkuse_link_is_valid.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ └── magic_link │ │ ├── error.html │ │ └── logmein.html ├── urls.py └── views.py ├── manage.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── screenshots ├── admin-inline.png ├── error-page.png └── landing-page.png ├── tests ├── __init__.py ├── auth.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── test_models.py ├── test_views.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = magic_link/* 3 | omit = 4 | magic_link/tests.py 5 | magic_link/migrations/* 6 | magic_link/apps.py 7 | .tox/* 8 | .venv/* 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Python / Django 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | format: 13 | name: Check formatting 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toxenv: [fmt, lint, mypy] 18 | env: 19 | TOXENV: ${{ matrix.toxenv }} 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python (3.11) 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.11" 29 | 30 | - name: Install and run tox 31 | run: | 32 | pip install tox 33 | tox 34 | 35 | checks: 36 | name: Run Django checks 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | toxenv: ["django-checks"] 41 | env: 42 | TOXENV: ${{ matrix.toxenv }} 43 | 44 | steps: 45 | - name: Check out the repository 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Python (3.11) 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.11" 52 | 53 | - name: Install and run tox 54 | run: | 55 | pip install tox 56 | tox 57 | 58 | test: 59 | name: Run tests 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 64 | # build LTS version, next version, HEAD 65 | django: ["32", "42", "50", "main"] 66 | exclude: 67 | - python: "3.8" 68 | django: "50" 69 | - python: "3.8" 70 | django: "main" 71 | - python: "3.9" 72 | django: "50" 73 | - python: "3.9" 74 | django: "main" 75 | - python: "3.10" 76 | django: "main" 77 | - python: "3.11" 78 | django: "32" 79 | - python: "3.12" 80 | django: "32" 81 | 82 | env: 83 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 84 | 85 | steps: 86 | - name: Check out the repository 87 | uses: actions/checkout@v4 88 | 89 | - name: Set up Python ${{ matrix.python }} 90 | uses: actions/setup-python@v4 91 | with: 92 | python-version: ${{ matrix.python }} 93 | 94 | - name: Install and run tox 95 | run: | 96 | pip install tox 97 | tox 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | poetry.lock 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | test.db 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.10.1 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.1.5" 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | 15 | # python static type checking 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.7.0 18 | hooks: 19 | - id: mypy 20 | args: 21 | - --disallow-untyped-defs 22 | - --disallow-incomplete-defs 23 | - --check-untyped-defs 24 | - --no-implicit-optional 25 | - --ignore-missing-imports 26 | - --follow-imports=silent 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 88 2 | ignore = [ 3 | "D100", # Missing docstring in public module 4 | "D101", # Missing docstring in public class 5 | "D102", # Missing docstring in public method 6 | "D103", # Missing docstring in public function 7 | "D104", # Missing docstring in public package 8 | "D105", # Missing docstring in magic method 9 | "D106", # Missing docstring in public nested class 10 | "D107", # Missing docstring in __init__ 11 | "D203", # 1 blank line required before class docstring 12 | "D212", # Multi-line docstring summary should start at the first line 13 | "D213", # Multi-line docstring summary should start at the second line 14 | "D404", # First word of the docstring should not be "This" 15 | "D405", # Section name should be properly capitalized 16 | "D406", # Section name should end with a newline 17 | "D407", # Missing dashed underline after section 18 | "D410", # Missing blank line after section 19 | "D411", # Missing blank line before section 20 | "D412", # No blank lines allowed between a section header and its content 21 | "D416", # Section name should end with a colon 22 | "D417", 23 | "D417", # Missing argument description in the docstring 24 | ] 25 | select = [ 26 | "A", # flake8 builtins 27 | "C9", # mcabe 28 | "D", # pydocstyle 29 | "E", # pycodestyle (errors) 30 | "F", # Pyflakes 31 | "I", # isort 32 | "S", # flake8-bandit 33 | "T2", # flake8-print 34 | "W", # pycodestype (warnings) 35 | ] 36 | 37 | [isort] 38 | combine-as-imports = true 39 | 40 | [mccabe] 41 | max-complexity = 8 42 | 43 | [per-file-ignores] 44 | "*tests/*" = [ 45 | "D205", # 1 blank line required between summary line and description 46 | "D400", # First line should end with a period 47 | "D401", # First line should be in imperative mood 48 | "D415", # First line should end with a period, question mark, or exclamation point 49 | "E501", # Line too long 50 | "E731", # Do not assign a lambda expression, use a def 51 | "S101", # Use of assert detected 52 | "S105", # Possible hardcoded password 53 | "S106", # Possible hardcoded password 54 | "S113", # Probable use of requests call with timeout set to {value} 55 | ] 56 | "*/migrations/*" = [ 57 | "E501", # Line too long 58 | ] 59 | "*/settings.py" = [ 60 | "F403", # from {name} import * used; unable to detect undefined names 61 | "F405", # {name} may be undefined, or defined from star imports: 62 | ] 63 | "*/settings/*" = [ 64 | "F403", # from {name} import * used; unable to detect undefined names 65 | "F405", # {name} may be undefined, or defined from star imports: 66 | ] 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 yunojuno 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Magic Link 2 | 3 | Opinionated Django app for managing "magic link" logins. 4 | 5 | **WARNING** 6 | 7 | If you send a login link to the wrong person, they will gain full access 8 | to the user's account. Use with extreme caution, and do not use this 9 | package without reading the source code and ensuring that you are 10 | comfortable with it. If you have an internal security team, ask them to 11 | look at it before using it. If your clients have security sign-off on 12 | your application, ask them to look at it before using it. 13 | 14 | **/WARNING** 15 | 16 | This app is not intended for general purpose URL tokenisation; it is 17 | designed to support a single use case - so-called "magic link" logins. 18 | 19 | There are lots of alternative apps that can support this use case, 20 | including the project from which this has been extracted - 21 | [`django-request-token`](https://github.com/yunojuno/django-request-token). 22 | The reason for yet another one is to handle the real-world challenge of 23 | URL caching / pre-fetch, where intermediaries use URLs with unintended 24 | consequences. 25 | 26 | This packages supports a very specific model: 27 | 28 | 1. User is sent a link to log them in automatically. 29 | 2. User clicks on the link, and which does a GET request to the URL. 30 | 3. User is presented with a confirmation page, but is _not_ logged in. 31 | 4. User clicks on a button and performs a POST to the same page. 32 | 5. The POST request authenticates the user, and deactivates the token. 33 | 34 | The advantage of this is the email clients do not support POST links, 35 | and any prefetch that attempts a POST will fail the CSRF checks. 36 | 37 | The purpose is to ensure that someone actively, purposefully, clicked on 38 | a link to authenticate themselves. This enables instant deactivation of 39 | the token, so that it can no longer be used. 40 | 41 | In practice, without this check, valid magic links may be requested a 42 | number of times via GET request before the intended recipient even sees 43 | the link. If you use a "max uses" restriction to lock down the link you 44 | may find this limit is hit, and the end user then finds that the link is 45 | inactive. The alternative to this is to remove the use limit and rely 46 | instead on an expiry window. This risks leaving the token active even 47 | after the user has logged in. This package is targeted at this 48 | situation. 49 | 50 | ## Use 51 | 52 | ### Prerequisite: Update settings.py and urls.py 53 | 54 | Add `magic_link` to INSTALLED_APPS in settings.py: 55 | 56 | ```python 57 | INSTALLED_APPS = [ 58 | ... 59 | 'magic_link', 60 | ] 61 | ``` 62 | 63 | Add the `magic_link` urls to urls.py: 64 | 65 | ```python 66 | from magic_link import urls as magic_link_urls 67 | 68 | 69 | urlpatterns = [ 70 | ... 71 | url(r'^magic_link/', include(magic_link_urls)), 72 | ] 73 | ``` 74 | 75 | ### Prerequisite: Override the default templates. 76 | 77 | This package has two HTML templates that must be overridden in your 78 | local application. 79 | 80 | **templates/magic_link/logmein.html** 81 | 82 | This is the landing page that a user sees when they click on the magic 83 | link. You can add any content you like to this page - the only 84 | requirement is that must contains a simple form with a csrf token and a 85 | submit button. This form must POST back to the link URL. The template 86 | render context includes the `link` which has a `get_absolute_url` method 87 | to simplify this: 88 | 89 | ```html 90 |
94 | ``` 95 | 96 | **templates/magic_link/error.html** 97 | 98 | If the link has expired, been used, or is being accessed by someone who 99 | is already logged in, then the `error.html` template will be rendered. 100 | The template context includes `link` and `error`. 101 | 102 | ```html 103 |Error handling magic link {{ link }}: {{ error }}.
104 | ``` 105 | 106 | ### 1. Create a new login link 107 | 108 | The first step in managing magic links is to create one. Links are bound 109 | to a user, and can have a custom post-login redirect URL. 110 | 111 | ```python 112 | # create a link with the default expiry and redirect 113 | link = MagicLink.objects.create(user=user) 114 | 115 | # create a link with a specific redirect 116 | link = MagicLink.objects.create(user=user, redirect_to="/foo") 117 | 118 | # construct a full URL from a MagicLink object and a Django HttpResponse 119 | url = request.build_absolute_uri(link.get_absolute_url()) 120 | ``` 121 | 122 | ### 2. Send the link to the user 123 | 124 | This package does not handle the sending on your behalf - it is your 125 | responsibility to ensure that you send the link to the correct user. If 126 | you send the link to the wrong user, they will have full access to the 127 | link user's account. **YOU HAVE BEEN WARNED**. 128 | 129 | ## Auditing 130 | 131 | A core requirement of this package is to be able to audit the use of 132 | links - for monitoring and analysis. To enable this we have a second 133 | model, `MagicLinkUse`, and we create a new object for every request to a 134 | link URL, _regardless of outcome_. Questions that we want to have 135 | answers for include: 136 | 137 | - How long does it take for users to click on a link? 138 | - How many times is a link used before the POST login? 139 | - How often is a link used _after_ a successful login? 140 | - How often does a link expire before a successful login? 141 | - Can we identify common non-user client requests (email caches, bots, etc)? 142 | - Should we disable links after X non-POST requests? 143 | 144 | In order to facilitate this analysis we denormalise a number of 145 | timestamps from the `MagicLinkUse` object back onto the `MagicLink` 146 | itself: 147 | 148 | - `created_at` - when the record was created in the database 149 | - `accessed_at` - the first GET request to the link URL 150 | - `logged_in_at` - the successful POST 151 | - `expires_at` - the link expiry, set when the link is created. 152 | 153 | Note that the expiry timestamp is **not** updated when the link is used. 154 | This is by design, to retain the original expiry timestamp. 155 | 156 | ### Link validation 157 | 158 | In addition to the timestamp fields, there is a separate boolean flag, 159 | `is_active`. This acts as a "kill switch" that overrides any other 160 | attribute, and it allows a link to be disabled without having to edit 161 | (or destroy) existing timestamp values. You can deactivate all links in 162 | one hit by calling `MagicLink.objects.deactivate()`. 163 | 164 | A link's `is_valid` property combines both `is_active` and timestamp 165 | data to return a bool value that defines whether a link can used, based 166 | on the following criteria: 167 | 168 | 1. The link is active (`is_active`) 169 | 2. The link has not expired (`expires_at`) 170 | 3. The link has not already been used (`logged_in_at`) 171 | 172 | In addition to checking the property `is_valid`, the `validate()` method 173 | will raise an exception based on the specific condition that failed. 174 | This is used by the link view to give feedback to the user on the nature 175 | of the failure. 176 | 177 | ### Request authorization 178 | 179 | If the link's `is_valid` property returns `True`, then the link _can_ be 180 | used. However, this does not mean that the link can be used by anyone. 181 | We do not allow authenticated users to login using someone else's magic 182 | link. The `authorize()` method takes a `User` argument and determines 183 | whether they are authorized to use the link. If the user is 184 | authenticated, and does not match the `link.user`, then a 185 | `PermissionDenied` exception is raised. 186 | 187 | ### Putting it together 188 | 189 | Combining the validation, authorization and auditing, we get a 190 | simplified flow that looks something like this: 191 | 192 | ```python 193 | def get(request, token): 194 | """Render login page.""" 195 | link = get_object_or_404(MagicLink, token=token) 196 | link.validate() 197 | link.authorize(request.user) 198 | link.audit() 199 | return render("logmein.html") 200 | 201 | def post(request, token): 202 | """Handle the login POST.""" 203 | link = get_object_or_404(MagicLink, token=token) 204 | link.validate() 205 | link.authorize(request.user) 206 | link.login(request) 207 | link.disable() 208 | return redirect(link.redirect_to) 209 | ``` 210 | 211 | ## Settings 212 | 213 | Settings are read from a `django.conf.settings` settings dictionary called `MAGIC_LINK`. 214 | 215 | Default settings show below: 216 | 217 | ```python 218 | # settings.py 219 | MAGIC_LINK = { 220 | # link expiry, in seconds 221 | "DEFAULT_EXPIRY": 300, 222 | # default link redirect 223 | "DEFAULT_REDIRECT": "/", 224 | # the preferred authorization backend to use, in the case where you have more 225 | # than one specified in the `settings.AUTHORIZATION_BACKENDS` setting. 226 | "AUTHENTICATION_BACKEND": "django.contrib.auth.backends.ModelBackend", 227 | # SESSION_COOKIE_AGE override for magic-link logins - in seconds (default is 1 week) 228 | "SESSION_EXPIRY": 7 * 24 * 60 * 60 229 | } 230 | ``` 231 | 232 | ## Screenshots 233 | 234 | **Default landing page (`logmein.html`)** 235 | 236 |4 | The following error occurred whilst attempting to use a magic link. 5 |
6 |
7 | error = {{ error }}
8 |
9 |
10 |
--------------------------------------------------------------------------------
/magic_link/templates/magic_link/logmein.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 | 12 | You have arrived on this page via a GET request to a valid magic-link URL. If the link 13 | had expired, or been used already, you would have see an error template in its place. 14 |
15 |
16 | You should override this template (logmein.html
) in your own application.
17 | It is rendered with the relevant link
passed in via the context. The form
18 | should POST to link.get_absolute_url
.
19 |
21 | If you click on the button you will be logged in as 22 | {{ link.user.get_full_name|default:link.user.username }}. 23 |
24 |The link will expire at {{ link.expires_at }}.
25 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /magic_link/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path( 7 | ""]
7 | maintainers = ["YunoJuno "]
8 | readme = "README.md"
9 | homepage = "https://github.com/yunojuno/django-magic-link"
10 | repository = "https://github.com/yunojuno/django-magic-link"
11 | documentation = "https://github.com/yunojuno/django-magic-link"
12 | classifiers = [
13 | "Environment :: Web Environment",
14 | "Framework :: Django",
15 | "Framework :: Django :: 3.2",
16 | "Framework :: Django :: 4.0",
17 | "Framework :: Django :: 4.1",
18 | "Framework :: Django :: 4.2",
19 | "Framework :: Django :: 5.0",
20 | "License :: OSI Approved :: MIT License",
21 | "Operating System :: OS Independent",
22 | "Programming Language :: Python :: 3 :: Only",
23 | "Programming Language :: Python :: 3.8",
24 | "Programming Language :: Python :: 3.9",
25 | "Programming Language :: Python :: 3.10",
26 | "Programming Language :: Python :: 3.11",
27 | "Programming Language :: Python :: 3.12",
28 | ]
29 | packages = [{ include = "magic_link" }]
30 |
31 | [tool.poetry.dependencies]
32 | python = "^3.8"
33 | django = "^3.2 || ^4.0 || ^5.0"
34 |
35 | [tool.poetry.dev-dependencies]
36 | black = "*"
37 | coverage = "*"
38 | freezegun = "*"
39 | mypy = "*"
40 | pre-commit = "*"
41 | pytest = "*"
42 | pytest-cov = "*"
43 | pytest-django = "*"
44 | ruff = "*"
45 | tox = "*"
46 |
47 | [build-system]
48 | requires = ["poetry>=0.12"]
49 | build-backend = "poetry.masonry.api"
50 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = tests.settings
--------------------------------------------------------------------------------
/screenshots/admin-inline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/screenshots/admin-inline.png
--------------------------------------------------------------------------------
/screenshots/error-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/screenshots/error-page.png
--------------------------------------------------------------------------------
/screenshots/landing-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/screenshots/landing-page.png
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/tests/__init__.py
--------------------------------------------------------------------------------
/tests/auth.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.backends import ModelBackend
2 |
3 |
4 | class TestAuthBackend(ModelBackend):
5 | """
6 | Authentication backend used for testing.
7 |
8 | This backend does nothing different from the standard model
9 | backend, but it exists so that we can have multiple backends
10 | in the test config. An early bug was caused by having only a
11 | single backend in testing, but multiple backends in a client
12 | app, which caused the call to login to fail.
13 |
14 | See https://github.com/django/django/blob/3.0.8/django/contrib/auth/__init__.py#L112-L120
15 |
16 | """
17 |
--------------------------------------------------------------------------------
/tests/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.7 on 2023-11-13 13:35
2 |
3 | import django.contrib.auth.models
4 | import django.contrib.auth.validators
5 | import django.utils.timezone
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | initial = True
11 |
12 | dependencies = [
13 | ("auth", "0012_alter_user_first_name_max_length"),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name="TestUser",
19 | fields=[
20 | (
21 | "id",
22 | models.AutoField(
23 | auto_created=True,
24 | primary_key=True,
25 | serialize=False,
26 | verbose_name="ID",
27 | ),
28 | ),
29 | ("password", models.CharField(max_length=128, verbose_name="password")),
30 | (
31 | "last_login",
32 | models.DateTimeField(
33 | blank=True, null=True, verbose_name="last login"
34 | ),
35 | ),
36 | (
37 | "is_superuser",
38 | models.BooleanField(
39 | default=False,
40 | help_text="Designates that this user has all permissions without explicitly assigning them.",
41 | verbose_name="superuser status",
42 | ),
43 | ),
44 | (
45 | "username",
46 | models.CharField(
47 | error_messages={
48 | "unique": "A user with that username already exists."
49 | },
50 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
51 | max_length=150,
52 | unique=True,
53 | validators=[
54 | django.contrib.auth.validators.UnicodeUsernameValidator()
55 | ],
56 | verbose_name="username",
57 | ),
58 | ),
59 | (
60 | "first_name",
61 | models.CharField(
62 | blank=True, max_length=150, verbose_name="first name"
63 | ),
64 | ),
65 | (
66 | "last_name",
67 | models.CharField(
68 | blank=True, max_length=150, verbose_name="last name"
69 | ),
70 | ),
71 | (
72 | "email",
73 | models.EmailField(
74 | blank=True, max_length=254, verbose_name="email address"
75 | ),
76 | ),
77 | (
78 | "is_staff",
79 | models.BooleanField(
80 | default=False,
81 | help_text="Designates whether the user can log into this admin site.",
82 | verbose_name="staff status",
83 | ),
84 | ),
85 | (
86 | "is_active",
87 | models.BooleanField(
88 | default=True,
89 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
90 | verbose_name="active",
91 | ),
92 | ),
93 | (
94 | "date_joined",
95 | models.DateTimeField(
96 | default=django.utils.timezone.now, verbose_name="date joined"
97 | ),
98 | ),
99 | (
100 | "groups",
101 | models.ManyToManyField(
102 | blank=True,
103 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
104 | related_name="user_set",
105 | related_query_name="user",
106 | to="auth.group",
107 | verbose_name="groups",
108 | ),
109 | ),
110 | (
111 | "user_permissions",
112 | models.ManyToManyField(
113 | blank=True,
114 | help_text="Specific permissions for this user.",
115 | related_name="user_set",
116 | related_query_name="user",
117 | to="auth.permission",
118 | verbose_name="user permissions",
119 | ),
120 | ),
121 | ],
122 | options={
123 | "verbose_name": "user",
124 | "verbose_name_plural": "users",
125 | "abstract": False,
126 | },
127 | managers=[
128 | ("objects", django.contrib.auth.models.UserManager()),
129 | ],
130 | ),
131 | ]
132 |
--------------------------------------------------------------------------------
/tests/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/tests/migrations/__init__.py
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractUser
2 |
3 |
4 | class TestUser(AbstractUser):
5 | """Custom user model for testing purposes."""
6 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | from os import path
2 |
3 | DEBUG = True
4 | TEMPLATE_DEBUG = True
5 | USE_TZ = True
6 | USE_L10N = True
7 |
8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}}
9 |
10 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
11 |
12 | INSTALLED_APPS = (
13 | "django.contrib.admin",
14 | "django.contrib.auth",
15 | "django.contrib.contenttypes",
16 | "django.contrib.sessions",
17 | "django.contrib.messages",
18 | "django.contrib.staticfiles",
19 | "magic_link",
20 | "tests",
21 | )
22 |
23 | MIDDLEWARE = [
24 | # default django middleware
25 | "django.contrib.sessions.middleware.SessionMiddleware",
26 | "django.middleware.common.CommonMiddleware",
27 | "django.middleware.csrf.CsrfViewMiddleware",
28 | "django.contrib.auth.middleware.AuthenticationMiddleware",
29 | "django.contrib.messages.middleware.MessageMiddleware",
30 | ]
31 |
32 | AUTHENTICATION_BACKENDS = [
33 | "django.contrib.auth.backends.ModelBackend",
34 | "tests.auth.TestAuthBackend",
35 | ]
36 |
37 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__)))
38 |
39 | TEMPLATES = [
40 | {
41 | "BACKEND": "django.template.backends.django.DjangoTemplates",
42 | "DIRS": [path.join(PROJECT_DIR, "templates")],
43 | "APP_DIRS": True,
44 | "OPTIONS": {
45 | "context_processors": [
46 | "django.contrib.messages.context_processors.messages",
47 | "django.contrib.auth.context_processors.auth",
48 | "django.template.context_processors.request",
49 | ]
50 | },
51 | }
52 | ]
53 |
54 |
55 | STATIC_URL = "/static/"
56 |
57 | SECRET_KEY = "secret"
58 |
59 | LOGGING = {
60 | "version": 1,
61 | "disable_existing_loggers": False,
62 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}},
63 | "handlers": {
64 | "console": {
65 | "level": "DEBUG",
66 | "class": "logging.StreamHandler",
67 | "formatter": "simple",
68 | }
69 | },
70 | "loggers": {
71 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"},
72 | # 'django': {
73 | # 'handlers': ['console'],
74 | # 'propagate': True,
75 | # 'level': 'WARNING',
76 | # },
77 | # 'request_profiler': {
78 | # 'handlers': ['console'],
79 | # 'propagate': True,
80 | # 'level': 'WARNING',
81 | # },
82 | },
83 | }
84 |
85 | ROOT_URLCONF = "tests.urls"
86 |
87 | ###################################################
88 | # django_coverage overrides
89 |
90 | # Specify a list of regular expressions of module paths to exclude
91 | # from the coverage analysis. Examples are ``'tests$'`` and ``'urls$'``.
92 | # This setting is optional.
93 | COVERAGE_MODULE_EXCLUDES = [
94 | "tests$",
95 | "settings$",
96 | "urls$",
97 | "locale$",
98 | "common.views.test",
99 | "__init__",
100 | "django",
101 | "migrations",
102 | "request_profiler.admin",
103 | "request_profiler.signals",
104 | ]
105 | # COVERAGE_REPORT_HTML_OUTPUT_DIR = 'coverage/html'
106 | # COVERAGE_USE_STDOUT = True
107 |
108 | AUTH_USER_MODEL = "tests.TestUser"
109 |
110 | # override default value - so we can check they come through in tests
111 | MAGIC_LINK = {
112 | "DEFAULT_EXPIRY": 600,
113 | "DEFAULT_REDIRECT": "/foo",
114 | "AUTHENTICATION_BACKEND": "django.contrib.auth.backends.ModelBackend",
115 | "SESSION_EXPIRY": 600,
116 | }
117 |
118 | assert DEBUG, "This settings file can only be used with DEBUG=True" # noqa: S101
119 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from unittest import mock
3 |
4 | import freezegun
5 | import pytest
6 | from django.contrib.auth import get_user_model
7 | from django.contrib.auth.models import AnonymousUser
8 | from django.contrib.sessions.backends.base import SessionBase
9 | from django.core.exceptions import PermissionDenied
10 | from django.http.request import HttpRequest
11 | from django.utils import timezone
12 |
13 | from magic_link.exceptions import ExpiredLink, InactiveLink, InvalidLink, UsedLink
14 | from magic_link.models import (
15 | MagicLink,
16 | MagicLinkUse,
17 | parse_remote_addr,
18 | parse_ua_string,
19 | )
20 | from magic_link.settings import AUTHENTICATION_BACKEND, SESSION_EXPIRY
21 |
22 | User = get_user_model()
23 |
24 | # standard "now" time used for freezegun
25 | FREEZE_TIME_NOW = timezone.now()
26 |
27 |
28 | class TestMAgicLinkFunctions:
29 | @pytest.mark.parametrize(
30 | "xff,remote,output",
31 | (
32 | ("", "", ""),
33 | ("127.0.0.1", "", "127.0.0.1"),
34 | ("127.0.0.1,192.168.0.1", "", "127.0.0.1"),
35 | ("127.0.0.1", "192.168.0.1", "127.0.0.1"),
36 | ("", "192.168.0.1", "192.168.0.1"),
37 | ),
38 | )
39 | def test_remote_addr(self, xff, remote, output):
40 | headers = {"X-Forwarded-For": xff} if xff else {}
41 | meta = {"REMOTE_ADDR": remote} if remote else {}
42 | request = mock.Mock(spec=HttpRequest, headers=headers, META=meta)
43 | assert parse_remote_addr(request) == output
44 |
45 | @pytest.mark.parametrize("ua_string", ("", "Chrome"))
46 | def test_ua_string(self, ua_string):
47 | headers = {"User-Agent": ua_string} if ua_string else {}
48 | request = mock.Mock(spec=HttpRequest, headers=headers)
49 | assert parse_ua_string(request) == ua_string
50 |
51 |
52 | class TestMagicLink:
53 | def test_has_been_used(self):
54 | link = MagicLink(user=User(), logged_in_at=None)
55 | assert not link.has_been_used
56 | link.logged_in_at = timezone.now()
57 | assert link.has_been_used
58 |
59 | def test_has_expired(self):
60 | link = MagicLink(user=User(), expires_at=None)
61 | assert link.has_expired is None
62 | link = MagicLink(user=User(), expires_at=timezone.now())
63 | assert link.is_active
64 | assert link.has_expired
65 |
66 | def test_validate__inactive(self):
67 | link = MagicLink(is_active=False)
68 | with pytest.raises(InactiveLink):
69 | link.validate()
70 |
71 | def test_validate__expired(self):
72 | link = MagicLink(expires_at=timezone.now())
73 | with pytest.raises(ExpiredLink):
74 | link.validate()
75 |
76 | def test_validate__used(self):
77 | link = MagicLink(logged_in_at=timezone.now())
78 | with pytest.raises(UsedLink):
79 | link.validate()
80 |
81 | def test_authorize__anonymous(self):
82 | """Check that an anonymous user can access the link."""
83 | user1 = User(id=1)
84 | link = MagicLink(user=user1)
85 | link.authorize(AnonymousUser())
86 |
87 | def test_authorize__link_user(self):
88 | """Check that the link user themselves can access it."""
89 | user1 = User(id=1)
90 | link = MagicLink(user=user1)
91 | link.authorize(user1)
92 |
93 | def test_authorize__user_denied(self):
94 | """Check that an authenticated user cannot use the link."""
95 | user1 = User(id=1)
96 | link = MagicLink(user=user1)
97 | with pytest.raises(PermissionDenied):
98 | link.authorize(User(id=2))
99 |
100 | @freezegun.freeze_time(FREEZE_TIME_NOW)
101 | @pytest.mark.django_db
102 | def test_login(self):
103 | # Regression test only - not functionally useful.
104 | user = User.objects.create_user(username="Fernando")
105 | link = MagicLink(user=user)
106 | assert not link.logged_in_at
107 | session = mock.Mock(spec=SessionBase)
108 | request = mock.Mock(spec=HttpRequest, user=link.user, session=session)
109 | with mock.patch("magic_link.models.login") as mock_login:
110 | link.login(request)
111 | mock_login.assert_called_once_with(
112 | request, link.user, backend=AUTHENTICATION_BACKEND
113 | )
114 | session.set_expiry.assert_called_once_with(SESSION_EXPIRY)
115 | assert link.logged_in_at == FREEZE_TIME_NOW
116 |
117 | @pytest.mark.django_db
118 | def test_disable(self):
119 | user = User.objects.create(username="Bob Loblaw")
120 | link = MagicLink.objects.create(user=user)
121 | assert link.is_active
122 | link.disable()
123 | assert not link.is_active
124 |
125 | @pytest.mark.django_db
126 | def test_audit(self):
127 | user = User.objects.create(username="Job Bluth")
128 | link = MagicLink.objects.create(user=user)
129 | request = mock.Mock(
130 | spec=HttpRequest,
131 | method="GET",
132 | user=user,
133 | headers={"X-Forwarded-For": "127.0.0.1", "User-Agent": "Chrome"},
134 | session=mock.Mock(spec=SessionBase, session_key=""),
135 | )
136 | log = link.audit(request)
137 | assert MagicLinkUse.objects.count() == 1
138 | assert log.link == link
139 | assert log.error == ""
140 | assert link.accessed_at == log.timestamp
141 |
142 | @pytest.mark.django_db
143 | def test_audit__accessed_at(self):
144 | """Check that accessed_at is not overwritten by a second visit."""
145 | user = User.objects.create(username="Job Bluth")
146 | link = MagicLink.objects.create(user=user, accessed_at=FREEZE_TIME_NOW)
147 | request = mock.Mock(
148 | spec=HttpRequest,
149 | method="GET",
150 | user=user,
151 | headers={"X-Forwarded-For": "127.0.0.1", "User-Agent": "Chrome"},
152 | session=mock.Mock(spec=SessionBase, session_key=""),
153 | )
154 | assert timezone.now() != FREEZE_TIME_NOW
155 | _ = link.audit(request)
156 | assert link.accessed_at == FREEZE_TIME_NOW
157 |
158 | @pytest.mark.django_db
159 | def test_audit__timestamp(self):
160 | user = User.objects.create(username="Job Bluth")
161 | link = MagicLink.objects.create(user=user)
162 | request = mock.Mock(
163 | spec=HttpRequest,
164 | method="GET",
165 | user=user,
166 | headers={"X-Forwarded-For": "127.0.0.1", "User-Agent": "Chrome"},
167 | session=mock.Mock(spec=SessionBase, session_key=""),
168 | )
169 | # create a timestamp that differs from 'now'
170 | with freezegun.freeze_time(FREEZE_TIME_NOW):
171 | timestamp = FREEZE_TIME_NOW - datetime.timedelta(seconds=10)
172 | log = link.audit(request, timestamp=timestamp)
173 | assert log.timestamp == timestamp
174 | assert log.timestamp != FREEZE_TIME_NOW
175 |
176 | @pytest.mark.django_db
177 | def test_audit__error(self):
178 | user = User.objects.create(username="Job Bluth")
179 | link = MagicLink.objects.create(user=user)
180 | headers = {"X-Forwarded-For": "127.0.0.1", "User-Agent": "Chrome"}
181 | session = mock.Mock(session_key="")
182 | request = mock.Mock(
183 | spec=HttpRequest, method="GET", user=user, headers=headers, session=session
184 | )
185 | log = link.audit(request, InvalidLink("Test error"))
186 | assert log.link == link
187 | assert log.error == "Test error"
188 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | import pytest
4 | from django.contrib.auth import get_user_model
5 | from django.test import Client
6 |
7 | from magic_link.models import MagicLink
8 |
9 | User = get_user_model()
10 |
11 |
12 | # we are mocking out the audit method in all these tests as it's orthogonal
13 | # to the core functions of the views. We just need to know that it has been called.
14 | @pytest.mark.django_db
15 | @mock.patch.object(MagicLink, "audit")
16 | class TestMagicLinkViewGet:
17 | def test_get_missing_link_404(self, mock_audit):
18 | client = Client()
19 | response = client.get("/magic-link")
20 | assert response.status_code == 404
21 | assert mock_audit.call_count == 0
22 |
23 | def test_get_invalid_link_403(self, mock_audit):
24 | client = Client()
25 | user = User.objects.create(username="Bob Loblaw")
26 | link = MagicLink.objects.create(user=user, is_active=False)
27 | response = client.get(link.get_absolute_url())
28 | assert response.status_code == 403
29 | assert mock_audit.call_count == 1
30 |
31 | def test_get_invalid_request_403(self, mock_audit):
32 | client = Client()
33 | user = User.objects.create(username="Bob Loblaw")
34 | user2 = User.objects.create(username="Job")
35 | link = MagicLink.objects.create(user=user)
36 | client.force_login(user2)
37 | response = client.get(link.get_absolute_url())
38 | assert response.status_code == 403
39 | assert mock_audit.call_count == 1
40 |
41 | def test_get_valid_request_200(self, mock_audit):
42 | client = Client()
43 | user = User.objects.create(username="Bob Loblaw")
44 | link = MagicLink.objects.create(user=user)
45 | response = client.get(link.get_absolute_url())
46 | assert response.status_code == 200
47 | assert mock_audit.call_count == 1
48 |
49 |
50 | @pytest.mark.django_db
51 | @mock.patch.object(MagicLink, "audit")
52 | class TestMagicLinkViewPost:
53 | def test_post_missing_link_404(self, mock_audit):
54 | client = Client()
55 | response = client.post("/magic-link")
56 | assert response.status_code == 404
57 | assert mock_audit.call_count == 0
58 |
59 | def test_post_invalid_link_403(self, mock_audit):
60 | client = Client()
61 | user = User.objects.create(username="Bob Loblaw")
62 | link = MagicLink.objects.create(user=user, is_active=False)
63 | response = client.post(link.get_absolute_url())
64 | assert response.status_code == 403
65 | assert mock_audit.call_count == 1
66 |
67 | def test_post_invalid_request_403(self, mock_audit):
68 | client = Client()
69 | user = User.objects.create(username="Bob Loblaw")
70 | user2 = User.objects.create(username="Job")
71 | link = MagicLink.objects.create(user=user)
72 | client.force_login(user2)
73 | response = client.post(link.get_absolute_url())
74 | link.refresh_from_db()
75 | assert response.status_code == 403
76 | assert mock_audit.call_count == 1
77 |
78 | def test_post_valid_request_302(self, mock_audit):
79 | """Check that valid link POST redirects and disables the link."""
80 | client = Client()
81 | user = User.objects.create(username="Bob Loblaw")
82 | link = MagicLink.objects.create(user=user, redirect_to="/foo/bar")
83 | response = client.post(link.get_absolute_url())
84 | link.refresh_from_db()
85 | assert response.status_code == 302
86 | assert response.url == link.redirect_to
87 | assert not link.is_active
88 | assert link.logged_in_at is not None
89 | assert mock_audit.call_count == 1
90 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import include, path
3 |
4 | # import magic_link.urls
5 | admin.autodiscover()
6 |
7 | urlpatterns = [
8 | path("admin/", admin.site.urls),
9 | path("magic-link/", include("magic_link.urls")),
10 | ]
11 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = True
3 | envlist =
4 | fmt, lint, mypy,
5 | django-checks,
6 | ; https://docs.djangoproject.com/en/5.0/releases/
7 | django32-py{38,39,310}
8 | django40-py{38,39,310}
9 | django41-py{38,39,310,311}
10 | django42-py{38,39,310,311}
11 | django50-py{310,311,312}
12 | djangomain-py{311,312}
13 |
14 | [testenv]
15 | deps =
16 | coverage
17 | freezegun
18 | pytest
19 | pytest-cov
20 | pytest-django
21 | django32: Django>=3.2,<3.3
22 | django40: Django>=4.0,<4.1
23 | django41: Django>=4.1,<4.2
24 | django42: Django>=4.2,<4.3
25 | django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz
26 | djangomain: https://github.com/django/django/archive/main.tar.gz
27 |
28 | commands =
29 | pytest --cov=magic_link --verbose tests/
30 |
31 | [testenv:django-checks]
32 | description = Django system checks and missing migrations
33 | deps = Django
34 | commands =
35 | python manage.py check --fail-level WARNING
36 | python manage.py makemigrations --dry-run --check --verbosity 3
37 |
38 | [testenv:fmt]
39 | description = Python source code formatting (black)
40 | deps =
41 | black
42 |
43 | commands =
44 | black --check magic_link
45 |
46 | [testenv:lint]
47 | description = Python source code linting (ruff)
48 | deps =
49 | ruff
50 |
51 | commands =
52 | ruff magic_link
53 |
54 | [testenv:mypy]
55 | description = Python source code type hints (mypy)
56 | deps =
57 | mypy
58 |
59 | commands =
60 | mypy magic_link
61 |
--------------------------------------------------------------------------------