├── .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 |
91 | {% csrf_token %} 92 | 93 |
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 | Screenshot of default landing page 237 | 238 | **Default error page (`error.html`)** 239 | 240 | Screenshot of default error page 241 | 242 | **Admin view of magic link uses** 243 | 244 | Screenshot of MagicLinkUseInline 245 | -------------------------------------------------------------------------------- /magic_link/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/magic_link/__init__.py -------------------------------------------------------------------------------- /magic_link/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import MagicLink, MagicLinkUse 4 | 5 | 6 | class LoggedInMixin: 7 | """Mixin used to provide a logged_in method for display purposes.""" 8 | 9 | def logged_in(self, obj: MagicLinkUse) -> bool: 10 | """Return True if the object was the one used to login.""" 11 | return obj.timestamp == obj.link.logged_in_at 12 | 13 | logged_in.boolean = True # type: ignore 14 | logged_in.short_description = "Used for login" # type: ignore 15 | 16 | 17 | class MagicLinkUseInline(LoggedInMixin, admin.TabularInline): 18 | model = MagicLinkUse 19 | readonly_fields = ( 20 | "link", 21 | "timestamp", 22 | "session_key", 23 | "remote_addr", 24 | "http_method", 25 | "logged_in", 26 | "error", 27 | ) 28 | exclude = ("ua_string",) 29 | extra = 0 30 | 31 | 32 | class MagicLinkAdmin(admin.ModelAdmin): 33 | list_display = ( 34 | "token", 35 | "user", 36 | "expires_at", 37 | "accessed_at", 38 | "logged_in_at", 39 | "valid", 40 | ) 41 | search_fields = ( 42 | "user__first_name", 43 | "user__last_name", 44 | "user__username", 45 | "token", 46 | ) 47 | raw_id_fields = ("user",) 48 | readonly_fields = ( 49 | "token", 50 | "created_at", 51 | "expires_at", 52 | "accessed_at", 53 | "logged_in_at", 54 | "has_expired", 55 | "has_been_used", 56 | ) 57 | ordering = ("-created_at",) 58 | inlines = (MagicLinkUseInline,) 59 | 60 | def valid(self, obj: MagicLink) -> bool: 61 | # convenience method to enable pretty bool icons 62 | return obj.is_valid 63 | 64 | valid.short_description = "Valid" # type: ignore 65 | valid.boolean = True # type: ignore 66 | 67 | 68 | admin.site.register(MagicLink, MagicLinkAdmin) 69 | 70 | 71 | class MagicLinkUseAdmin(LoggedInMixin, admin.ModelAdmin): 72 | list_display = ("link", "http_method", "session_key", "logged_in") 73 | search_fields = ( 74 | "session_key", 75 | "link__token", 76 | ) 77 | raw_id_fields = ("link",) 78 | readonly_fields = ( 79 | "link", 80 | "timestamp", 81 | "session_key", 82 | "remote_addr", 83 | "http_method", 84 | "ua_string", 85 | "error", 86 | "logged_in", 87 | ) 88 | ordering = ("-timestamp",) 89 | 90 | 91 | admin.site.register(MagicLinkUse, MagicLinkUseAdmin) 92 | -------------------------------------------------------------------------------- /magic_link/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MagicLinkAppConfig(AppConfig): 5 | name = "magic_link" 6 | verbose_name = "Magic links" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /magic_link/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidLink(Exception): 2 | """Generic base exception for invalid link errors.""" 3 | 4 | 5 | class InactiveLink(InvalidLink): 6 | """Raised if link is marked as inactive.""" 7 | 8 | 9 | class ExpiredLink(InvalidLink): 10 | """Raised if link has expired.""" 11 | 12 | 13 | class UsedLink(InvalidLink): 14 | """Raised if the link has already been used.""" 15 | -------------------------------------------------------------------------------- /magic_link/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-06 18:01 2 | import uuid 3 | 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | import magic_link.models 10 | import magic_link.settings 11 | 12 | 13 | class Migration(migrations.Migration): 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="MagicLink", 23 | fields=[ 24 | ( 25 | "id", 26 | models.AutoField( 27 | auto_created=True, 28 | primary_key=True, 29 | serialize=False, 30 | verbose_name="ID", 31 | ), 32 | ), 33 | ( 34 | "token", 35 | models.UUIDField( 36 | default=uuid.uuid4, 37 | editable=False, 38 | help_text="Unique login token", 39 | unique=True, 40 | ), 41 | ), 42 | ( 43 | "redirect_to", 44 | models.CharField( 45 | default=magic_link.settings.DEFAULT_REDIRECT, 46 | help_text="URL to which user will be redirected after logging in. ('/')", 47 | max_length=255, 48 | ), 49 | ), 50 | ( 51 | "created_at", 52 | models.DateTimeField( 53 | default=django.utils.timezone.now, 54 | help_text="When the link was originally created", 55 | ), 56 | ), 57 | ( 58 | "expires_at", 59 | models.DateTimeField( 60 | default=magic_link.models.link_expires_at, 61 | help_text="When the link is due to expire (uses DEFAULT_EXPIRY)", 62 | ), 63 | ), 64 | ( 65 | "is_active", 66 | models.BooleanField( 67 | default=True, help_text="Set to False to deactivate the token" 68 | ), 69 | ), 70 | ( 71 | "user", 72 | models.ForeignKey( 73 | on_delete=django.db.models.deletion.CASCADE, 74 | related_name="magic_links", 75 | to=settings.AUTH_USER_MODEL, 76 | ), 77 | ), 78 | ], 79 | ), 80 | migrations.CreateModel( 81 | name="MagicLinkUse", 82 | fields=[ 83 | ( 84 | "id", 85 | models.AutoField( 86 | auto_created=True, 87 | primary_key=True, 88 | serialize=False, 89 | verbose_name="ID", 90 | ), 91 | ), 92 | ( 93 | "timestamp", 94 | models.DateTimeField( 95 | default=django.utils.timezone.now, 96 | help_text="When the token page was requested", 97 | ), 98 | ), 99 | ("http_method", models.CharField(max_length=10)), 100 | ( 101 | "session_key", 102 | models.CharField( 103 | blank=True, 104 | help_text="The request session identifier", 105 | max_length=40, 106 | ), 107 | ), 108 | ( 109 | "remote_addr", 110 | models.CharField( 111 | blank=True, 112 | help_text="The client IP address, extracted from HttpRequest", 113 | max_length=100, 114 | ), 115 | ), 116 | ( 117 | "ua_string", 118 | models.TextField( 119 | blank=True, 120 | help_text="The client User-Agent, extracted from HttpRequest headers", 121 | ), 122 | ), 123 | ( 124 | "link_is_valid", 125 | models.BooleanField( 126 | default=True, 127 | help_text="Snapshot of parent link is_valid property at the time of use", 128 | ), 129 | ), 130 | ( 131 | "error", 132 | models.CharField( 133 | blank=True, 134 | help_text="If the link use failed the error will be recorded here", 135 | max_length=100, 136 | ), 137 | ), 138 | ( 139 | "link", 140 | models.ForeignKey( 141 | on_delete=django.db.models.deletion.CASCADE, 142 | to="magic_link.MagicLink", 143 | ), 144 | ), 145 | ], 146 | options={ 147 | "get_latest_by": ("timestamp",), 148 | }, 149 | ), 150 | ] 151 | -------------------------------------------------------------------------------- /magic_link/migrations/0002_magiclink_logged_in_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-06 22:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("magic_link", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="magiclink", 14 | name="logged_in_at", 15 | field=models.DateTimeField( 16 | blank=True, help_text="When the link was used to login", null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /magic_link/migrations/0003_magiclink_accessed_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-07 09:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("magic_link", "0002_magiclink_logged_in_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="magiclink", 14 | name="accessed_at", 15 | field=models.DateTimeField( 16 | blank=True, help_text="When the link was first used (GET)", null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /magic_link/migrations/0004_remove_magiclinkuse_link_is_valid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-07 11:06 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("magic_link", "0003_magiclink_accessed_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="magiclinkuse", 14 | name="link_is_valid", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /magic_link/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-magic-link/89b659b499a822102d548aceb6a73e3cfb2f0509/magic_link/migrations/__init__.py -------------------------------------------------------------------------------- /magic_link/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import uuid 5 | 6 | from django.conf import settings 7 | from django.contrib.auth import login 8 | from django.core.exceptions import PermissionDenied 9 | from django.db import models 10 | from django.http import HttpRequest 11 | from django.urls import reverse 12 | from django.utils import timezone 13 | 14 | from .exceptions import ExpiredLink, InactiveLink, InvalidLink, UsedLink 15 | from .settings import ( 16 | AUTHENTICATION_BACKEND, 17 | DEFAULT_EXPIRY, 18 | DEFAULT_REDIRECT, 19 | SESSION_EXPIRY, 20 | ) 21 | 22 | 23 | def parse_remote_addr(request: HttpRequest) -> str: 24 | """Extract client IP from request.""" 25 | x_forwarded_for = request.headers.get("X-Forwarded-For", "") 26 | if x_forwarded_for: 27 | return x_forwarded_for.split(",")[0] 28 | return request.META.get("REMOTE_ADDR", "") 29 | 30 | 31 | def parse_ua_string(request: HttpRequest) -> str: 32 | """Extract client user-agent from request.""" 33 | return request.headers.get("User-Agent", "") 34 | 35 | 36 | def link_expires_at(interval: int = DEFAULT_EXPIRY) -> datetime.datetime: 37 | """Return timestamp used as default link exires_at value.""" 38 | return timezone.now() + datetime.timedelta(seconds=interval) 39 | 40 | 41 | class MagicLink(models.Model): 42 | """A unique token used for magic links.""" 43 | 44 | user = models.ForeignKey( 45 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="magic_links" 46 | ) 47 | token = models.UUIDField( 48 | default=uuid.uuid4, editable=False, unique=True, help_text="Unique login token" 49 | ) 50 | redirect_to = models.CharField( 51 | help_text="URL to which user will be redirected after logging in. ('/')", 52 | max_length=255, 53 | default=DEFAULT_REDIRECT, 54 | ) 55 | created_at = models.DateTimeField( 56 | default=timezone.now, help_text="When the link was originally created" 57 | ) 58 | expires_at = models.DateTimeField( 59 | help_text="When the link is due to expire (uses DEFAULT_EXPIRY)", 60 | default=link_expires_at, 61 | ) 62 | accessed_at = models.DateTimeField( 63 | help_text="When the link was first used (GET)", blank=True, null=True 64 | ) 65 | logged_in_at = models.DateTimeField( 66 | help_text="When the link was used to login", blank=True, null=True 67 | ) 68 | is_active = models.BooleanField( 69 | default=True, help_text="Set to False to deactivate the token" 70 | ) 71 | 72 | def __str__(self) -> str: 73 | return f"Magic link ({self.id}) for {self.user.username}" 74 | 75 | def __repr__(self) -> str: 76 | return f"'" 77 | 78 | def get_absolute_url(self) -> str: 79 | return reverse("magic_link", kwargs={"token": self.token}) 80 | 81 | @property 82 | def has_expired(self) -> bool | None: 83 | """Return True if the link is past its expiry timestamp.""" 84 | if self.expires_at: 85 | return self.expires_at < timezone.now() 86 | return None 87 | 88 | @property 89 | def has_been_used(self) -> bool: 90 | """Return True if the link has been used to login already.""" 91 | return self.logged_in_at is not None 92 | 93 | @property 94 | def is_valid(self) -> bool: 95 | """Return True if the link can be used.""" 96 | return self.is_active and not self.has_expired and not self.has_been_used 97 | 98 | def validate(self) -> None: 99 | """ 100 | Check token and request and raise InvalidLink if necessary. 101 | 102 | This method checks the is_valid property, and if found to be False, 103 | it then runs through the possibilities and raises an appropriate error. 104 | 105 | """ 106 | if not self.is_active: 107 | raise InactiveLink("Link is inactive") 108 | if self.has_expired: 109 | raise ExpiredLink("Link has expired") 110 | if self.has_been_used: 111 | raise UsedLink("Link has already been used") 112 | # theoretically impossible, but belt-and-braces - 113 | # ensures that is_valid and validate method are kept in sync. 114 | if not self.is_valid: 115 | raise InvalidLink("Link is invalid") 116 | 117 | def authorize(self, user: settings.AUTH_USER_MODEL) -> None: 118 | """ 119 | Check the user may access this link. 120 | 121 | Raises PermissionDenied if the user is authenticated already, and is not 122 | the user defined in the link. 123 | 124 | """ 125 | if user.is_authenticated and user != self.user: 126 | raise PermissionDenied("User is already logged in as another user.") 127 | 128 | def login(self, request: HttpRequest) -> None: 129 | """Call login as the link.user.""" 130 | login(request, self.user, backend=AUTHENTICATION_BACKEND) 131 | request.session.set_expiry(SESSION_EXPIRY) 132 | self.logged_in_at = timezone.now() 133 | self.save() 134 | 135 | def disable(self) -> None: 136 | """Disable the link regardless of expiry - used as a kill switch.""" 137 | self.is_active = False 138 | self.save() 139 | 140 | def audit( 141 | self, 142 | request: HttpRequest, 143 | error: InvalidLink | None = None, 144 | timestamp: datetime.datetime | None = None, 145 | ) -> MagicLinkUse: 146 | """ 147 | Create a MagicLinkUse from an HtttpRequest. 148 | 149 | The timestamp parameter is used to force the timestamp of the log to a specific 150 | value - this is useful for aligning logs with parent link values. 151 | 152 | """ 153 | log = MagicLinkUse.objects.create( 154 | link=self, 155 | timestamp=timestamp or timezone.now(), 156 | http_method=request.method, 157 | remote_addr=parse_remote_addr(request), 158 | ua_string=parse_ua_string(request), 159 | session_key=request.session.session_key or "", 160 | error=str(error) if error else "", 161 | ) 162 | if not self.accessed_at: 163 | self.accessed_at = log.timestamp 164 | self.save() 165 | return log 166 | 167 | 168 | class MagicLinkUse(models.Model): 169 | """ 170 | Record the use of a token. 171 | 172 | This model is used for auditing purposes - tracking when the token was 173 | used, recording the IP address and User-Agent of the client, and their 174 | session id. This allows us to perform diagnostics when tokens fail. 175 | 176 | The canonical pattern of use is to have two records per token - the first 177 | being a GET to render the login page, and the second being a POST to log 178 | the user in. 179 | 180 | """ 181 | 182 | link = models.ForeignKey(MagicLink, on_delete=models.CASCADE) 183 | timestamp = models.DateTimeField( 184 | help_text="When the token page was requested", default=timezone.now 185 | ) 186 | http_method = models.CharField( 187 | max_length=10, 188 | ) 189 | session_key = models.CharField( 190 | max_length=40, help_text="The request session identifier", blank=True 191 | ) 192 | remote_addr = models.CharField( 193 | max_length=100, 194 | blank=True, 195 | help_text="The client IP address, extracted from HttpRequest", 196 | ) 197 | ua_string = models.TextField( 198 | help_text="The client User-Agent, extracted from HttpRequest headers", 199 | blank=True, 200 | ) 201 | error = models.CharField( 202 | max_length=100, 203 | help_text="If the link use failed the error will be recorded here", 204 | blank=True, 205 | ) 206 | 207 | class Meta: 208 | get_latest_by = ("timestamp",) 209 | 210 | def __str__(self) -> str: 211 | if self.error: 212 | return f"Magic link ({self.link_id}) failed at {self.timestamp}" 213 | return f"Magic link ({self.link_id}) used at {self.timestamp}" 214 | 215 | def __repr__(self) -> str: 216 | return ( 217 | f"" 219 | ) 220 | -------------------------------------------------------------------------------- /magic_link/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | _settings = getattr(settings, "MAGIC_LINK", {}) 4 | 5 | # default link expiry, in seconds; defaults to 60s / 1 mins 6 | DEFAULT_EXPIRY: int = _settings.get("DEFAULT_EXPIRY", 300) 7 | 8 | # default redirect URL; defaults to 'root' 9 | DEFAULT_REDIRECT: str = _settings.get("DEFAULT_REDIRECT", "/") 10 | 11 | # the authentication backend used when calling the login method 12 | AUTHENTICATION_BACKEND: str = _settings.get( 13 | "AUTHENTICATION_BACKEND", "django.contrib.auth.backends.ModelBackend" 14 | ) 15 | 16 | # override session expiry for magic links only (seconds) 17 | SESSION_EXPIRY: int = _settings.get("SESSION_EXPIRY", settings.SESSION_COOKIE_AGE) 18 | -------------------------------------------------------------------------------- /magic_link/templates/magic_link/error.html: -------------------------------------------------------------------------------- 1 | 2 |

