├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .ruff.toml ├── CHANGELOG ├── LICENSE ├── README.md ├── manage.py ├── mypy.ini ├── poetry.toml ├── pyproject.toml ├── pytest.ini ├── request_token ├── __init__.py ├── admin.py ├── apps.py ├── commands.py ├── context_processors.py ├── decorators.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── truncate_request_token_log.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20151227_1428.py │ ├── 0003_auto_20151229_1105.py │ ├── 0004_remove_requesttoken_target_url.py │ ├── 0005_auto_20160103_1655.py │ ├── 0006_auto_20161104_1428.py │ ├── 0007_add_client_ipv6.py │ ├── 0008_convert_token_data_to_jsonfield.py │ ├── 0009_requesttokenerror.py │ ├── 0010_auto_20170521_1944.py │ ├── 0011_update_model_meta_options.py │ ├── 0012_delete_requesttokenerrorlog.py │ └── __init__.py ├── models.py ├── settings.py ├── templatetags │ ├── __init__.py │ └── request_token_tags.py └── utils.py ├── tests ├── __init__.py ├── integration_tests.py ├── settings.py ├── templates │ └── test_form.html ├── test_admin.py ├── test_commands.py ├── test_context_processors.py ├── test_decorators.py ├── test_middleware.py ├── test_migrations.py ├── test_models.py ├── test_templatetags.py ├── test_utils.py ├── urls.py └── views.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* 4 | request_token/migrations/* 5 | 6 | include = 7 | request_token/* 8 | -------------------------------------------------------------------------------- /.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 | 12 | [*.yaml] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.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 | .coverage 2 | .tox 3 | *.db 4 | *.egg-info 5 | *.pyc 6 | dist 7 | poetry.lock 8 | static 9 | test.db 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.3.0 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.0.262" 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.2.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 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [2.3.1] - 2024-10-23 6 | 7 | - Catch UnicodeDecodeError in middleware [fixes #61] 8 | 9 | ## [2.3] - 2023-11-14 10 | 11 | - Add Django 5.0 to build matrix 12 | - Add Python 3.12 to build matrix 13 | 14 | No code changes. 15 | 16 | ## [2.2] - 2023-11-14 17 | 18 | - Add support for searching in admin by JWT [fixes #59] 19 | - Add support for disabling logging on a per-view basis [fixes #55] 20 | 21 | ## [2.1] - 2022-11-09 22 | 23 | - Add Python 3.11 support 24 | 25 | ## [1.0] - 2022-02-07 26 | 27 | - Add Django 4.0 and Python 3.10 support 28 | - Add `RequestToken.tokenise` method 29 | 30 | ## [0.15] - 2021-06-24 31 | 32 | - Add support for non-default user PK fields (e.g. uuid) - thanks @timomeara 33 | - Add `increment_used_count` method. 34 | 35 | ## [0.14] - 2021-04-14 36 | 37 | ### Removed 38 | 39 | - Logging of token use errors - this has never worked 40 | - Custom 403_TEMPLATE use (and setting) 41 | 42 | ## [0.12] - 2020-08-17 43 | 44 | No functional changes - just updating classifiers and CI support. 45 | 46 | ### Added 47 | 48 | - Add Django 3.1 support 49 | - Add Django master to CI build matrix 50 | 51 | ## [0.10] - 2020-01-26 52 | 53 | ### Added 54 | 55 | - Add `isort` and `black` formatting 56 | - Add `pylint` and `flake8` linting 57 | - Add type hints and `mypy` checks 58 | - Add pre-commit config 59 | 60 | ### Changed 61 | 62 | - Replace pipenv with poetry 63 | 64 | ### Deprecated 65 | 66 | - Django 1.11, 2.0, 2.1 67 | - Python 3.6 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 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 | ## Supported versions 2 | 3 | This project supports Django 3.2+ and Python 3.8+. The latest version 4 | supported is Django 4.1 running on Python 3.11. 5 | 6 | ## Django Request Token 7 | 8 | Django app that uses JWT to manage one-time and expiring tokens to 9 | protect URLs. 10 | 11 | This app currently requires the use of PostgreSQL. 12 | 13 | ### Background 14 | 15 | This project was borne out of our experiences at YunoJuno with 'expiring 16 | links' - which is a common use case of providing users with a URL that 17 | performs a single action, and may bypass standard authentication. A 18 | well-known use of this is the ubiquitous 'unsubscribe' link you find 19 | at the bottom of newsletters. You click on the link and it immediately 20 | unsubscribes you, irrespective of whether you are already authenticated 21 | or not. 22 | 23 | If you google "temporary url", "one-time link" or something similar you 24 | will find lots of StackOverflow articles on supporting this in Django - 25 | it's pretty obvious, you have a dedicated token url, and you store the 26 | tokens in a model - when they are used you expire the token, and it 27 | can't be used again. This works well, but it falls down in a number of 28 | areas: 29 | 30 | * Hard to support multiple endpoints (views) 31 | 32 | If you want to support the same functionality (expiring links) for more 33 | than one view in your project, you either need to have multiple models 34 | and token handlers, or you need to store the specific view function and 35 | args in the model; neither of these is ideal. 36 | 37 | * Hard to debug 38 | 39 | If you use have a single token url view that proxies view functions, you 40 | need to store the function name, args and it then becomes hard to 41 | support - when someone claims that they clicked on 42 | `example.com/t/`, you can't tell what that would resolve to 43 | without looking it up in the database - which doesn't work for customer 44 | support. 45 | 46 | * Hard to support multiple scenarios 47 | 48 | Some links expire, others have usage quotas - some have both. Links may 49 | be for use by a single user, or multiple users. 50 | 51 | This project is intended to provide an easy-to-support mechanism for 52 | 'tokenising' URLs without having to proxy view functions - you can build 53 | well-formed Django URLs and views, and then add request token support 54 | afterwards. 55 | 56 | ### Use Cases 57 | 58 | This project supports three core use cases, each of which is modelled 59 | using the `login_mode` attribute of a request token: 60 | 61 | 1. Public link with payload 62 | 2. ~~Single authenticated request~~ (DEPRECATED: use `django-visitor-pass`) 63 | 3. ~~Auto-login~~ (DEPRECATED: use `django-magic-link`) 64 | 65 | **Public Link** (`RequestToken.LOGIN_MODE_NONE`) 66 | 67 | In this mode (the default for a new token), there is no authentication, 68 | and no assigned user. The token is used as a mechanism for attaching a 69 | payload to the link. An example of this might be a custom registration 70 | or affiliate link, that renders the standard template with additional 71 | information extracted from the token - e.g. the name of the affiliate, 72 | or the person who invited you to register. 73 | 74 | ```python 75 | # a token that can be used to access a public url, without authenticating 76 | # as a user, but carrying a payload (affiliate_id). 77 | token = RequestToken.objects.create_token( 78 | scope="foo", 79 | login_mode=RequestToken.LOGIN_MODE_NONE, 80 | data={ 81 | 'affiliate_id': 1 82 | } 83 | ) 84 | 85 | ... 86 | 87 | @use_request_token(scope="foo") 88 | function view_func(request): 89 | # extract the affiliate id from an token _if_ one is supplied 90 | affiliate_id = ( 91 | request.token.data['affiliate_id'] 92 | if hasattr(request, 'token') 93 | else None 94 | ) 95 | ``` 96 | 97 | **Single Request** (`RequestToken.LOGIN_MODE_REQUEST`) 98 | 99 | In Request mode, the request.user property is overridden by the user 100 | specified in the token, but only for a single request. This is useful 101 | for responding to a single action (e.g. RSVP, unsubscribe). If the user 102 | then navigates onto another page on the site, they will not be 103 | authenticated. If the user is already authenticated, but as a different 104 | user to the one in the token, then they will receive a 403 response. 105 | 106 | ```python 107 | # this token will identify the request.user as a given user, but only for 108 | # a single request - not the entire session. 109 | token = RequestToken.objects.create_token( 110 | scope="foo", 111 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 112 | user=User.objects.get(username="hugo") 113 | ) 114 | 115 | ... 116 | 117 | @use_request_token(scope="foo") 118 | function view_func(request): 119 | assert request.user == User.objects.get(username="hugo") 120 | ``` 121 | **Auto-login** (`RequestToken.LOGIN_MODE_SESSION`) 122 | 123 | This is the nuclear option, and must be treated with extreme care. Using 124 | a Session token will automatically log the user in for an entire 125 | session, giving the user who clicks on the link full access the token 126 | user's account. This is useful for automatic logins. A good example of 127 | this is the email login process on medium.com, which takes an email 128 | address (no password) and sends out a login link. 129 | 130 | Session tokens have a default expiry of ten minutes. 131 | 132 | ```python 133 | # this token will log in as the given user for the entire session - 134 | # NB use with caution. 135 | token = RequestToken.objects.create_token( 136 | scope="foo", 137 | login_mode=RequestToken.LOGIN_MODE_SESSION, 138 | user=User.objects.get(username="hugo") 139 | ) 140 | ``` 141 | 142 | ### Implementation 143 | 144 | The project contains middleware and a view function decorator that 145 | together validate request tokens added to site URLs. 146 | 147 | **request_token.models.RequestToken** - stores the token details 148 | 149 | Step 1 is to create a `RequestToken` - this has various attributes that 150 | can be used to modify its behaviour, and mandatory property - `scope`. 151 | This is a text value - it can be anything you like - it is used by the 152 | function decorator (described below) to confirm that the token given 153 | matches the function being called - i.e. the `token.scope` must match 154 | the function decorator scope kwarg: 155 | 156 | ```python 157 | token = RequestToken(scope="foo") 158 | 159 | # this will raise a 403 without even calling the function 160 | @use_request_token(scope="bar") 161 | def incorrect_scope(request): 162 | pass 163 | 164 | # this will call the function as expected 165 | @use_request_token(scope="foo") 166 | def correct_scope(request): 167 | pass 168 | ``` 169 | 170 | The token itself - the value that must be appended to links as a 171 | querystring argument - is a JWT - and comes from the 172 | `RequestToken.jwt()` method. For example, if you were sending out an 173 | email, you might render the email as an HTML template like this: 174 | 175 | ```html 176 | {% if token %} 177 | click here 180 | {% endif %} 181 | ``` 182 | 183 | If you haven't come across JWT before you can find out more on the 184 | [jwt.io](https://jwt.io/) website. The token produced will include the 185 | following JWT claims (available as the property `RequestToken.claims`: 186 | 187 | * `max`: maximum times the token can be used 188 | * `sub`: the scope 189 | * `mod`: the login mode 190 | * `jti`: the token id 191 | * `aud`: (optional) the user the token represents 192 | * `exp`: (optional) the expiration time of the token 193 | * `iat`: (optional) the time the token was issued 194 | * `ndf`: (optional) the not-before-time of the token 195 | 196 | **request_token.models.RequestTokenLog** - stores usage data for tokens 197 | 198 | Each time a token is used successfully, a log object is written to the 199 | database. This provided an audit log of the usage, and it stores client 200 | IP address and user agent, so can be used to debug issues. This can be 201 | disabled using the `REQUEST_TOKEN_DISABLE_LOGS` setting. The logs table 202 | can be maintained using the management command as described below. 203 | 204 | **request_token.middleware.RequestTokenMiddleware** - decodes and verifies tokens 205 | 206 | The `RequestTokenMiddleware` will look for a querystring token value 207 | (the argument name defaults to 'rt' and can overridden using the 208 | `JWT_QUERYSTRING_ARG` setting), and if it finds one it will verify the 209 | token (using the JWT decode verification). If the token is verified, it 210 | will fetch the token object from the database and perform additional 211 | validation against the token attributes. If the token checks out it is 212 | added to the incoming request as a `token` attribute. This way you can 213 | add arbitrary data (stored on the token) to incoming requests. 214 | 215 | If the token has a user specified, then the `request.user` is updated to 216 | reflect this. The middleware must run after the Django auth middleware, 217 | and before any custom middleware that inspects / monkey-patches the 218 | `request.user`. 219 | 220 | If the token cannot be verified it returns a 403. 221 | 222 | **request_token.decorators.use_request_token** - applies token 223 | permissions to views 224 | 225 | A function decorator that takes one mandatory kwargs (`scope`) and one 226 | optional kwargs (`required`). The `scope` is used to match tokens to 227 | view functions - it's just a straight text match - the value can be 228 | anything you like, but if the token scope is 'foo', then the 229 | corresponding view function decorator scope must match. The `required` 230 | kwarg is used to indicate whether the view **must** have a token in 231 | order to be used, or not. This defaults to False - if a token **is** 232 | provided, then it will be validated, if not, the view function is called 233 | as is. 234 | 235 | If the scopes do not match then a 403 is returned. 236 | 237 | If required is True and no token is provided the a 403 is returned. 238 | 239 | ### Installation 240 | 241 | Download / install the app using pip: 242 | 243 | ```shell 244 | pip install django-request-token 245 | ``` 246 | 247 | Add the app `request_token` to your `INSTALLED_APPS` Django setting: 248 | 249 | ```python 250 | # settings.py 251 | INSTALLED_APPS = ( 252 | 'django.contrib.admin', 253 | 'django.contrib.auth', 254 | 'django.contrib.contenttypes', 255 | 'django.contrib.sessions', 256 | 'django.contrib.messages', 257 | 'django.contrib.staticfiles', 258 | 'request_token', 259 | ... 260 | ) 261 | ``` 262 | 263 | Add the middleware to your settings, **after** the standard 264 | authentication middleware, and before any custom middleware that uses 265 | the `request.user`. 266 | 267 | ```python 268 | MIDDLEWARE_CLASSES = [ 269 | # default django middleware 270 | 'django.contrib.sessions.middleware.SessionMiddleware', 271 | 'django.middleware.common.CommonMiddleware', 272 | 'django.middleware.csrf.CsrfViewMiddleware', 273 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 274 | 'django.contrib.messages.middleware.MessageMiddleware', 275 | 'request_token.middleware.RequestTokenMiddleware', 276 | ] 277 | ``` 278 | 279 | You can now add `RequestToken` objects, either via the shell (or within 280 | your app) or through the admin interface. Once you have added a 281 | `RequestToken` you can add the token JWT to your URLs (using the `jwt()` 282 | method): 283 | 284 | ```python 285 | >>> token = RequestToken.objects.create_token(scope="foo") 286 | >>> url = "https://example.com/foo?rt=" + token.jwt() 287 | ``` 288 | 289 | You now have a request token enabled URL. You can use this token to 290 | protect a view function using the view decorator: 291 | 292 | ```python 293 | @use_request_token(scope="foo") 294 | function foo(request): 295 | pass 296 | ``` 297 | 298 | NB The 'scope' argument to the decorator is used to bind the function to 299 | the incoming token - if someone tries to use a valid token on another 300 | URL, this will return a 403. 301 | 302 | **NB this currently supports only view functions - not class-based views.** 303 | 304 | ### Management commands 305 | 306 | There is a single management command, `truncate_request_token_log` which can 307 | be used to manage the size of the log table (each token usage is logged to 308 | the database). It supports two arguments - `--max-count` and `--max-days` which 309 | are self-explanatory: 310 | 311 | ``` 312 | $ python manage.py truncate_request_token_log --max-count=100 313 | Truncating request token log records: 314 | -> Retaining last 100 request token log records 315 | -> Truncating request token log records from 2021-08-01 00:00:00 316 | -> Truncating 0 request token log records. 317 | $ 318 | ``` 319 | 320 | ### Settings 321 | 322 | * `REQUEST_TOKEN_QUERYSTRING` 323 | 324 | The querystring argument name used to extract the token from incoming 325 | requests, defaults to **rt**. 326 | 327 | * `REQUEST_TOKEN_EXPIRY` 328 | 329 | Session tokens have a default expiry interval, specified in minutes. The 330 | primary use case (above) dictates that the expiry should be no longer 331 | than it takes to receive and open an email, defaults to **10** 332 | (minutes). 333 | 334 | * `REQUEST_TOKEN_403_TEMPLATE` 335 | 336 | Specifying the 403-template so that for prettyfying the 403-response, 337 | in production with a setting like: 338 | 339 | ```python 340 | FOUR03_TEMPLATE = os.path.join(BASE_DIR,'...','403.html') 341 | ``` 342 | 343 | * `REQUEST_TOKEN_DISABLE_LOGS` 344 | 345 | Set to `True` to disable the creation of `RequestTokenLog` objects on 346 | each use of a token. This is not recommended in production, as the 347 | auditing of token use is a valuable part of the library. 348 | 349 | ### Tests 350 | 351 | There is a set of `tox` tests. 352 | 353 | ### License 354 | 355 | MIT 356 | 357 | ### Contributing 358 | 359 | This is by no means complete, however, it's good enough to be of value, hence releasing it. 360 | If you would like to contribute to the project, usual Github rules apply: 361 | 362 | 1. Fork the repo to your own account 363 | 2. Submit a pull request 364 | 3. Add tests for any new code 365 | 4. Follow coding style of existing project 366 | 367 | ### Acknowledgements 368 | 369 | @jpadilla for [PyJWT](https://github.com/jpadilla/pyjwt/) 370 | -------------------------------------------------------------------------------- /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 | # This file is not yet enforced on CI but exists 2 | # here so that we can test our type hinting every 3 | # now and then. When Django and other tooling has 4 | # caught up, the idea is that we block on type 5 | # checking failures via CI. 6 | 7 | [mypy] 8 | check_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | follow_imports = silent 11 | ignore_missing_imports = true 12 | no_implicit_optional = true 13 | python_version = 3.11 14 | show_error_codes = true 15 | strict_equality = true 16 | strict_optional = true 17 | warn_redundant_casts = true 18 | warn_unreachable = true 19 | 20 | # Disable mypy for admin.py files 21 | [mypy-request_token.admin] 22 | ignore_errors=true 23 | 24 | # Disable mypy for migrations 25 | [mypy-request_token.migrations.*] 26 | ignore_errors=true 27 | 28 | # Disable mypy for settings 29 | [mypy-request_token.settings.*] 30 | ignore_errors=true 31 | 32 | # Disable mypy for tests 33 | [mypy-tests.*] 34 | ignore_errors=true 35 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-request-token" 3 | version = "2.3.1" 4 | description = "JWT-backed Django app for managing querystring tokens." 5 | license = "MIT" 6 | authors = ["YunoJuno "] 7 | maintainers = ["YunoJuno "] 8 | readme = "README.md" 9 | homepage = "https://github.com/yunojuno/django-request-token" 10 | repository = "https://github.com/yunojuno/django-request-token" 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Environment :: Web Environment", 14 | "Framework :: Django :: 3.2", 15 | "Framework :: Django :: 4.0", 16 | "Framework :: Django :: 4.1", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.0", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | ] 27 | packages = [{ include = "request_token" }] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.8" 31 | django = "^3.2 || ^4.0 || ^5.0" 32 | pyjwt = "^2.0" 33 | 34 | [tool.poetry.dev-dependencies] 35 | black = "*" 36 | coverage = "*" 37 | ruff = "*" 38 | mypy = "*" 39 | pre-commit = "*" 40 | pytest = "*" 41 | pytest-cov = "*" 42 | pytest-django = "*" 43 | tox = "*" 44 | 45 | [build-system] 46 | requires = ["poetry>=0.12"] 47 | build-backend = "poetry.masonry.api" 48 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | -------------------------------------------------------------------------------- /request_token/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/request_token/__init__.py -------------------------------------------------------------------------------- /request_token/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | from django.contrib import admin 6 | from django.db.models import QuerySet 7 | from django.http import HttpRequest 8 | from django.utils.safestring import mark_safe 9 | from django.utils.timezone import now as tz_now 10 | from jwt.exceptions import DecodeError 11 | 12 | from .models import RequestToken, RequestTokenLog 13 | from .utils import decode, is_jwt 14 | 15 | 16 | def pretty_print(data: dict | None) -> str | None: 17 | """Convert dict into formatted HTML.""" 18 | if data is None: 19 | return None 20 | pretty = json.dumps(data, sort_keys=True, indent=4, separators=(",", ": ")) 21 | html = pretty.replace(" ", " ").replace("\n", "
") 22 | return mark_safe("
%s
" % html) # noqa: S703,S308 23 | 24 | 25 | @admin.register(RequestToken) 26 | class RequestTokenAdmin(admin.ModelAdmin): 27 | """Admin model for RequestToken objects.""" 28 | 29 | list_display = ( 30 | "user", 31 | "scope", 32 | "not_before_time", 33 | "expiration_time", 34 | "max_uses", 35 | "used_to_date", 36 | "issued_at", 37 | "is_valid", 38 | ) 39 | readonly_fields = ("issued_at", "jwt", "_parsed", "_claims", "_data") 40 | search_fields = ( 41 | "user__first_name", 42 | "user__last_name", 43 | "user__email", 44 | "user__username", 45 | "scope", 46 | ) 47 | raw_id_fields = ("user",) 48 | 49 | def get_search_results( 50 | self, request: HttpRequest, queryset: QuerySet[RequestToken], search_term: str 51 | ) -> tuple[QuerySet[RequestToken], bool]: 52 | """Override search to short-circuit if a JWT is identified.""" 53 | if not is_jwt(search_term): 54 | return super().get_search_results(request, queryset, search_term) 55 | try: 56 | pk = decode(search_term)["jti"] 57 | except DecodeError as ex: 58 | self.message_user( 59 | request, 60 | f"Search term interpreted as JWT - but decoding failed: {ex}", 61 | "error", 62 | ) 63 | return super().get_search_results(request, queryset, search_term) 64 | queryset = RequestToken.objects.filter(pk=pk) 65 | if queryset.exists(): 66 | self.message_user( 67 | request, 68 | "Search term interpreted as JWT - match found.", 69 | "success", 70 | ) 71 | else: 72 | self.message_user( 73 | request, 74 | "Search term interpreted as JWT - no match found.", 75 | "error", 76 | ) 77 | return queryset, False 78 | 79 | @admin.display(description="JWT (decoded)") 80 | def _claims(self, obj: RequestToken) -> str | None: 81 | return pretty_print(obj.claims) 82 | 83 | @admin.display(description="Data (JSON)") 84 | def _data(self, obj: RequestToken) -> str | None: 85 | return pretty_print(obj.data) 86 | 87 | @admin.display(description="JWT") 88 | def jwt(self, obj: RequestToken) -> str | None: 89 | try: 90 | return obj.jwt() 91 | except Exception: # noqa: B902 92 | return None 93 | 94 | @admin.display(description="JWT (parsed)") 95 | def _parsed(self, obj: RequestToken) -> str | None: 96 | try: 97 | jwt = obj.jwt().split(".") 98 | return pretty_print( 99 | {"header": jwt[0], "claims": jwt[1], "signature": jwt[2]} 100 | ) 101 | except Exception: # noqa: B902 102 | return None 103 | 104 | def is_valid(self, obj: RequestToken) -> bool: 105 | """Validate the time window and usage.""" 106 | now = tz_now() 107 | if obj.not_before_time and obj.not_before_time > now: 108 | return False 109 | if obj.expiration_time and obj.expiration_time < now: 110 | return False 111 | if obj.used_to_date >= obj.max_uses: 112 | return False 113 | return True 114 | 115 | is_valid.boolean = True # type: ignore 116 | 117 | 118 | @admin.register(RequestTokenLog) 119 | class RequestTokenLogAdmin(admin.ModelAdmin): 120 | """Admin model for RequestTokenLog objects.""" 121 | 122 | list_display = ("token", "user", "status_code", "timestamp") 123 | search_fields = ("user__first_name", "user__username") 124 | raw_id_fields = ("user", "token") 125 | list_filter = ("status_code",) 126 | -------------------------------------------------------------------------------- /request_token/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class RequestTokenAppConfig(AppConfig): 7 | """AppConfig for request_token app.""" 8 | 9 | name = "request_token" 10 | verbose_name = "JWT Request Tokens" 11 | default_auto_field = "django.db.models.AutoField" 12 | -------------------------------------------------------------------------------- /request_token/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import transaction 4 | from django.http import HttpRequest 5 | 6 | from request_token.models import RequestToken, RequestTokenLog 7 | from request_token.settings import DISABLE_LOGS 8 | 9 | 10 | def parse_xff(header_value: str) -> str | None: 11 | """ 12 | Parse out the X-Forwarded-For request header. 13 | 14 | This handles the bug that blows up when multiple IP addresses are 15 | specified in the header. The docs state that the header contains 16 | "The originating IP address", but in reality it contains a list 17 | of all the intermediate addresses. The first item is the original 18 | client, and then any intermediate proxy IPs. We want the original. 19 | 20 | Returns the first IP in the list, else None. 21 | 22 | """ 23 | try: 24 | return header_value.split(",")[0].strip() 25 | except (KeyError, AttributeError): 26 | return None 27 | 28 | 29 | def request_meta(request: HttpRequest) -> dict: 30 | """Extract values from request to be added to log object.""" 31 | user = None if request.user.is_anonymous else request.user 32 | xff = parse_xff(request.META.get("HTTP_X_FORWARDED_FOR")) 33 | remote_addr = request.META.get("REMOTE_ADDR", None) 34 | user_agent = request.META.get("HTTP_USER_AGENT", "unknown") 35 | return {"user": user, "client_ip": xff or remote_addr, "user_agent": user_agent} 36 | 37 | 38 | @transaction.atomic 39 | def log_token_use( 40 | token: RequestToken, request: HttpRequest, status_code: int 41 | ) -> RequestTokenLog | None: 42 | token.increment_used_count() 43 | 44 | if DISABLE_LOGS: 45 | return None 46 | 47 | return RequestTokenLog.objects.create( 48 | token=token, status_code=status_code, **request_meta(request) 49 | ) 50 | -------------------------------------------------------------------------------- /request_token/context_processors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.http import HttpRequest 5 | from django.utils.functional import SimpleLazyObject 6 | 7 | 8 | def request_token(request: HttpRequest) -> dict[str, SimpleLazyObject]: 9 | """Add a request_token to template context (if found on the request).""" 10 | 11 | def _get_val() -> str: 12 | try: 13 | return request.token.jwt() 14 | except AttributeError: 15 | raise ImproperlyConfigured( 16 | "Request has no 'token' attribute - " 17 | "is RequestTokenMiddleware installed?" 18 | ) 19 | 20 | return {"request_token": SimpleLazyObject(_get_val)} 21 | -------------------------------------------------------------------------------- /request_token/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import logging 5 | from typing import Any, Callable 6 | 7 | from django.http import HttpRequest, HttpResponse 8 | 9 | from .commands import log_token_use 10 | from .exceptions import ScopeError, TokenNotFoundError 11 | from .models import RequestToken 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def _get_request_arg(*args: Any) -> HttpRequest | None: 17 | """Extract the arg that is an HttpRequest object.""" 18 | for arg in args: 19 | if isinstance(arg, HttpRequest): 20 | return arg 21 | return None 22 | 23 | 24 | def use_request_token( # noqa: C901 25 | view_func: Callable | None = None, 26 | scope: str | None = None, 27 | required: bool = False, 28 | log: bool = True, 29 | ) -> Callable: 30 | """ 31 | Decorate view functions that supports RequestTokens. 32 | 33 | This decorator is used in conjunction with RequestTokens and the 34 | RequestTokenMiddleware. By the time that a request has passed through the 35 | middleware and reached the view function, if a RequestToken exists it will 36 | have been decoded, and the payload added to the request as `token_payload`. 37 | 38 | This decorator is then used to map the `sub` JWT token claim to the function 39 | using the 'scope' kwarg - the scope must be provided, and must match the 40 | scope of the request token. 41 | 42 | If the 'required' kwarg is True, then the view function expects a valid token 43 | in all cases - and the decorator will raise TokenNotFoundError if one does 44 | not exist. 45 | 46 | If the 'log' kwargs is False then the usage is not logged. 47 | 48 | Errors are trapped and returned as HttpResponseForbidden responses, with 49 | the original error as the `response.error` property. 50 | 51 | For more details on decorators with optional args, see: 52 | https://blogs.it.ox.ac.uk/inapickle/2012/01/05/python-decorators-with-optional-arguments/ 53 | 54 | """ 55 | if not scope: 56 | raise ValueError("Decorator scope cannot be empty.") 57 | 58 | if view_func is None: 59 | return functools.partial( 60 | use_request_token, scope=scope, required=required, log=log 61 | ) 62 | 63 | @functools.wraps(view_func) 64 | def inner(*args: Any, **kwargs: Any) -> HttpResponse: 65 | # HACK: if this is decorating a method, then the first arg will be 66 | # the object (self), and not the request. In order to make this work 67 | # with functions and methods we need to determine where the request 68 | # arg is. 69 | request = _get_request_arg(*args) 70 | token: RequestToken | None = getattr(request, "token", None) 71 | if view_func is None: 72 | raise ValueError("Missing view_func") 73 | 74 | if token is None: 75 | if required is True: 76 | raise TokenNotFoundError() 77 | return view_func(*args, **kwargs) 78 | 79 | if token.scope != scope: 80 | raise ScopeError( 81 | "RequestToken scope mismatch: '{}' != '{}'".format(token.scope, scope) 82 | ) 83 | 84 | token.validate_max_uses() 85 | token.authenticate(request) 86 | response: HttpResponse = view_func(*args, **kwargs) 87 | # this will only log the request here if the view function 88 | # returns a valid HttpResponse object - if the view function 89 | # raises an error, **or this decorator raises an error**, it 90 | # will be handled in the middleware process_exception function, 91 | if log: 92 | log_token_use(token, request, response.status_code) 93 | return response 94 | 95 | return inner 96 | -------------------------------------------------------------------------------- /request_token/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Local exceptions related to tokens. 3 | 4 | These exceptions all inherit from the PyJWT base InvalidTokenError. 5 | 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from jwt.exceptions import InvalidTokenError 11 | 12 | 13 | class MaxUseError(InvalidTokenError): 14 | """Error raised when a token has exceeded its max_use cap.""" 15 | 16 | pass 17 | 18 | 19 | class ScopeError(InvalidTokenError): 20 | """Error raised when a token scope does not match the function scope.""" 21 | 22 | pass 23 | 24 | 25 | class TokenNotFoundError(InvalidTokenError): 26 | """Error raised when a token is expected, but not found.""" 27 | 28 | pass 29 | -------------------------------------------------------------------------------- /request_token/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/request_token/management/__init__.py -------------------------------------------------------------------------------- /request_token/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/request_token/management/commands/__init__.py -------------------------------------------------------------------------------- /request_token/management/commands/truncate_request_token_log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Truncate the request token log table. 3 | 4 | This command should be run on a schedule if you wish to control the size 5 | of the log table. You can control truncation using either count - the 6 | max number of rows to retain, or date - so that logs are only kept for a 7 | period of time. 8 | 9 | """ 10 | 11 | from argparse import ArgumentParser 12 | from datetime import datetime, timedelta 13 | from typing import Any 14 | 15 | from django.core.management.base import BaseCommand 16 | from django.db.models import Min 17 | from django.utils.timezone import now as tz_now 18 | 19 | from request_token.models import RequestTokenLog 20 | 21 | 22 | def get_timestamp_from_count(count: int) -> datetime: 23 | """ 24 | Return timestamp of nth record where n=count. 25 | 26 | This function will always return a datetime, even if there are no 27 | records - defaults to datetime.min. 28 | 29 | """ 30 | if not count: 31 | return datetime.min 32 | return ( 33 | RequestTokenLog.objects.order_by("-id")[:count] 34 | .aggregate(min_timestamp=Min("timestamp")) 35 | .get("min_timestamp") 36 | ) or datetime.min 37 | 38 | 39 | class Command(BaseCommand): 40 | help = "Truncate request token logs." # noqa: A003 41 | 42 | def add_arguments(self, parser: ArgumentParser) -> None: 43 | parser.add_argument( 44 | "--max-count", 45 | type=int, 46 | dest="count", 47 | help="The maximum number of records to retain", 48 | ) 49 | parser.add_argument( 50 | "--max-days", 51 | type=int, 52 | dest="days", 53 | help="The maximum number of days to retain records", 54 | ) 55 | 56 | def handle(self, *args: Any, **options: Any) -> None: 57 | self.stdout.write("Truncating request token log records:") 58 | count = options.get("count") 59 | days = options.get("days") 60 | t1 = t2 = datetime.min 61 | if count: 62 | self.stdout.write(f"-> Retaining last {count} request token log records") 63 | t1 = get_timestamp_from_count(count) 64 | if days: 65 | self.stdout.write( 66 | f"-> Retaining last {days} days' request token log records" 67 | ) 68 | t2 = tz_now() - timedelta(days=days) 69 | timestamp = max(t1, t2) 70 | if timestamp == datetime.min: 71 | self.stdout.write("-> No records available for truncation") 72 | return 73 | self.stdout.write(f"-> Truncating request token log records from {timestamp}") 74 | records = RequestTokenLog.objects.filter(timestamp__lt=timestamp) 75 | self.stdout.write(f"-> Truncating {records.count()} request token log records.") 76 | records.delete() 77 | -------------------------------------------------------------------------------- /request_token/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | from typing import Callable 6 | 7 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied 8 | from django.http.request import HttpRequest 9 | from django.http.response import HttpResponse 10 | from jwt.exceptions import InvalidTokenError 11 | 12 | from .models import RequestToken 13 | from .settings import JWT_QUERYSTRING_ARG 14 | from .utils import decode 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class RequestTokenMiddleware: 20 | """ 21 | Extract and verify request tokens from incoming GET requests. 22 | 23 | This middleware is used to perform initial JWT verfication of 24 | link tokens. 25 | 26 | """ 27 | 28 | def __init__(self, get_response: Callable): 29 | self.get_response = get_response 30 | 31 | def extract_ajax_token(self, request: HttpRequest) -> str | None: 32 | """Extract token from AJAX request.""" 33 | try: 34 | payload = json.loads(request.body) 35 | except json.decoder.JSONDecodeError: 36 | return None 37 | except UnicodeDecodeError: 38 | return None 39 | 40 | try: 41 | return payload.get(JWT_QUERYSTRING_ARG) 42 | except AttributeError: 43 | return None 44 | 45 | def __call__(self, request: HttpRequest) -> HttpResponse: # noqa: C901 46 | """ 47 | Verify JWT request querystring arg. 48 | 49 | If a token is found (using JWT_QUERYSTRING_ARG), then it is decoded, 50 | which verifies the signature and expiry dates, and raises a 403 if 51 | the token is invalid. 52 | 53 | The decoded payload is then added to the request as the `token_payload` 54 | property - allowing it to be interrogated by the view function 55 | decorator when it gets there. 56 | 57 | We don't substitute in the user at this point, as we are not making 58 | any assumptions about the request path at this point - it's not until 59 | we get to the view function that we know where we are heading - at 60 | which point we verify that the scope matches, and only then do we 61 | use the token user. 62 | 63 | """ 64 | if not hasattr(request, "session"): 65 | raise ImproperlyConfigured( 66 | "Request has no session attribute, please ensure that Django " 67 | "session middleware is installed." 68 | ) 69 | if not hasattr(request, "user"): 70 | raise ImproperlyConfigured( 71 | "Request has no user attribute, please ensure that Django " 72 | "authentication middleware is installed." 73 | ) 74 | 75 | if request.method == "GET" or request.method == "POST": 76 | token = request.GET.get(JWT_QUERYSTRING_ARG) 77 | if not token and request.method == "POST": 78 | if request.META.get("CONTENT_TYPE") == "application/json": 79 | token = self.extract_ajax_token(request) 80 | if not token: 81 | token = request.POST.get(JWT_QUERYSTRING_ARG) 82 | else: 83 | token = None 84 | 85 | if token is None: 86 | return self.get_response(request) 87 | 88 | # in the event of an error we log it, but then let the request 89 | # continue - as the fact that the token cannot be decoded, or 90 | # no longer exists, may not invalidate the request itself. 91 | try: 92 | payload = decode(token) 93 | request.token = RequestToken.objects.get(id=payload["jti"]) 94 | except RequestToken.DoesNotExist: 95 | request.token = None 96 | logger.exception("RequestToken no longer exists: %s", payload["jti"]) 97 | except InvalidTokenError: 98 | request.token = None 99 | logger.exception("RequestToken cannot be decoded: %s", token) 100 | 101 | return self.get_response(request) 102 | 103 | def process_exception( 104 | self, request: HttpRequest, exception: Exception 105 | ) -> HttpResponse: 106 | """Handle all InvalidTokenErrors.""" 107 | if isinstance(exception, InvalidTokenError): 108 | logger.exception("JWT request token error, raising 403") 109 | raise PermissionDenied("Invalid request token.") 110 | -------------------------------------------------------------------------------- /request_token/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 7 | 8 | operations = [ 9 | migrations.CreateModel( 10 | name="RequestToken", 11 | fields=[ 12 | ( 13 | "id", 14 | models.AutoField( 15 | verbose_name="ID", 16 | serialize=False, 17 | auto_created=True, 18 | primary_key=True, 19 | ), 20 | ), 21 | ( 22 | "target_url", 23 | models.CharField(help_text="The target endpoint.", max_length=200), 24 | ), 25 | ( 26 | "expiration_time", 27 | models.DateTimeField( 28 | help_text="DateTime at which this token expires.", 29 | null=True, 30 | blank=True, 31 | ), 32 | ), 33 | ( 34 | "not_before_time", 35 | models.DateTimeField( 36 | help_text="DateTime before which this token is invalid.", 37 | null=True, 38 | blank=True, 39 | ), 40 | ), 41 | ( 42 | "data", 43 | models.TextField( 44 | help_text="Custom data (JSON) added to the default payload.", 45 | max_length=1000, 46 | blank=True, 47 | ), 48 | ), 49 | ( 50 | "issued_at", 51 | models.DateTimeField( 52 | help_text="Time the token was created, set in the initial save.", 53 | null=True, 54 | blank=True, 55 | ), 56 | ), 57 | ( 58 | "max_uses", 59 | models.IntegerField( 60 | default=1, 61 | help_text="Cap on the number of times the token can be used, defaults to 1 (single use).", 62 | ), 63 | ), 64 | ( 65 | "used_to_date", 66 | models.IntegerField( 67 | default=0, 68 | help_text="Denormalised count of the number times the token has been used.", 69 | ), 70 | ), 71 | ( 72 | "user", 73 | models.ForeignKey( 74 | blank=True, 75 | to=settings.AUTH_USER_MODEL, 76 | help_text="Intended recipient of the JWT.", 77 | null=True, 78 | on_delete=models.deletion.CASCADE, 79 | ), 80 | ), 81 | ], 82 | ), 83 | migrations.CreateModel( 84 | name="RequestTokenLog", 85 | fields=[ 86 | ( 87 | "id", 88 | models.AutoField( 89 | verbose_name="ID", 90 | serialize=False, 91 | auto_created=True, 92 | primary_key=True, 93 | ), 94 | ), 95 | ( 96 | "user_agent", 97 | models.TextField( 98 | help_text="User-agent of client used to make the request.", 99 | blank=True, 100 | ), 101 | ), 102 | ( 103 | "client_ip", 104 | models.CharField( 105 | help_text="Client IP of device used to make the request.", 106 | max_length=15, 107 | ), 108 | ), 109 | ( 110 | "timestamp", 111 | models.DateTimeField(help_text="Time the request was logged."), 112 | ), 113 | ( 114 | "token", 115 | models.ForeignKey( 116 | help_text="The RequestToken that was used.", 117 | to="request_token.RequestToken", 118 | on_delete=models.deletion.CASCADE, 119 | ), 120 | ), 121 | ( 122 | "user", 123 | models.ForeignKey( 124 | blank=True, 125 | to=settings.AUTH_USER_MODEL, 126 | help_text="The user who made the request (None if anonymous).", 127 | null=True, 128 | on_delete=models.deletion.CASCADE, 129 | ), 130 | ), 131 | ], 132 | ), 133 | ] 134 | -------------------------------------------------------------------------------- /request_token/migrations/0002_auto_20151227_1428.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [("request_token", "0001_initial")] 6 | 7 | operations = [ 8 | migrations.AddField( 9 | model_name="requesttokenlog", 10 | name="status_code", 11 | field=models.IntegerField( 12 | help_text="Response status code associated with this use of the token.", 13 | null=True, 14 | blank=True, 15 | ), 16 | ), 17 | migrations.AlterField( 18 | model_name="requesttoken", 19 | name="data", 20 | field=models.TextField( 21 | default="{}", 22 | help_text="Custom data (JSON) added to the default payload.", 23 | max_length=1000, 24 | blank=True, 25 | ), 26 | ), 27 | migrations.AlterField( 28 | model_name="requesttokenlog", 29 | name="timestamp", 30 | field=models.DateTimeField( 31 | help_text="Time the request was logged.", blank=True 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /request_token/migrations/0003_auto_20151229_1105.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | from ..settings import DEFAULT_MAX_USES 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("request_token", "0002_auto_20151227_1428")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="requesttoken", 13 | name="scope", 14 | field=models.CharField( 15 | default="", 16 | help_text="Label used to match request to view function in decorator.", 17 | max_length=100, 18 | ), 19 | preserve_default=False, 20 | ), 21 | migrations.AlterField( 22 | model_name="requesttoken", 23 | name="expiration_time", 24 | field=models.DateTimeField( 25 | help_text="Token will expire at this time (raises ExpiredSignatureError).", 26 | null=True, 27 | blank=True, 28 | ), 29 | ), 30 | migrations.AlterField( 31 | model_name="requesttoken", 32 | name="issued_at", 33 | field=models.DateTimeField( 34 | help_text="Time the token was created (set in the initial save).", 35 | null=True, 36 | blank=True, 37 | ), 38 | ), 39 | migrations.AlterField( 40 | model_name="requesttoken", 41 | name="max_uses", 42 | field=models.IntegerField( 43 | default=DEFAULT_MAX_USES, 44 | help_text="The maximum number of times the token can be used.", 45 | ), 46 | ), 47 | migrations.AlterField( 48 | model_name="requesttoken", 49 | name="not_before_time", 50 | field=models.DateTimeField( 51 | help_text="Token cannot be used before this time (raises ImmatureSignatureError).", 52 | null=True, 53 | blank=True, 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="requesttoken", 58 | name="used_to_date", 59 | field=models.IntegerField( 60 | default=0, 61 | help_text="Number of times the token has been used to date (raises MaxUseError).", 62 | ), 63 | ), 64 | migrations.AlterField( 65 | model_name="requesttoken", 66 | name="user", 67 | field=models.ForeignKey( 68 | blank=True, 69 | to=settings.AUTH_USER_MODEL, 70 | help_text="Intended recipient of the JWT (can be used by anyone if not set).", 71 | null=True, 72 | on_delete=models.deletion.CASCADE, 73 | ), 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /request_token/migrations/0004_remove_requesttoken_target_url.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [("request_token", "0003_auto_20151229_1105")] 6 | 7 | operations = [migrations.RemoveField(model_name="requesttoken", name="target_url")] 8 | -------------------------------------------------------------------------------- /request_token/migrations/0005_auto_20160103_1655.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [("request_token", "0004_remove_requesttoken_target_url")] 6 | 7 | operations = [ 8 | migrations.AddField( 9 | model_name="requesttoken", 10 | name="login_mode", 11 | field=models.CharField( 12 | default="None", 13 | help_text="How should the request be authenticated?", 14 | max_length=10, 15 | choices=[ 16 | ("None", "Do not authenticate"), 17 | ("Request", "Authenticate a single request"), 18 | ("Session", "Authenticate for the entire session"), 19 | ], 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="requesttoken", 24 | name="data", 25 | field=models.TextField( 26 | default="{}", 27 | help_text="Custom data add to the token, but not encoded (must be fetched from DB).", 28 | max_length=1000, 29 | blank=True, 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /request_token/migrations/0006_auto_20161104_1428.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [("request_token", "0005_auto_20160103_1655")] 6 | 7 | operations = [ 8 | migrations.AlterModelOptions( 9 | name="requesttokenlog", 10 | options={ 11 | "verbose_name": "Token use", 12 | "verbose_name_plural": "Token use logs", 13 | }, 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /request_token/migrations/0007_add_client_ipv6.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | # gets around "django.db.utils.ProgrammingError: column "client_ip" cannot be cast automatically to type inet" 5 | ALTER_SQL = ( 6 | "ALTER TABLE request_token_requesttokenlog " 7 | "ALTER COLUMN client_ip TYPE inet " 8 | "USING client_ip::inet;" 9 | ) 10 | 11 | # if we are using postgresql then we use the ALTER_FIELD version 12 | POSTGRES = ( 13 | settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql_psycopg2" 14 | ) 15 | 16 | 17 | class Migration(migrations.Migration): 18 | dependencies = [("request_token", "0006_auto_20161104_1428")] 19 | 20 | alter_field = migrations.AlterField( 21 | model_name="requesttokenlog", 22 | name="client_ip", 23 | field=models.GenericIPAddressField( 24 | help_text="Client IP of device used to make the request.", 25 | null=True, 26 | unpack_ipv4=True, 27 | blank=True, 28 | ), 29 | ) 30 | 31 | run_sql = migrations.RunSQL( 32 | ALTER_SQL, # run the SQL to alter the column 33 | None, # reverse_sql - none available 34 | state_operations=[alter_field], 35 | ) 36 | 37 | operations = [run_sql] if POSTGRES else [alter_field] 38 | -------------------------------------------------------------------------------- /request_token/migrations/0008_convert_token_data_to_jsonfield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10 on 2017-03-27 14:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("request_token", "0007_add_client_ipv6")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="requesttoken", 12 | name="data", 13 | field=models.JSONField( 14 | blank=True, 15 | default=dict, 16 | help_text="Custom data add to the token, but not encoded (must be fetched from DB).", 17 | null=True, 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="requesttokenlog", 22 | name="token", 23 | field=models.ForeignKey( 24 | help_text="The RequestToken that was used.", 25 | on_delete=models.deletion.CASCADE, 26 | related_name="logs", 27 | to="request_token.RequestToken", 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /request_token/migrations/0009_requesttokenerror.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10 on 2017-05-21 19:33 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("request_token", "0008_convert_token_data_to_jsonfield")] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="RequestTokenErrorLog", 13 | fields=[ 14 | ( 15 | "id", 16 | models.AutoField( 17 | auto_created=True, 18 | primary_key=True, 19 | serialize=False, 20 | verbose_name="ID", 21 | ), 22 | ), 23 | ( 24 | "error_type", 25 | models.CharField( 26 | help_text="The underlying type of error raised.", max_length=50 27 | ), 28 | ), 29 | ( 30 | "error_message", 31 | models.CharField( 32 | help_text="The error message supplied.", max_length=200 33 | ), 34 | ), 35 | ( 36 | "log", 37 | models.OneToOneField( 38 | help_text="The token use against which the error occurred.", 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="error", 41 | to="request_token.RequestTokenLog", 42 | ), 43 | ), 44 | ( 45 | "token", 46 | models.ForeignKey( 47 | help_text="The RequestToken that was used.", 48 | on_delete=django.db.models.deletion.CASCADE, 49 | related_name="errors", 50 | to="request_token.RequestToken", 51 | ), 52 | ), 53 | ], 54 | ) 55 | ] 56 | -------------------------------------------------------------------------------- /request_token/migrations/0010_auto_20170521_1944.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10 on 2017-05-21 19:44 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("request_token", "0009_requesttokenerror")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="requesttoken", 13 | name="user", 14 | field=models.ForeignKey( 15 | blank=True, 16 | help_text="Intended recipient of the JWT (can be used by anyone if not set).", 17 | null=True, 18 | on_delete=models.deletion.CASCADE, 19 | related_name="request_tokens", 20 | to=settings.AUTH_USER_MODEL, 21 | ), 22 | ) 23 | ] 24 | -------------------------------------------------------------------------------- /request_token/migrations/0011_update_model_meta_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.6 on 2017-05-30 11:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("request_token", "0010_auto_20170521_1944")] 8 | 9 | operations = [ 10 | migrations.AlterModelOptions( 11 | name="requesttoken", 12 | options={"verbose_name": "Token", "verbose_name_plural": "Tokens"}, 13 | ), 14 | migrations.AlterModelOptions( 15 | name="requesttokenerrorlog", 16 | options={"verbose_name": "Error", "verbose_name_plural": "Errors"}, 17 | ), 18 | migrations.AlterModelOptions( 19 | name="requesttokenlog", 20 | options={"verbose_name": "Log", "verbose_name_plural": "Logs"}, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /request_token/migrations/0012_delete_requesttokenerrorlog.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-22 18:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("request_token", "0011_update_model_meta_options"), 9 | ] 10 | 11 | operations = [ 12 | migrations.DeleteModel( 13 | name="RequestTokenErrorLog", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /request_token/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/request_token/migrations/__init__.py -------------------------------------------------------------------------------- /request_token/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | from typing import Any 6 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 7 | 8 | from django.conf import settings 9 | from django.contrib.auth import login 10 | from django.core.exceptions import ValidationError 11 | from django.db import models, transaction 12 | from django.db.models import JSONField 13 | from django.http import HttpRequest 14 | from django.utils.timezone import now as tz_now 15 | from django.utils.translation import gettext_lazy as _lazy 16 | from jwt.exceptions import InvalidAudienceError 17 | 18 | from .exceptions import MaxUseError 19 | from .settings import DEFAULT_MAX_USES, JWT_QUERYSTRING_ARG, JWT_SESSION_TOKEN_EXPIRY 20 | from .utils import encode, to_seconds 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class RequestTokenQuerySet(models.query.QuerySet): 26 | """Custom QuerySet for RquestToken objects.""" 27 | 28 | def create_token(self, scope: str, **kwargs: Any) -> RequestToken: 29 | """Create a new RequestToken.""" 30 | return RequestToken(scope=scope, **kwargs).save() 31 | 32 | 33 | class RequestToken(models.Model): 34 | """ 35 | A link token, targeted for use by a known Django User. 36 | 37 | A RequestToken contains information that can be encoded as a JWT 38 | (JSON Web Token). It is designed to be used in conjunction with the 39 | RequestTokenMiddleware (responsible for JWT verification) and the 40 | @use_request_token decorator (responsible for validating the token 41 | and setting the request.user correctly). 42 | 43 | Each token must have a 'scope', which is used to tie it to a view function 44 | that is decorated with the `use_request_token` decorator. The token can 45 | only be used by functions with matching scopes. 46 | 47 | The token may be set to a specific User, in which case, if the existing 48 | request is unauthenticated, it will use that user as the `request.user` 49 | property, allowing access to authenticated views. 50 | 51 | The token may be timebound by the `not_before_time` and `expiration_time` 52 | properties, which are registered JWT 'claims'. 53 | 54 | The token may be restricted by the number of times it can be used, through 55 | the `max_use` property, which is incremented each time it's used (NB *not* 56 | thread-safe). 57 | 58 | The token may also store arbitrary serializable data, which can be used 59 | by the view function if the request token is valid. 60 | 61 | JWT spec: https://tools.ietf.org/html/rfc7519 62 | 63 | """ 64 | 65 | # do not login the user on the request 66 | LOGIN_MODE_NONE = "None" 67 | # login the user, but only for the original request 68 | LOGIN_MODE_REQUEST = "Request" 69 | # login the user fully, but only for single-use short-duration links 70 | LOGIN_MODE_SESSION = "Session" 71 | 72 | LOGIN_MODE_CHOICES = ( 73 | (LOGIN_MODE_NONE, _lazy("Do not authenticate")), 74 | (LOGIN_MODE_REQUEST, _lazy("Authenticate a single request")), 75 | (LOGIN_MODE_SESSION, _lazy("Authenticate for the entire session")), 76 | ) 77 | login_mode = models.CharField( 78 | max_length=10, 79 | default=LOGIN_MODE_NONE, 80 | choices=LOGIN_MODE_CHOICES, 81 | help_text=_lazy("How should the request be authenticated?"), 82 | ) 83 | user = models.ForeignKey( 84 | settings.AUTH_USER_MODEL, 85 | related_name="request_tokens", 86 | blank=True, 87 | null=True, 88 | on_delete=models.CASCADE, 89 | help_text=_lazy( 90 | "Intended recipient of the JWT (can be used by anyone if not set)." 91 | ), 92 | ) 93 | scope = models.CharField( 94 | max_length=100, 95 | help_text=_lazy("Label used to match request to view function in decorator."), 96 | ) 97 | expiration_time = models.DateTimeField( 98 | blank=True, 99 | null=True, 100 | help_text=_lazy( 101 | "Token will expire at this time (raises ExpiredSignatureError)." 102 | ), 103 | ) 104 | not_before_time = models.DateTimeField( 105 | blank=True, 106 | null=True, 107 | help_text=_lazy( 108 | "Token cannot be used before this time (raises ImmatureSignatureError)." 109 | ), 110 | ) 111 | data = JSONField( 112 | help_text=_lazy( 113 | "Custom data add to the token, but not encoded (must be fetched from DB)." 114 | ), 115 | blank=True, 116 | null=True, 117 | default=dict, 118 | ) 119 | issued_at = models.DateTimeField( 120 | blank=True, 121 | null=True, 122 | help_text=_lazy("Time the token was created (set in the initial save)."), 123 | ) 124 | max_uses = models.IntegerField( 125 | default=DEFAULT_MAX_USES, 126 | help_text=_lazy("The maximum number of times the token can be used."), 127 | ) 128 | used_to_date = models.IntegerField( 129 | default=0, 130 | help_text=_lazy( 131 | "Number of times the token has been used to date (raises MaxUseError)." 132 | ), 133 | ) 134 | 135 | objects = RequestTokenQuerySet.as_manager() 136 | 137 | class Meta: 138 | verbose_name = "Token" 139 | verbose_name_plural = "Tokens" 140 | 141 | def __str__(self) -> str: 142 | return "Request token #%s" % (self.id) 143 | 144 | def __repr__(self) -> str: 145 | return "".format( 146 | self.id, 147 | self.scope, 148 | self.login_mode, 149 | ) 150 | 151 | @property 152 | def aud(self) -> int | None: 153 | """Return 'aud' claim, mapped to user.id.""" 154 | return self.claims.get("aud") 155 | 156 | @property 157 | def exp(self) -> datetime.datetime | None: 158 | """Return 'exp' claim, mapped to expiration_time.""" 159 | return self.claims.get("exp") 160 | 161 | @property 162 | def nbf(self) -> datetime.datetime | None: 163 | """Return the 'nbf' claim, mapped to not_before_time.""" 164 | return self.claims.get("nbf") 165 | 166 | @property 167 | def iat(self) -> datetime.datetime | None: 168 | """Return the 'iat' claim, mapped to issued_at.""" 169 | return self.claims.get("iat") 170 | 171 | @property 172 | def jti(self) -> int | None: 173 | """Return the 'jti' claim, mapped to id.""" 174 | return self.claims.get("jti") 175 | 176 | @property 177 | def max(self) -> int: # noqa: A003 178 | """Return the 'max' claim, mapped to max_uses.""" 179 | return self.claims["max"] 180 | 181 | @property 182 | def sub(self) -> str: 183 | """Return the 'sub' claim, mapped to scope.""" 184 | return self.claims["sub"] 185 | 186 | @property 187 | def claims(self) -> dict: 188 | """Return dict containing all of the DEFAULT_CLAIMS (where values exist).""" 189 | claims = { 190 | "max": self.max_uses, 191 | "sub": self.scope, 192 | "mod": self.login_mode[:1].lower(), 193 | } 194 | if self.id is not None: 195 | claims["jti"] = self.id 196 | if self.user is not None: 197 | claims["aud"] = str(self.user.pk) 198 | if self.expiration_time is not None: 199 | claims["exp"] = to_seconds(self.expiration_time) 200 | if self.issued_at is not None: 201 | claims["iat"] = to_seconds(self.issued_at) 202 | if self.not_before_time is not None: 203 | claims["nbf"] = to_seconds(self.not_before_time) 204 | return claims 205 | 206 | def clean(self) -> None: 207 | """Ensure that login_mode setting is valid.""" 208 | if self.login_mode == RequestToken.LOGIN_MODE_NONE: 209 | pass 210 | if self.login_mode == RequestToken.LOGIN_MODE_SESSION: 211 | if self.user is None: 212 | raise ValidationError({"user": "Session token must have a user."}) 213 | 214 | if self.expiration_time is None: 215 | raise ValidationError( 216 | {"expiration_time": "Session token must have an expiration_time."} 217 | ) 218 | if self.login_mode == RequestToken.LOGIN_MODE_REQUEST: 219 | if self.user is None: 220 | raise ValidationError( 221 | {"expiration_time": "Request token must have a user."} 222 | ) 223 | 224 | def save(self, *args: Any, **kwargs: Any) -> RequestToken: 225 | if "update_fields" not in kwargs: 226 | self.issued_at = self.issued_at or tz_now() 227 | if self.login_mode == RequestToken.LOGIN_MODE_SESSION: 228 | self.expiration_time = self.expiration_time or ( 229 | self.issued_at 230 | + datetime.timedelta(minutes=JWT_SESSION_TOKEN_EXPIRY) 231 | ) 232 | self.clean() 233 | super().save(*args, **kwargs) 234 | return self 235 | 236 | def jwt(self) -> str: 237 | """Encode the token claims into a JWT.""" 238 | return encode(self.claims) 239 | 240 | @transaction.atomic 241 | def increment_used_count(self) -> None: 242 | """Add 1 (One) to the used_to_date field.""" 243 | self.used_to_date = models.F("used_to_date") + 1 244 | self.save() 245 | # refresh to clear out the F expression, otherwise we risk 246 | # continuing to update the field. 247 | # https://docs.djangoproject.com/en/3.2/ref/models/expressions/ 248 | self.refresh_from_db() 249 | 250 | def validate_max_uses(self) -> None: 251 | """ 252 | Check the token max_uses is still valid. 253 | 254 | Raises MaxUseError if invalid. 255 | 256 | """ 257 | if self.used_to_date >= self.max_uses: 258 | raise MaxUseError("RequestToken [%s] has exceeded max uses" % self.id) 259 | 260 | def _auth_is_anonymous(self, request: HttpRequest) -> HttpRequest: 261 | """Authenticate anonymous requests.""" 262 | if request.user.is_authenticated: 263 | raise InvalidAudienceError("Token requires anonymous user.") 264 | 265 | if self.login_mode == RequestToken.LOGIN_MODE_NONE: 266 | pass 267 | 268 | if self.login_mode == RequestToken.LOGIN_MODE_REQUEST: 269 | logger.debug( 270 | "Setting request.user to %r from token %i.", self.user, self.id 271 | ) 272 | request.user = self.user 273 | 274 | if self.login_mode == RequestToken.LOGIN_MODE_SESSION: 275 | logger.debug( 276 | "Authenticating request.user as %r from token %i.", self.user, self.id 277 | ) 278 | # I _think_ we can get away with this as we are pulling the 279 | # user out of the DB, and we are explicitly authenticating 280 | # the user. 281 | self.user.backend = "django.contrib.auth.backends.ModelBackend" 282 | login(request, self.user) 283 | 284 | return request 285 | 286 | def _auth_is_authenticated(self, request: HttpRequest) -> HttpRequest: 287 | """Authenticate requests with existing users.""" 288 | if request.user.is_anonymous: 289 | raise InvalidAudienceError("Token requires authenticated user.") 290 | 291 | if self.login_mode == RequestToken.LOGIN_MODE_NONE: 292 | return request 293 | 294 | if request.user == self.user: 295 | return request 296 | 297 | raise InvalidAudienceError( 298 | "RequestToken [%i] audience mismatch: '%s' != '%s'" 299 | % (self.id, request.user, self.user) 300 | ) 301 | 302 | def authenticate(self, request: HttpRequest) -> HttpRequest: 303 | """ 304 | Authenticate an HttpRequest with the token user. 305 | 306 | This method encapsulates the request handling - if the token 307 | has a user assigned, then this will be added to the request. 308 | 309 | """ 310 | if request.user.is_anonymous: 311 | return self._auth_is_anonymous(request) 312 | else: 313 | return self._auth_is_authenticated(request) 314 | 315 | def expire(self) -> None: 316 | """Mark the token as expired immediately, effectively killing the token.""" 317 | self.expiration_time = tz_now() - datetime.timedelta(microseconds=1) 318 | self.save() 319 | 320 | def tokenise(self, url: str) -> str: 321 | """Add token to a base URL.""" 322 | parts = urlparse(url) 323 | qs = parse_qs(parts.query) 324 | # https://docs.python.org/3/library/urllib.parse.html#urllib.parse.parse_qs 325 | # parse_qs response is a dict[str, list[str]] - we want to replace it. 326 | qs[JWT_QUERYSTRING_ARG] = [self.jwt()] 327 | new_parts = ( 328 | parts.scheme, 329 | parts.netloc, 330 | parts.path, 331 | parts.params, 332 | urlencode(qs, doseq=True), 333 | parts.fragment, 334 | ) 335 | return urlunparse(new_parts) 336 | 337 | 338 | class RequestTokenLog(models.Model): 339 | """Used to log the use of a RequestToken.""" 340 | 341 | token = models.ForeignKey( 342 | RequestToken, 343 | related_name="logs", 344 | help_text="The RequestToken that was used.", 345 | on_delete=models.CASCADE, 346 | db_index=True, 347 | ) 348 | user = models.ForeignKey( 349 | settings.AUTH_USER_MODEL, 350 | blank=True, 351 | null=True, 352 | on_delete=models.CASCADE, 353 | help_text="The user who made the request (None if anonymous).", 354 | ) 355 | user_agent = models.TextField( 356 | blank=True, help_text="User-agent of client used to make the request." 357 | ) 358 | client_ip = models.GenericIPAddressField( 359 | blank=True, 360 | null=True, 361 | unpack_ipv4=True, 362 | help_text="Client IP of device used to make the request.", 363 | ) 364 | status_code = models.IntegerField( 365 | blank=True, 366 | null=True, 367 | help_text="Response status code associated with this use of the token.", 368 | ) 369 | timestamp = models.DateTimeField( 370 | blank=True, help_text="Time the request was logged." 371 | ) 372 | 373 | class Meta: 374 | verbose_name = "Log" 375 | verbose_name_plural = "Logs" 376 | 377 | def __str__(self) -> str: 378 | if self.user is None: 379 | return "{} used {}".format(self.token, self.timestamp) 380 | else: 381 | return "{} used by {} at {}".format(self.token, self.user, self.timestamp) 382 | 383 | def __repr__(self) -> str: 384 | return "".format( 385 | self.id, 386 | self.token.id, 387 | self.timestamp, 388 | ) 389 | 390 | def save(self, *args: Any, **kwargs: Any) -> RequestToken: 391 | if "update_fields" not in kwargs: 392 | self.timestamp = self.timestamp or tz_now() 393 | super().save(*args, **kwargs) 394 | return self 395 | -------------------------------------------------------------------------------- /request_token/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | # the name of GET argument to contain the token 4 | JWT_QUERYSTRING_ARG: str = getattr(settings, "REQUEST_TOKEN_QUERYSTRING", "rt") 5 | 6 | # the fixed expiration check on Session tokens 7 | JWT_SESSION_TOKEN_EXPIRY: int = getattr(settings, "REQUEST_TOKEN_EXPIRY", 10) 8 | 9 | DEFAULT_MAX_USES: int = getattr(settings, "REQUEST_TOKEN_DEFAULT_MAX_USES", 10) 10 | 11 | # if True then the RequestTokenLog creation is disabled. 12 | DISABLE_LOGS: bool = getattr(settings, "REQUEST_TOKEN_DISABLE_LOGS", False) 13 | -------------------------------------------------------------------------------- /request_token/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/request_token/templatetags/__init__.py -------------------------------------------------------------------------------- /request_token/templatetags/request_token_tags.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django import template 4 | from django.utils.html import format_html 5 | 6 | from ..settings import JWT_QUERYSTRING_ARG 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.simple_tag(takes_context=True) 12 | def request_token(context: dict) -> str: 13 | """Render a hidden form field containing request token.""" 14 | request_token = context.get("request_token") 15 | if request_token: 16 | return format_html( 17 | '', 18 | JWT_QUERYSTRING_ARG, 19 | request_token, 20 | ) 21 | return "" 22 | 23 | 24 | @register.simple_tag(takes_context=True) 25 | def request_token_querystring(context: dict) -> str: 26 | """Render a query-string with the request token if it exists.""" 27 | request = context["request"] 28 | if getattr(request, "token", None): 29 | return f"?{JWT_QUERYSTRING_ARG}={request.token.jwt()}" 30 | return "" 31 | -------------------------------------------------------------------------------- /request_token/utils.py: -------------------------------------------------------------------------------- 1 | """Basic encode/decode utils, taken from PyJWT.""" 2 | 3 | from __future__ import annotations 4 | 5 | import calendar 6 | import datetime 7 | from typing import Sequence 8 | 9 | from django.conf import settings 10 | from jwt import ( 11 | decode as jwt_decode, 12 | encode as jwt_encode, 13 | exceptions, 14 | get_unverified_header, 15 | ) 16 | 17 | # verification options - signature and expiry date 18 | DEFAULT_DECODE_OPTIONS = { 19 | "verify_signature": True, 20 | "verify_exp": True, 21 | "verify_nbf": True, 22 | "verify_iat": True, 23 | "verify_aud": False, 24 | "verify_iss": False, # we're only validating our own claims 25 | "require_exp": False, 26 | "require_iat": False, 27 | "require_nbf": False, 28 | } 29 | 30 | MANDATORY_CLAIMS = ("jti", "sub", "mod") 31 | 32 | 33 | def check_mandatory_claims( 34 | payload: dict, claims: Sequence[str] = MANDATORY_CLAIMS 35 | ) -> None: 36 | """Check dict for mandatory claims.""" 37 | for claim in claims: 38 | if claim not in payload: 39 | raise exceptions.MissingRequiredClaimError(claim) 40 | 41 | 42 | def encode(payload: dict, check_claims: Sequence[str] = MANDATORY_CLAIMS) -> str: 43 | """Encode JSON payload (using SECRET_KEY).""" 44 | check_mandatory_claims(payload, claims=check_claims) 45 | return jwt_encode(payload, settings.SECRET_KEY) 46 | 47 | 48 | def decode( 49 | token: str, 50 | options: dict[str, bool] | None = None, 51 | check_claims: Sequence[str] | None = None, 52 | algorithms: list[str] | None = None, 53 | ) -> dict: 54 | """Decode JWT payload and check for 'jti', 'sub' claims.""" 55 | if not options: 56 | options = DEFAULT_DECODE_OPTIONS 57 | if not check_claims: 58 | check_claims = MANDATORY_CLAIMS 59 | if not algorithms: 60 | # default encode algorithm - see PyJWT.encode 61 | algorithms = ["HS256"] 62 | decoded = jwt_decode( 63 | token, settings.SECRET_KEY, algorithms=algorithms, options=options 64 | ) 65 | check_mandatory_claims(decoded, claims=check_claims) 66 | return decoded 67 | 68 | 69 | def to_seconds(timestamp: datetime.datetime) -> int | None: 70 | """Convert timestamp into integers since epoch.""" 71 | try: 72 | return calendar.timegm(timestamp.utctimetuple()) 73 | except Exception: # noqa: B902 74 | return None 75 | 76 | 77 | def is_jwt(jwt: str) -> bool: 78 | """Return True if the value supplied is a JWT.""" 79 | if not jwt: 80 | return False 81 | try: 82 | header = get_unverified_header(jwt) 83 | except exceptions.DecodeError: 84 | return False 85 | else: 86 | return header["typ"].lower() == "jwt" 87 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-token/0d3a971b7c7439b3e2125beb3e4a6881a2d16253/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration_tests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.test import Client, TransactionTestCase 6 | from django.urls import reverse 7 | 8 | from request_token.models import RequestToken, RequestTokenLog, tz_now 9 | from request_token.settings import JWT_QUERYSTRING_ARG, JWT_SESSION_TOKEN_EXPIRY 10 | from request_token.templatetags.request_token_tags import request_token 11 | 12 | 13 | def get_url(url_name, token): 14 | """Helper to format urls with tokens.""" 15 | url = reverse("test_app:%s" % url_name) 16 | if token: 17 | url += "?{}={}".format(JWT_QUERYSTRING_ARG, token.jwt()) 18 | return url 19 | 20 | 21 | class ViewTests(TransactionTestCase): 22 | """ 23 | Test the end-to-end use of tokens. 24 | 25 | These tests specifically confirm the way in which request and session tokens 26 | deal with repeated requests. 27 | 28 | """ 29 | 30 | def setUp(self): 31 | self.client = Client() 32 | self.user = get_user_model().objects.create_user("zoidberg") 33 | 34 | def test_request_token(self): 35 | """Test the request tokens only set the user for a single request.""" 36 | token = RequestToken.objects.create_token( 37 | scope="foo", 38 | max_uses=2, 39 | user=self.user, 40 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 41 | ) 42 | response = self.client.get(get_url("decorated", token)) 43 | self.assertEqual(response.status_code, 200) 44 | self.assertEqual(response.request_user, self.user) 45 | self.assertEqual(RequestTokenLog.objects.count(), 1) 46 | 47 | response = self.client.get(get_url("undecorated", None)) 48 | self.assertEqual(response.status_code, 200) 49 | self.assertIsInstance(response.request_user, AnonymousUser) 50 | self.assertEqual(RequestTokenLog.objects.count(), 1) 51 | 52 | def test_session_token(self): 53 | """Test that session tokens set the user for all requests.""" 54 | token = RequestToken.objects.create_token( 55 | scope="foo", 56 | max_uses=1, 57 | user=self.user, 58 | login_mode=RequestToken.LOGIN_MODE_SESSION, 59 | expiration_time=( 60 | datetime.now() + timedelta(minutes=JWT_SESSION_TOKEN_EXPIRY) 61 | ), 62 | ) 63 | 64 | response = self.client.get(get_url("decorated", token)) 65 | self.assertEqual(response.status_code, 200) 66 | self.assertEqual(response.request_user, self.user) 67 | self.assertEqual(RequestTokenLog.objects.count(), 1) 68 | 69 | # for a session token, all future requests should also be authenticated 70 | response = self.client.get(get_url("undecorated", None)) 71 | self.assertEqual(response.status_code, 200) 72 | self.assertEqual(response.request_user, self.user) 73 | self.assertEqual(RequestTokenLog.objects.count(), 1) 74 | 75 | def test_get_post_token(self): 76 | """Test the GET > POST chain.""" 77 | token = RequestToken.objects.create_token( 78 | scope="bar", login_mode=RequestToken.LOGIN_MODE_NONE, max_uses=100 79 | ) 80 | # expiry not set - we will do that explicitly in the POST 81 | self.assertTrue(token.expiration_time is None) 82 | # this is the output from the {% request_token %} tag 83 | html = request_token({"request_token": token.jwt()}) 84 | 85 | # initial GET - mark token as used, do not expire 86 | response = self.client.get(get_url("roundtrip", token)) 87 | self.assertContains(response, html, status_code=200) 88 | token.refresh_from_db() 89 | self.assertTrue(token.expiration_time is None) 90 | self.assertEqual(token.used_to_date, 1) 91 | 92 | # now re-post the token to the same URL - equivalent to POSTing the form 93 | response = self.client.post( 94 | get_url("roundtrip", None), {JWT_QUERYSTRING_ARG: token.jwt()} 95 | ) 96 | # 201 is a sentinel status_code so we know that the form has been processed 97 | self.assertContains(response, "OK", status_code=201) 98 | token.refresh_from_db() 99 | # token has been forcibly expired 100 | self.assertFalse(token.expiration_time is None) 101 | self.assertTrue(token.expiration_time < tz_now()) 102 | self.assertEqual(token.used_to_date, 2) 103 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | DATABASES = { 4 | "default": { 5 | "ENGINE": "django.db.backends.sqlite3", 6 | "NAME": "django_request_token.db", 7 | } 8 | } 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.sessions", 14 | "django.contrib.contenttypes", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "request_token", 18 | "tests", 19 | ) 20 | 21 | MIDDLEWARE = [ 22 | "django.middleware.common.CommonMiddleware", 23 | "django.middleware.csrf.CsrfViewMiddleware", 24 | "django.contrib.sessions.middleware.SessionMiddleware", 25 | "django.contrib.messages.middleware.MessageMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "request_token.middleware.RequestTokenMiddleware", 28 | ] 29 | 30 | TEMPLATES = [ 31 | { 32 | "BACKEND": "django.template.backends.django.DjangoTemplates", 33 | "DIRS": [ 34 | # insert your TEMPLATE_DIRS here 35 | ], 36 | "APP_DIRS": True, 37 | "OPTIONS": { 38 | "context_processors": [ 39 | "django.template.context_processors.request", 40 | "django.contrib.auth.context_processors.auth", 41 | "django.contrib.messages.context_processors.messages", 42 | "request_token.context_processors.request_token", 43 | ] 44 | }, 45 | } 46 | ] 47 | 48 | ALLOWED_HOSTS = ["localhost", "127.0.0.1"] 49 | 50 | SECRET_KEY = "request_token" # noqa: S703,S105 51 | 52 | ROOT_URLCONF = "tests.urls" 53 | 54 | APPEND_SLASH = True 55 | 56 | STATIC_URL = "/static/" 57 | 58 | STATIC_ROOT = "./static" 59 | 60 | TIME_ZONE = "UTC" 61 | 62 | SITE_ID = 1 63 | 64 | LOGGING = { 65 | "version": 1, 66 | "disable_existing_loggers": False, 67 | "formatters": { 68 | "verbose": { 69 | "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" 70 | }, 71 | "simple": {"format": "%(levelname)s %(message)s"}, 72 | }, 73 | "handlers": { 74 | "console": { 75 | "level": "DEBUG", 76 | "class": "logging.StreamHandler", 77 | "formatter": "simple", 78 | } 79 | }, 80 | "loggers": {"request_token": {"handlers": ["console"], "level": "DEBUG"}}, 81 | } 82 | 83 | if not DEBUG: 84 | raise Exception("This project is only intended to be used for testing.") 85 | -------------------------------------------------------------------------------- /tests/templates/test_form.html: -------------------------------------------------------------------------------- 1 | {% load request_token_tags %} 2 | 3 | 4 |
5 | {% request_token %} 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | from django.test import TestCase 5 | from django.utils.timezone import now as tz_now 6 | from jwt.exceptions import MissingRequiredClaimError 7 | 8 | from request_token.admin import RequestTokenAdmin, pretty_print 9 | from request_token.models import RequestToken 10 | 11 | 12 | class AdminTests(TestCase): 13 | """Admin function tests.""" 14 | 15 | def test_pretty_print(self): 16 | self.assertEqual(pretty_print(None), None) 17 | self.assertEqual( 18 | pretty_print({"foo": True}), 19 | '
{
    "foo": true
}
', 20 | ) 21 | 22 | 23 | class RequestTokenAdminTests(TestCase): 24 | """RequestTokenAdmin class tests.""" 25 | 26 | @mock.patch("request_token.models.tz_now") 27 | def test_is_valid(self, mock_now): 28 | now = tz_now() 29 | mock_now.return_value = now 30 | token = RequestToken() 31 | admin = RequestTokenAdmin(RequestToken, None) 32 | 33 | token.not_before_time = now + datetime.timedelta(minutes=1) 34 | self.assertTrue(token.not_before_time > now) 35 | self.assertFalse(admin.is_valid(token)) 36 | 37 | token.not_before_time = None 38 | token.expiration_time = now - datetime.timedelta(minutes=1) 39 | self.assertTrue(token.expiration_time < now) 40 | self.assertFalse(admin.is_valid(token)) 41 | 42 | token.expiration_time = None 43 | token.max_uses = 1 44 | token.used_to_date = 1 45 | self.assertFalse(admin.is_valid(token)) 46 | 47 | # finally make it valid 48 | token.max_uses = 10 49 | self.assertTrue(admin.is_valid(token)) 50 | 51 | def test_jwt(self): 52 | token = RequestToken(id=1, scope="foo").save() 53 | admin = RequestTokenAdmin(RequestToken, None) 54 | self.assertEqual(admin.jwt(token), token.jwt()) 55 | 56 | token = RequestToken() 57 | self.assertRaises(MissingRequiredClaimError, token.jwt) 58 | self.assertEqual(admin.jwt(token), None) 59 | 60 | def test_claims(self): 61 | token = RequestToken(id=1, scope="foo").save() 62 | admin = RequestTokenAdmin(RequestToken, None) 63 | self.assertEqual(admin._claims(token), pretty_print(token.claims)) 64 | 65 | def test_parsed(self): 66 | token = RequestToken(id=1, scope="foo", data='{"foo": true}').save() 67 | admin = RequestTokenAdmin(RequestToken, None) 68 | parsed = admin._parsed(token) 69 | self.assertTrue("header" in parsed) 70 | self.assertTrue("claims" in parsed) 71 | self.assertTrue("signature" in parsed) 72 | 73 | # if the token is invalid we get None back 74 | with mock.patch.object(RequestToken, "jwt", side_effect=Exception()): 75 | self.assertIsNone(admin._parsed(token)) 76 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import mock 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.http import HttpResponse 8 | from django.test import RequestFactory 9 | 10 | from request_token.commands import log_token_use, parse_xff, request_meta 11 | from request_token.models import RequestToken 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_log_token_use(rf: RequestFactory) -> None: 16 | token = RequestToken().save() 17 | request = rf.get("/") 18 | request.user = AnonymousUser() 19 | request.META = { 20 | "HTTP_X_FORWARDED_FOR": None, 21 | "REMOTE_ADDR": "192.168.0.1", 22 | "HTTP_USER_AGENT": "magical device", 23 | } 24 | response = HttpResponse("foo", status=123) 25 | 26 | log = log_token_use(token, request, response.status_code) 27 | assert token.logs.count() == 1 28 | assert log.user is None 29 | assert log.token == token 30 | assert log.user_agent == "magical device" 31 | assert log.client_ip == "192.168.0.1" 32 | assert log.status_code == 123 33 | assert token.used_to_date == 1 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_log_token_use__disabled(rf: RequestFactory) -> None: 38 | token = RequestToken().save() 39 | request = rf.get("/") 40 | request.user = AnonymousUser() 41 | request.META = {} 42 | response = HttpResponse("foo", status=123) 43 | 44 | with mock.patch("request_token.commands.DISABLE_LOGS", lambda: True): 45 | log = log_token_use(token, request, response.status_code) 46 | assert log is None 47 | assert not token.logs.exists() 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "remote_addr,xff,client_ip", 52 | [ 53 | ("192.168.0.1", "", "192.168.0.1"), 54 | ("192.168.0.1", "192.168.0.2", "192.168.0.2"), 55 | ("192.168.0.1", "192.168.0.2,192.168.0.3", "192.168.0.2"), 56 | ], 57 | ) 58 | def test_request_meta__client_ip( 59 | rf: RequestFactory, remote_addr: str, xff: str, client_ip: str 60 | ) -> None: 61 | request = rf.get("/") 62 | request.user = AnonymousUser() 63 | request.META = {"HTTP_X_FORWARDED_FOR": xff, "REMOTE_ADDR": remote_addr} 64 | meta = request_meta(request) 65 | assert meta["client_ip"] == client_ip 66 | 67 | 68 | @pytest.mark.django_db 69 | @pytest.mark.parametrize( 70 | "input, output", 71 | [ 72 | (None, None), 73 | ("", ""), 74 | ("foo", "foo"), 75 | ("foo, bar, baz", "foo"), 76 | ("foo , bar, baz", "foo"), 77 | ("8.8.8.8, 123.124.125.126", "8.8.8.8"), 78 | ], 79 | ) 80 | def test_parse_xff(input: str, output: str) -> None: # noqa: A002 81 | assert parse_xff(input) == output 82 | -------------------------------------------------------------------------------- /tests/test_context_processors.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.http import HttpRequest 5 | from django.test import TestCase 6 | 7 | from request_token.context_processors import request_token 8 | from request_token.models import RequestToken 9 | 10 | 11 | @mock.patch.object(RequestToken, "jwt", lambda t: "foo") 12 | class ContextProcessorTests(TestCase): 13 | def test_request_token_no_token(self): 14 | request = HttpRequest() 15 | context = request_token(request) 16 | with self.assertRaises(ImproperlyConfigured): 17 | # assert forces evaluation of SimpleLazyObject 18 | assert context["request_token"] 19 | 20 | def test_request_token(self): 21 | request = HttpRequest() 22 | request.token = RequestToken() 23 | context = request_token(request) 24 | assert context["request_token"] == request.token.jwt() 25 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser, User 2 | from django.http import HttpRequest, HttpResponse 3 | from django.test import RequestFactory, TestCase 4 | 5 | from request_token.decorators import _get_request_arg, use_request_token 6 | from request_token.exceptions import ScopeError, TokenNotFoundError 7 | from request_token.middleware import RequestTokenMiddleware 8 | from request_token.models import RequestToken, RequestTokenLog 9 | from request_token.settings import JWT_QUERYSTRING_ARG 10 | 11 | 12 | @use_request_token(scope="foo") 13 | def test_view_func(request): 14 | """Return decorated request / response objects.""" 15 | response = HttpResponse("Hello, world!", status=200) 16 | return response 17 | 18 | 19 | class TestClassBasedView: 20 | @use_request_token(scope="foobar") 21 | def get(self, request): 22 | """Return decorated request / response objects.""" 23 | response = HttpResponse(str(request.token.id), status=200) 24 | return response 25 | 26 | 27 | class MockSession: 28 | """Fake Session model used to support `session_key` property.""" 29 | 30 | @property 31 | def session_key(self): 32 | return "foobar" 33 | 34 | 35 | class DecoratorTests(TestCase): 36 | """use_jwt decorator tests.""" 37 | 38 | def setUp(self): 39 | self.factory = RequestFactory() 40 | self.middleware = RequestTokenMiddleware(get_response=lambda r: r) 41 | 42 | def _request(self, path, token, user): 43 | path = path + "?{}={}".format(JWT_QUERYSTRING_ARG, token) if token else path 44 | request = self.factory.get(path) 45 | request.session = MockSession() 46 | request.user = user 47 | self.middleware(request) 48 | return request 49 | 50 | def test_no_token(self): 51 | request = self._request("/", None, AnonymousUser()) 52 | response = test_view_func(request) 53 | self.assertEqual(response.status_code, 200) 54 | self.assertFalse(hasattr(request, "token")) 55 | self.assertFalse(RequestTokenLog.objects.exists()) 56 | 57 | # now force a TokenNotFoundError, by requiring it in the decorator 58 | @use_request_token(scope="foo", required=True) 59 | def test_view_func2(request): 60 | pass 61 | 62 | self.assertRaises(TokenNotFoundError, test_view_func2, request) 63 | 64 | def test_scope(self): 65 | token = RequestToken.objects.create_token(scope="foobar") 66 | request = self._request("/", token.jwt(), AnonymousUser()) 67 | self.assertRaises(ScopeError, test_view_func, request) 68 | self.assertFalse(RequestTokenLog.objects.exists()) 69 | 70 | RequestToken.objects.all().update(scope="foo") 71 | request = self._request("/", token.jwt(), AnonymousUser()) 72 | response = test_view_func(request) 73 | self.assertEqual(response.status_code, 200) 74 | self.assertTrue(RequestTokenLog.objects.exists()) 75 | 76 | def test_class_based_view(self): 77 | """Test that CBV methods extract the request correctly.""" 78 | cbv = TestClassBasedView() 79 | token = RequestToken.objects.create_token(scope="foobar") 80 | request = self._request("/", token.jwt(), AnonymousUser()) 81 | response = cbv.get(request) 82 | self.assertEqual(response.status_code, 200) 83 | self.assertEqual(int(response.content), token.id) 84 | self.assertTrue(RequestTokenLog.objects.exists()) 85 | 86 | def test__get_request_arg(self): 87 | request = HttpRequest() 88 | cbv = TestClassBasedView() 89 | self.assertEqual(_get_request_arg(request), request) 90 | self.assertEqual(_get_request_arg(request, cbv), request) 91 | self.assertEqual(_get_request_arg(cbv, request), request) 92 | 93 | def test_delete_user__pass(self): 94 | user = User.objects.create_user("test_user") 95 | token = RequestToken.objects.create_token(user=user, scope="foo") 96 | request = self._request("/", token.jwt(), user) 97 | assert User.objects.count() == 1 98 | 99 | @use_request_token(scope="foo", log=False) 100 | def delete_token_user_pass(request): 101 | request.user.delete() 102 | return HttpResponse("Hello, world!", status=204) 103 | 104 | response = delete_token_user_pass(request) 105 | assert response.status_code == 204 106 | assert User.objects.count() == 0 107 | 108 | def test_delete_user__fail(self): 109 | user = User.objects.create_user("test_user") 110 | token = RequestToken.objects.create_token(user=user, scope="foo") 111 | request = self._request("/", token.jwt(), user) 112 | 113 | @use_request_token(scope="foo", log=True) 114 | def delete_token_user(request): 115 | request.user.delete() 116 | return HttpResponse("Hello, world!", status=204) 117 | 118 | self.assertRaises(ValueError, delete_token_user, request) 119 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | from unittest import mock 4 | 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied 8 | from django.http import HttpResponse 9 | from django.test import RequestFactory, TestCase 10 | from jwt import exceptions 11 | 12 | from request_token.middleware import RequestTokenMiddleware 13 | from request_token.models import RequestToken 14 | from request_token.settings import JWT_QUERYSTRING_ARG 15 | 16 | 17 | class MockSession: 18 | """Fake Session model used to support `session_key` property.""" 19 | 20 | @property 21 | def session_key(self): 22 | return "foobar" 23 | 24 | 25 | class MiddlewareTests(TestCase): 26 | """RequestTokenMiddleware tests.""" 27 | 28 | def setUp(self): 29 | self.user = get_user_model().objects.create_user("zoidberg") 30 | self.factory = RequestFactory() 31 | self.middleware = RequestTokenMiddleware(get_response=lambda r: HttpResponse()) 32 | self.token = RequestToken.objects.create_token(scope="foo") 33 | self.default_payload = {JWT_QUERYSTRING_ARG: self.token.jwt()} 34 | 35 | def get_request(self): 36 | request = self.factory.get(f"/?{JWT_QUERYSTRING_ARG}={self.token.jwt()}") 37 | request.user = self.user 38 | request.session = MockSession() 39 | return request 40 | 41 | def post_request(self): 42 | request = self.factory.post("/", self.default_payload) 43 | request.user = self.user 44 | request.session = MockSession() 45 | return request 46 | 47 | def post_request_with_JSON(self, payload: Any): 48 | data = json.dumps(payload) 49 | request = self.factory.post("/", data, "application/json") 50 | request.user = self.user 51 | request.session = MockSession() 52 | return request 53 | 54 | def test_process_request_assertions(self): 55 | request = self.factory.get("/") 56 | self.assertRaises(ImproperlyConfigured, self.middleware, request) 57 | 58 | request.user = AnonymousUser() 59 | self.assertRaises(ImproperlyConfigured, self.middleware, request) 60 | request.session = MockSession() 61 | 62 | self.middleware(request) 63 | self.assertFalse(hasattr(request, "token")) 64 | 65 | def test_process_request_without_token(self): 66 | request = self.factory.get("/") 67 | request.user = AnonymousUser() 68 | request.session = MockSession() 69 | self.middleware(request) 70 | self.assertFalse(hasattr(request, "token")) 71 | 72 | def test_process_GET_request_with_valid_token(self): 73 | request = self.get_request() 74 | self.middleware(request) 75 | self.assertEqual(request.token, self.token) 76 | 77 | def test_process_POST_request_with_valid_token(self): 78 | request = self.post_request() 79 | self.middleware(request) 80 | self.assertEqual(request.token, self.token) 81 | 82 | def test_process_POST_request_with_valid_token_with_json(self): 83 | request = self.post_request_with_JSON(self.default_payload) 84 | self.middleware(request) 85 | self.assertEqual(request.token, self.token) 86 | 87 | def test_process_AJAX_request_with_array(self): 88 | """Test for issue #50.""" 89 | request = self.post_request_with_JSON([1]) 90 | self.middleware(request) 91 | self.assertFalse(hasattr(request, "token")) 92 | 93 | def test_process_request_not_allowed(self): 94 | # PUT requests won't decode the token 95 | request = self.factory.put("/?rt=foo") 96 | request.user = self.user 97 | request.session = MockSession() 98 | response = self.middleware(request) 99 | self.assertFalse(hasattr(request, "token")) 100 | self.assertEqual(response.status_code, 200) 101 | 102 | @mock.patch("request_token.middleware.logger") 103 | def test_process_request_token_error(self, mock_logger): 104 | # token decode error - request passes through _without_ a token 105 | request = self.factory.get("/?rt=foo") 106 | request.user = self.user 107 | request.session = MockSession() 108 | self.middleware(request) 109 | self.assertIsNone(request.token) 110 | self.assertEqual(mock_logger.exception.call_count, 1) 111 | 112 | @mock.patch("request_token.middleware.logger") 113 | def test_process_request_token_does_not_exist(self, mock_logger): 114 | request = self.get_request() 115 | self.token.delete() 116 | self.middleware(request) 117 | self.assertIsNone(request.token) 118 | self.assertEqual(mock_logger.exception.call_count, 1) 119 | 120 | def test_process_exception(self): 121 | request = self.get_request() 122 | request.token = self.token 123 | exception = exceptions.InvalidTokenError("bar") 124 | with self.assertRaises(PermissionDenied): 125 | response = self.middleware.process_exception(request, exception) 126 | self.assertEqual(response.status_code, 403) 127 | self.assertEqual(response.reason_phrase, str(exception)) 128 | 129 | # no request token = no error log 130 | del request.token 131 | with self.assertRaises(PermissionDenied): 132 | response = self.middleware.process_exception(request, exception) 133 | self.assertEqual(response.status_code, 403) 134 | self.assertEqual(response.reason_phrase, str(exception)) 135 | 136 | # round it out with a non-token error 137 | response = self.middleware.process_exception(request, Exception("foo")) 138 | self.assertIsNone(response) 139 | 140 | def test_extract_json_token__array(self): 141 | """Test for issue #51.""" 142 | request = self.post_request_with_JSON(["foo"]) 143 | middleware = RequestTokenMiddleware(lambda r: HttpResponse()) 144 | self.assertIsNone(middleware.extract_ajax_token(request)) 145 | 146 | def test_extract_json_token(self): 147 | request = self.post_request_with_JSON(self.default_payload) 148 | middleware = RequestTokenMiddleware(lambda r: HttpResponse()) 149 | self.assertEqual(middleware.extract_ajax_token(request), self.token.jwt()) 150 | 151 | def test_extract_ajax_token_catches_unicode_error(self): 152 | request = self.factory.post( 153 | "/", data=b"\xa0", content_type="application/json" # Invalid UTF-8 data 154 | ) 155 | request.user = self.user 156 | request.session = MockSession() 157 | 158 | middleware = RequestTokenMiddleware(get_response=lambda r: HttpResponse()) 159 | result = middleware.extract_ajax_token(request) 160 | self.assertIsNone(result) 161 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.db import connection 3 | from django.db.migrations.autodetector import MigrationAutodetector 4 | from django.db.migrations.executor import MigrationExecutor 5 | from django.db.migrations.state import ProjectState 6 | from django.test import TestCase 7 | 8 | 9 | class MigrationsTests(TestCase): 10 | def test_for_missing_migrations(self): 11 | """Checks if there're models changes which aren't reflected in migrations.""" 12 | migrations_loader = MigrationExecutor(connection).loader 13 | migrations_detector = MigrationAutodetector( 14 | from_state=migrations_loader.project_state(), 15 | to_state=ProjectState.from_apps(apps), 16 | ) 17 | if migrations_detector.changes(graph=migrations_loader.graph): 18 | self.fail( 19 | "Your models have changes that are not yet reflected " 20 | "in a migration. You should add them now." 21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.contrib.sessions.middleware import SessionMiddleware 8 | from django.core.exceptions import ValidationError 9 | from django.db import IntegrityError 10 | from django.http import HttpRequest, HttpResponse 11 | from django.test import RequestFactory, TestCase 12 | from django.utils.timezone import now as tz_now 13 | from jwt.exceptions import InvalidAudienceError 14 | 15 | from request_token.exceptions import MaxUseError 16 | from request_token.models import RequestToken, RequestTokenLog 17 | from request_token.settings import DEFAULT_MAX_USES, JWT_SESSION_TOKEN_EXPIRY 18 | from request_token.utils import decode, to_seconds 19 | 20 | 21 | def get_response(request: HttpRequest) -> HttpResponse: 22 | return HttpResponse() 23 | 24 | 25 | class RequestTokenTests(TestCase): 26 | """RequestToken model property and method tests.""" 27 | 28 | def setUp(self): 29 | # ensure user has unicode chars 30 | self.user = get_user_model().objects.create_user( 31 | "zoidberg", first_name="ß∂ƒ©˙∆", last_name="ƒ∆" 32 | ) 33 | 34 | def test_defaults(self): 35 | token = RequestToken() 36 | self.assertIsNone(token.user) 37 | self.assertEqual(token.scope, "") 38 | self.assertEqual(token.login_mode, RequestToken.LOGIN_MODE_NONE) 39 | self.assertIsNone(token.expiration_time) 40 | self.assertIsNone(token.not_before_time) 41 | self.assertEqual(token.data, {}) 42 | self.assertIsNone(token.issued_at) 43 | self.assertEqual(token.max_uses, DEFAULT_MAX_USES) 44 | self.assertEqual(token.used_to_date, 0) 45 | 46 | def test_string_repr(self): 47 | token = RequestToken(user=self.user) 48 | self.assertIsNotNone(str(token)) 49 | self.assertIsNotNone(repr(token)) 50 | 51 | def test_save(self): 52 | token = RequestToken().save() 53 | self.assertIsNotNone(token) 54 | self.assertIsNone(token.user) 55 | self.assertEqual(token.scope, "") 56 | self.assertEqual(token.login_mode, RequestToken.LOGIN_MODE_NONE) 57 | self.assertIsNone(token.expiration_time) 58 | self.assertIsNone(token.not_before_time) 59 | self.assertEqual(token.data, {}) 60 | self.assertIsNotNone(token.issued_at) 61 | self.assertEqual(token.max_uses, DEFAULT_MAX_USES) 62 | self.assertEqual(token.used_to_date, 0) 63 | 64 | token.issued_at = None 65 | token = token.save(update_fields=["issued_at"]) 66 | self.assertIsNone(token.issued_at) 67 | 68 | now = tz_now() 69 | expires = now + datetime.timedelta(minutes=JWT_SESSION_TOKEN_EXPIRY) 70 | with mock.patch("request_token.models.tz_now", lambda: now): 71 | token = RequestToken( 72 | login_mode=RequestToken.LOGIN_MODE_SESSION, user=self.user, scope="foo" 73 | ) 74 | self.assertIsNone(token.issued_at) 75 | self.assertIsNone(token.expiration_time) 76 | token.save() 77 | self.assertEqual(token.issued_at, now) 78 | self.assertEqual(token.expiration_time, expires) 79 | 80 | def test_claims(self): 81 | token = RequestToken() 82 | # raises error with no id set - put into context manager as it's 83 | # an attr, not a callable 84 | self.assertEqual(len(token.claims), 3) 85 | self.assertEqual(token.max, DEFAULT_MAX_USES) 86 | self.assertEqual(token.sub, "") 87 | self.assertIsNone(token.jti) 88 | self.assertIsNone(token.aud) 89 | self.assertIsNone(token.exp) 90 | self.assertIsNone(token.nbf) 91 | self.assertIsNone(token.iat) 92 | 93 | # now let's set some properties 94 | token.user = self.user 95 | self.assertEqual(token.aud, str(self.user.pk)) 96 | self.assertEqual(len(token.claims), 4) 97 | 98 | token.login_mode = RequestToken.LOGIN_MODE_REQUEST 99 | self.assertEqual( 100 | token.claims["mod"], RequestToken.LOGIN_MODE_REQUEST[:1].lower() 101 | ) 102 | self.assertEqual(len(token.claims), 4) 103 | 104 | now = tz_now() 105 | now_sec = to_seconds(now) 106 | 107 | token.expiration_time = now 108 | self.assertEqual(token.exp, now_sec) 109 | self.assertEqual(len(token.claims), 5) 110 | 111 | token.not_before_time = now 112 | self.assertEqual(token.nbf, now_sec) 113 | self.assertEqual(len(token.claims), 6) 114 | 115 | # saving updates the id and issued_at timestamp 116 | with mock.patch("request_token.models.tz_now", lambda: now): 117 | token.save() 118 | self.assertEqual(token.iat, now_sec) 119 | self.assertEqual(token.jti, token.id) 120 | self.assertEqual(len(token.claims), 8) 121 | 122 | def test_json(self): 123 | """Test the data field is really JSON.""" 124 | token = RequestToken(data={"foo": True}) 125 | token.save() 126 | self.assertTrue(token.data["foo"]) 127 | 128 | def test_clean(self): 129 | # LOGIN_MODE_NONE doesn't care about user. 130 | token = RequestToken(login_mode=RequestToken.LOGIN_MODE_NONE) 131 | token.clean() 132 | token.user = self.user 133 | token.clean() 134 | 135 | # request mode 136 | token.login_mode = RequestToken.LOGIN_MODE_REQUEST 137 | token.clean() 138 | token.user = None 139 | self.assertRaises(ValidationError, token.clean) 140 | 141 | def reset_session(): 142 | """Reset properties so that token passes validation.""" 143 | token.login_mode = RequestToken.LOGIN_MODE_SESSION 144 | token.user = self.user 145 | token.issued_at = tz_now() 146 | token.expiration_time = token.issued_at + datetime.timedelta(minutes=1) 147 | token.max_uses = DEFAULT_MAX_USES 148 | 149 | def assertValidationFails(field_name): 150 | with self.assertRaises(ValidationError) as ctx: 151 | token.clean() 152 | self.assertTrue(field_name in dict(ctx.exception)) 153 | 154 | # check the reset_session works! 155 | reset_session() 156 | token.user = None 157 | assertValidationFails("user") 158 | 159 | reset_session() 160 | token.expiration_time = None 161 | assertValidationFails("expiration_time") 162 | 163 | def test_jwt(self): 164 | token = RequestToken(id=1, scope="foo").save() 165 | jwt = token.jwt() 166 | self.assertEqual(decode(jwt), token.claims) 167 | 168 | def test_validate_max_uses(self): 169 | token = RequestToken(max_uses=1, used_to_date=0) 170 | token.validate_max_uses() 171 | token.used_to_date = token.max_uses 172 | self.assertRaises(MaxUseError, token.validate_max_uses) 173 | 174 | def test__auth_is_anonymous(self): 175 | factory = RequestFactory() 176 | middleware = SessionMiddleware(get_response) 177 | anon = AnonymousUser() 178 | request = factory.get("/foo") 179 | middleware.process_request(request) 180 | request.user = anon 181 | 182 | # try default token 183 | token = RequestToken.objects.create_token( 184 | scope="foo", max_uses=10, login_mode=RequestToken.LOGIN_MODE_NONE 185 | ) 186 | request = token._auth_is_anonymous(request) 187 | self.assertEqual(request.user, anon) 188 | 189 | # try request token 190 | user1 = get_user_model().objects.create_user(username="Finbar") 191 | token = RequestToken.objects.create_token( 192 | user=user1, 193 | scope="foo", 194 | max_uses=10, 195 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 196 | ) 197 | token._auth_is_anonymous(request) 198 | self.assertEqual(request.user, user1) 199 | self.assertFalse(hasattr(token.user, "backend")) 200 | 201 | def test__auth_is_anonymous__authenticated(self): 202 | factory = RequestFactory() 203 | middleware = SessionMiddleware(get_response) 204 | request = factory.get("/foo") 205 | middleware.process_request(request) 206 | 207 | # try request token 208 | request.user = get_user_model().objects.create_user(username="Finbar") 209 | token = RequestToken.objects.create_token( 210 | user=request.user, 211 | scope="foo", 212 | max_uses=10, 213 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 214 | ) 215 | 216 | # authenticated user fails 217 | self.assertRaises(InvalidAudienceError, token._auth_is_anonymous, request) 218 | 219 | def test__auth_is_authenticated(self): 220 | factory = RequestFactory() 221 | middleware = SessionMiddleware(get_response) 222 | request = factory.get("/foo") 223 | middleware.process_request(request) 224 | user1 = get_user_model().objects.create_user(username="Jekyll") 225 | request.user = user1 226 | 227 | # try default token 228 | token = RequestToken.objects.create_token( 229 | scope="foo", max_uses=10, login_mode=RequestToken.LOGIN_MODE_NONE 230 | ) 231 | request = token._auth_is_authenticated(request) 232 | self.assertEqual(request.user, user1) 233 | 234 | # try request token 235 | token = RequestToken.objects.create_token( 236 | user=user1, 237 | scope="foo", 238 | max_uses=10, 239 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 240 | ) 241 | request = token._auth_is_authenticated(request) 242 | 243 | token.login_mode = RequestToken.LOGIN_MODE_SESSION 244 | request = token._auth_is_authenticated(request) 245 | self.assertEqual(request.user, user1) 246 | 247 | token.user = get_user_model().objects.create_user(username="Hyde") 248 | self.assertRaises(InvalidAudienceError, token._auth_is_authenticated, request) 249 | 250 | # anonymous user fails 251 | request.user = AnonymousUser() 252 | self.assertRaises(InvalidAudienceError, token._auth_is_authenticated, request) 253 | 254 | def test_authenticate(self): 255 | factory = RequestFactory() 256 | middleware = SessionMiddleware(get_response) 257 | anon = AnonymousUser() 258 | request = factory.get("/foo") 259 | middleware.process_request(request) 260 | request.user = anon 261 | 262 | user1 = get_user_model().objects.create_user(username="Finbar") 263 | token = RequestToken.objects.create_token( 264 | user=user1, 265 | scope="foo", 266 | max_uses=10, 267 | login_mode=RequestToken.LOGIN_MODE_REQUEST, 268 | ) 269 | token.authenticate(request) 270 | self.assertEqual(request.user, user1) 271 | 272 | request.user = get_user_model().objects.create_user(username="Hyde") 273 | self.assertRaises(InvalidAudienceError, token.authenticate, request) 274 | 275 | def test_increment_used_count(self): 276 | token = RequestToken.objects.create(max_uses=1, used_to_date=0) 277 | token.increment_used_count() 278 | self.assertEqual(str(token.used_to_date), "1") 279 | 280 | def test_expire(self): 281 | expiry = tz_now() + datetime.timedelta(days=1) 282 | token = RequestToken.objects.create_token( 283 | scope="foo", login_mode=RequestToken.LOGIN_MODE_NONE, expiration_time=expiry 284 | ) 285 | self.assertTrue(token.expiration_time == expiry) 286 | token.expire() 287 | self.assertTrue(token.expiration_time < expiry) 288 | 289 | 290 | @pytest.mark.parametrize( 291 | "input,output", 292 | [ 293 | ("/foo", "/foo?rt={jwt}"), 294 | ("/foo?rt=baz", "/foo?rt={jwt}"), 295 | ("/foo?rt=baz&q=hello", "/foo?rt={jwt}&q=hello"), 296 | ("/foo?rt=baz&q=hello#anchor", "/foo?rt={jwt}&q=hello#anchor"), 297 | ], 298 | ) 299 | def test_tokenise(input: str, output: str) -> None: # noqa: A002 300 | token = RequestToken(id=1, scope="foo", login_mode=RequestToken.LOGIN_MODE_NONE) 301 | assert token.tokenise(input) == output.format(jwt=token.jwt()) 302 | 303 | 304 | class RequestTokenQuerySetTests(TestCase): 305 | """RequestTokenQuerySet class tests.""" 306 | 307 | def test_create_token(self): 308 | self.assertRaises(TypeError, RequestToken.objects.create_token) 309 | RequestToken.objects.create_token(scope="foo") 310 | self.assertEqual(RequestToken.objects.get().scope, "foo") 311 | 312 | 313 | class RequestTokenLogTests(TestCase): 314 | """RequestTokenLog model property and method tests.""" 315 | 316 | def setUp(self): 317 | self.user = get_user_model().objects.create_user( 318 | "zoidberg", first_name="∂ƒ©˙∆", last_name="†¥¨^" 319 | ) 320 | self.token = RequestToken.objects.create_token( 321 | scope="foo", user=self.user, login_mode=RequestToken.LOGIN_MODE_REQUEST 322 | ) 323 | 324 | def test_defaults(self): 325 | log = RequestTokenLog(token=self.token, user=self.user) 326 | self.assertEqual(log.user, self.user) 327 | self.assertEqual(log.token, self.token) 328 | self.assertEqual(log.user_agent, "") 329 | self.assertEqual(log.client_ip, None) 330 | self.assertIsNone(log.timestamp) 331 | 332 | token = RequestToken(user=self.user) 333 | self.assertIsNotNone(str(token)) 334 | self.assertIsNotNone(repr(token)) 335 | 336 | def test_string_repr(self): 337 | log = RequestTokenLog(token=self.token, user=self.user) 338 | self.assertIsNotNone(str(log)) 339 | self.assertIsNotNone(repr(log)) 340 | 341 | log.user = None 342 | self.assertIsNotNone(str(log)) 343 | self.assertIsNotNone(repr(log)) 344 | 345 | def test_save(self): 346 | log = RequestTokenLog(token=self.token, user=self.user).save() 347 | self.assertIsNotNone(log.timestamp) 348 | 349 | log.timestamp = None 350 | self.assertRaises(IntegrityError, log.save, update_fields=["timestamp"]) 351 | 352 | def test_ipv6(self): 353 | """Test that IP v4 and v6 are handled.""" 354 | log = RequestTokenLog(token=self.token, user=self.user).save() 355 | self.assertIsNone(log.client_ip) 356 | 357 | def assertIP(ip): 358 | log.client_ip = ip 359 | log.save() 360 | self.assertEqual(log.client_ip, ip) 361 | 362 | assertIP("192.168.0.1") 363 | # taken from http://ipv6.com/articles/general/IPv6-Addressing.htm 364 | assertIP("2001:cdba:0000:0000:0000:0000:3257:9652") 365 | assertIP("2001:cdba:0:0:0:0:3257:9652") 366 | assertIP("2001:cdba::3257:9652") 367 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | 5 | from request_token.settings import JWT_QUERYSTRING_ARG 6 | from request_token.templatetags import request_token_tags 7 | 8 | 9 | class TemplateTagTests(TestCase): 10 | def test_request_token_missing(self): 11 | context = {} 12 | assert request_token_tags.request_token(context) == "" 13 | 14 | def test_request_token(self): 15 | context = {"request_token": "foo"} 16 | assert request_token_tags.request_token(context) == ( 17 | f'' 18 | ) 19 | 20 | def test_request_token_querystring_missing(self): 21 | mock_request = mock.Mock() 22 | mock_request.token = None 23 | context = {"request": mock_request} 24 | assert request_token_tags.request_token_querystring(context) == "" 25 | 26 | def test_request_token_querystring(self): 27 | mock_request = mock.Mock() 28 | mock_request.token.jwt.return_value = "foo" 29 | context = {"request": mock_request} 30 | assert request_token_tags.request_token_querystring(context) == ( 31 | f"?{JWT_QUERYSTRING_ARG}=foo" 32 | ) 33 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.test import TestCase 6 | from jwt import encode as jwt_encode 7 | from jwt.exceptions import DecodeError, InvalidAlgorithmError, MissingRequiredClaimError 8 | 9 | from request_token.utils import MANDATORY_CLAIMS, decode, encode, is_jwt, to_seconds 10 | 11 | 12 | class FunctionTests(TestCase): 13 | """Tests for free-floating functions.""" 14 | 15 | def test_to_seconds(self): 16 | timestamp = datetime.datetime(2015, 1, 1) 17 | self.assertEqual(to_seconds(timestamp), 1420070400) 18 | self.assertEqual(to_seconds(1420070400), None) 19 | 20 | def test_encode(self): 21 | payload = {"foo": "bar"} 22 | self.assertRaises(MissingRequiredClaimError, encode, payload) 23 | # force all mandatory claims into the payload 24 | payload = {k: "foo" for k in MANDATORY_CLAIMS} 25 | self.assertEqual(encode(payload), jwt_encode(payload, settings.SECRET_KEY)) 26 | 27 | def test_decode(self): 28 | # test valid encode / decode 29 | payload = {k: "foo" for k in MANDATORY_CLAIMS} 30 | encoded = jwt_encode(payload, settings.SECRET_KEY) 31 | self.assertEqual(decode(encoded), payload) 32 | 33 | def test_decode__wrong_secret(self): 34 | # check that we can't decode with the wrong secret 35 | payload = {k: "foo" for k in MANDATORY_CLAIMS} 36 | encoded = jwt_encode(payload, "QWERTYUIO") 37 | self.assertRaises(DecodeError, decode, encoded) 38 | 39 | def test_decode__missing_claims(self): 40 | # we can decode this, but we're missing the mandatory fields 41 | payload = {"foo": "bar"} 42 | encoded = jwt_encode(payload, settings.SECRET_KEY) 43 | self.assertRaises(MissingRequiredClaimError, decode, encoded) 44 | 45 | def test_decode__invalid_algo(self): 46 | # check that we can't decode with the wrong algorithms 47 | payload = {"foo": "bar"} 48 | encoded = jwt_encode(payload, settings.SECRET_KEY) 49 | self.assertRaises(InvalidAlgorithmError, decode, encoded, algorithms=["HS384"]) 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "jwt,result", 54 | [ 55 | (None, False), 56 | ("", False), 57 | ("123.abc.DEF", False), 58 | ], 59 | ) 60 | def test_is_jwt__False(jwt: str, result: bool) -> None: 61 | assert is_jwt(jwt) == result 62 | 63 | 64 | def test_is_jwt__True() -> None: 65 | encoded = jwt_encode({}, settings.SECRET_KEY) 66 | assert is_jwt(encoded) 67 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import path 5 | from django.views import debug 6 | 7 | from .views import decorated, roundtrip, undecorated 8 | 9 | app_name = "tests" 10 | 11 | urlpatterns = [ 12 | path("", debug.default_urlconf), 13 | path("admin/", admin.site.urls), 14 | path("decorated/", decorated, name="decorated"), 15 | path("roundtrip/", roundtrip, name="roundtrip"), 16 | path("undecorated/", undecorated, name="undecorated"), 17 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 18 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render 3 | 4 | from request_token.decorators import use_request_token 5 | 6 | 7 | def undecorated(request): 8 | response = HttpResponse("Hello, %s" % request.user) 9 | response.request_user = request.user 10 | return response 11 | 12 | 13 | @use_request_token(scope="foo") 14 | def decorated(request): 15 | response = HttpResponse("Hello, %s" % request.user) 16 | response.request_user = request.user 17 | return response 18 | 19 | 20 | @use_request_token(scope="bar") 21 | def roundtrip(request): 22 | if request.method == "GET": 23 | return render(request, "test_form.html") 24 | else: 25 | request.token.expire() 26 | return HttpResponse("OK", status=201) 27 | -------------------------------------------------------------------------------- /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 | pytest 18 | pytest-cov 19 | pytest-django 20 | django32: Django>=3.2,<3.3 21 | django40: Django>=4.0,<4.1 22 | django41: Django>=4.1,<4.2 23 | django42: Django>=4.2,<4.3 24 | django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz 25 | djangomain: https://github.com/django/django/archive/main.tar.gz 26 | 27 | commands = 28 | pytest --cov=request_token --verbose tests/ 29 | 30 | [testenv:django-checks] 31 | description = Django system checks and missing migrations 32 | deps = Django 33 | commands = 34 | python manage.py check --fail-level WARNING 35 | python manage.py makemigrations --dry-run --check --verbosity 3 36 | 37 | [testenv:fmt] 38 | description = Python source code formatting (black) 39 | deps = 40 | black 41 | 42 | commands = 43 | black --check request_token 44 | 45 | [testenv:lint] 46 | description = Python source code linting (ruff) 47 | deps = 48 | ruff 49 | 50 | commands = 51 | ruff check request_token 52 | 53 | [testenv:mypy] 54 | description = Python source code type hints (mypy) 55 | deps = 56 | mypy 57 | 58 | commands = 59 | mypy request_token 60 | --------------------------------------------------------------------------------