Magic link error

3 |

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 |

Welcome to the magic link login page

11 |

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 |

20 |

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 |
26 | {% csrf_token %} 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /magic_link/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path( 7 | "/", 8 | views.MagicLinkView.as_view(), 9 | name="magic_link", 10 | ), 11 | ] 12 | -------------------------------------------------------------------------------- /magic_link/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.exceptions import PermissionDenied 4 | from django.db import transaction 5 | from django.http import HttpRequest 6 | from django.http.response import HttpResponse, HttpResponseRedirect 7 | from django.shortcuts import get_object_or_404, render 8 | from django.views import View 9 | 10 | from .models import InvalidLink, MagicLink 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class MagicLinkView(View): 16 | def get(self, request: HttpRequest, token: str) -> HttpResponse: 17 | """ 18 | Render login page. 19 | 20 | If the link is invalid, or the user is already logged in, then this 21 | view will raise a PermissionDenied, which will render the 403 template. 22 | 23 | """ 24 | link = get_object_or_404(MagicLink, token=token) 25 | try: 26 | link.validate() 27 | link.authorize(request.user) 28 | except (PermissionDenied, InvalidLink) as ex: 29 | link.audit(request, error=ex) 30 | return render( 31 | request, 32 | template_name="magic_link/error.html", 33 | context={"link": link, "error": ex}, 34 | status=403, 35 | ) 36 | else: 37 | link.audit(request) 38 | return render( 39 | request, 40 | template_name="magic_link/logmein.html", 41 | context={"link": link}, 42 | status=200, 43 | ) 44 | 45 | @transaction.atomic 46 | def post(self, request: HttpRequest, token: str) -> HttpResponse: 47 | """ 48 | Handle the login POST request. 49 | 50 | If the link is invalid, or the user is already logged in, then this 51 | view will raise a PermissionDenied, which will render the 403 template. 52 | 53 | If the use is valid then this will call the login() method, and redirect 54 | the user (now authenticated). 55 | 56 | The token will be rendered inactive at this point. 57 | 58 | """ 59 | link = get_object_or_404(MagicLink, token=token) 60 | try: 61 | link.validate() 62 | link.authorize(request.user) 63 | except (PermissionDenied, InvalidLink) as ex: 64 | link.audit(request, error=ex) 65 | return render( 66 | request, 67 | template_name="magic_link/error.html", 68 | context={"link": link, "error": ex}, 69 | status=403, 70 | ) 71 | else: 72 | link.login(request) 73 | link.disable() 74 | link.audit(request, timestamp=link.logged_in_at) 75 | return HttpResponseRedirect(link.redirect_to) 76 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict_optional=True 3 | ignore_missing_imports=True 4 | follow_imports=silent 5 | warn_redundant_casts=True 6 | warn_unused_ignores = true 7 | warn_unreachable = true 8 | disallow_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | 11 | # Disable mypy for migrations 12 | [mypy-*.migrations.*] 13 | ignore_errors=True 14 | 15 | # Disable mypy for settings 16 | [mypy-*.settings.*] 17 | ignore_errors=True 18 | 19 | # Disable mypy for tests 20 | [mypy-tests.*] 21 | ignore_errors=True 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-magic-link" 3 | version = "1.0.0" 4 | description = "Django app for managing tokenised 'magic link' logins." 5 | license = "MIT" 6 | authors = ["YunoJuno "] 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 | --------------------------------------------------------------------------------