├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── changes.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── pyramid_jwt │ ├── __init__.py │ └── policy.py └── tests ├── test_cookies.py ├── test_integration.py └── test_policy.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [paths] 5 | source = 6 | tests/ 7 | src/fullint/ 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yaml] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | strategy: 8 | matrix: 9 | python: ["3.6", "3.7", "3.8"] 10 | 11 | name: Test Python ${{ matrix.python }} 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python ${{ matrix.python }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install -e .[testing] 24 | 25 | - name: Run tests 26 | run: py.test 27 | 28 | style: 29 | runs-on: ubuntu-latest 30 | name: Code style 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | 35 | - name: Set up Python 36 | uses: actions/setup-python@v1 37 | with: 38 | python-version: 3.7 39 | 40 | - name: Install black 41 | run: python -m pip install black 42 | 43 | - name: Check code style 44 | run: black --check . 45 | 46 | release: 47 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 48 | runs-on: ubuntu-latest 49 | name: Release 50 | needs: [ test, style ] 51 | 52 | steps: 53 | - uses: actions/checkout@v2 54 | with: 55 | fetch-depth: 0 56 | 57 | - name: Set up Python 58 | uses: actions/setup-python@v1 59 | with: 60 | python-version: 3.7 61 | 62 | - name: Install wheel 63 | run: python -m pip install wheel --user 64 | 65 | - name: Insert version number 66 | run: | 67 | version=$(echo $GITHUB_REF | cut -d/ -f3) 68 | sed -ie "/^version/s/=.*/= $version/" setup.cfg 69 | 70 | - name: Generate changelog 71 | uses: scottbrenner/generate-changelog-action@master 72 | id: Changelog 73 | env: 74 | REPO: ${{ github.repository }} 75 | 76 | - name: Build a binary wheel and a source tarball 77 | run: python setup.py sdist bdist_wheel 78 | 79 | - name: Publish package 80 | uses: pypa/gh-action-pypi-publish@master 81 | with: 82 | user: __token__ 83 | password: ${{ secrets.pypi_password }} 84 | 85 | - name: Create GitHub Release 86 | id: create_release 87 | uses: actions/create-release@latest 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | with: 91 | tag_name: ${{ github.ref }} 92 | release_name: Release ${{ github.ref }} 93 | body: | 94 | ${{ steps.Changelog.outputs.changelog }} 95 | draft: false 96 | prerelease: false 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | /.cache 4 | 5 | /bin 6 | /build 7 | /dist 8 | /lib 9 | /share 10 | /pip-selfcheck.json 11 | /pyvenv.cfg 12 | /src/pyramid_jwt.egg-info/ 13 | 14 | /.coverage 15 | /htmlcov 16 | /venv/ 17 | .venv/ 18 | .idea/ 19 | 20 | .project 21 | .pydevproject 22 | .settings/org.eclipse.core.resources.prefs 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015, Wichert Akkerman 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | include .coveragerc 4 | include LICENSE 5 | recursive-include tests *.py 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | JWT authentication for Pyramid 2 | ============================== 3 | 4 | This package implements an authentication policy for Pyramid that using `JSON 5 | Web Tokens `_. This standard (`RFC 7519 6 | `_) is often used to secure backend APIs. 7 | The excellent `PyJWT `_ library is 8 | used for the JWT encoding / decoding logic. 9 | 10 | Enabling JWT support in a Pyramid application is very simple: 11 | 12 | .. code-block:: python 13 | 14 | from pyramid.config import Configurator 15 | from pyramid.authorization import ACLAuthorizationPolicy 16 | 17 | def main(): 18 | config = Configurator() 19 | # Pyramid requires an authorization policy to be active. 20 | config.set_authorization_policy(ACLAuthorizationPolicy()) 21 | # Enable JWT authentication. 22 | config.include('pyramid_jwt') 23 | config.set_jwt_authentication_policy('secret') 24 | 25 | This will set a JWT authentication policy using the `Authorization` HTTP header 26 | with a `JWT` scheme to retrieve tokens. Using another HTTP header is trivial: 27 | 28 | .. code-block:: python 29 | 30 | config.set_jwt_authentication_policy('secret', http_header='X-My-Header') 31 | 32 | If your application needs to decode tokens which contain an `Audience `_ claim you can extend this with: 33 | 34 | .. code-block:: python 35 | 36 | config.set_jwt_authentication_policy('secret', 37 | auth_type='Bearer', 38 | callback=add_role_principals, 39 | audience="example.org") 40 | 41 | 42 | To make creating valid tokens easier a new ``create_jwt_token`` method is 43 | added to the request. You can use this in your view to create tokens. A simple 44 | authentication view for a REST backend could look something like this: 45 | 46 | .. code-block:: python 47 | 48 | @view_config('login', request_method='POST', renderer='json') 49 | def login(request): 50 | login = request.POST['login'] 51 | password = request.POST['password'] 52 | user_id = authenticate(login, password) # You will need to implement this. 53 | if user_id: 54 | return { 55 | 'result': 'ok', 56 | 'token': request.create_jwt_token(user_id) 57 | } 58 | else: 59 | return { 60 | 'result': 'error' 61 | } 62 | 63 | Unless you are using JWT cookies within cookies (see the next section), the 64 | standard ``remember()`` and ``forget()`` functions from Pyramid are not useful. 65 | Trying to use them while regular (header-based) JWT authentication is enabled 66 | will result in a warning. 67 | 68 | Using JWT inside cookies 69 | ------------------------ 70 | 71 | Optionally, you can use cookies as a transport for the JWT Cookies. This is an 72 | useful technique to allow browser-based web apps to consume your REST APIs 73 | without the hassle of managing token storage (where to store JWT cookies is a 74 | known-issue), since ``http_only`` cookies cannot be handled by Javascript 75 | running on the page 76 | 77 | Using JWT within cookies have some added benefits, the first one being *sliding 78 | sessions*: Tokens inside cookies will automatically be reissued whenever 79 | ``reissue_time`` is past. 80 | 81 | .. code-block:: python 82 | 83 | from pyramid.config import Configurator 84 | from pyramid.authorization import ACLAuthorizationPolicy 85 | 86 | def main(): 87 | config = Configurator() 88 | # Pyramid requires an authorization policy to be active. 89 | config.set_authorization_policy(ACLAuthorizationPolicy()) 90 | # Enable JWT authentication. 91 | config.include('pyramid_jwt') 92 | config.set_jwt_cookie_authentication_policy( 93 | 'secret', reissue_time=7200 94 | ) 95 | 96 | When working with JWT alone, there's no standard for manually invalidating a 97 | token: Either the token validity expires, or the application needs to handle a 98 | token blacklist (or even better, a whitelist) 99 | 100 | On the other hand, when using cookies, this library allows the app to *logout* 101 | a given user by erasing its cookie: This policy follows the standard cookie 102 | deletion mechanism respected by most browsers, so a call to Pyramid's 103 | ``forget()`` function will instruct the browser remove that cookie, effectively 104 | throwing that JWT token away, even though it may still be valid. 105 | 106 | See `Creating a JWT within a cookie`_ for examples. 107 | 108 | Extra claims 109 | ------------ 110 | 111 | Normally pyramid_jwt only makes a single JWT claim: the *subject* (or 112 | ``sub`` claim) is set to the principal. You can also add extra claims to the 113 | token by passing keyword parameters to the ``create_jwt_token`` method. 114 | 115 | .. code-block:: python 116 | 117 | token = request.create_jwt_token(user.id, 118 | name=user.name, 119 | admin=(user.role == 'admin')) 120 | 121 | 122 | All claims found in a JWT token can be accessed through the ``jwt_claims`` 123 | dictionary property on a request. For the above example you can retrieve the 124 | name and admin-status for the user directly from the request: 125 | 126 | .. code-block:: python 127 | 128 | print('User id: %d' % request.authenticated_userid) 129 | print('Users name: %s', request.jwt_claims['name']) 130 | if request.jwt_claims['admin']: 131 | print('This user is an admin!') 132 | 133 | Keep in mind that data ``jwt_claims`` only reflects the claims from a JWT 134 | token and do not check if the user is valid: the callback configured for the 135 | authentication policy is *not* checked. For this reason you should always use 136 | ``request.authenticated_userid`` instead of ``request.jwt_claims['sub']``. 137 | 138 | You can also use extra claims to manage extra principals for users. For example 139 | you could claims to represent add group membership or roles for a user. This 140 | requires two steps: first add the extra claims to the JWT token as shown above, 141 | and then use the authentication policy's callback hook to turn the extra claim 142 | into principals. Here is a quick example: 143 | 144 | .. code-block:: python 145 | 146 | def add_role_principals(userid, request): 147 | return ['role:%s' % role for role in request.jwt_claims.get('roles', [])] 148 | 149 | config.set_jwt_authentication_policy(callback=add_role_principals) 150 | 151 | 152 | You can then use the role principals in an ACL: 153 | 154 | .. code-block:: python 155 | 156 | class MyView: 157 | __acl__ = [ 158 | (Allow, Everyone, ['read']), 159 | (Allow, 'role:admin', ['create', 'update']), 160 | ] 161 | 162 | Validation Example 163 | ------------------ 164 | 165 | After creating and returning the token through your API with 166 | ``create_jwt_token`` you can test by issuing an HTTP authorization header type 167 | for JWT. 168 | 169 | .. code-block:: text 170 | 171 | GET /resource HTTP/1.1 172 | Host: server.example.com 173 | Authorization: JWT eyJhbGciOiJIUzI1NiIXVCJ9...TJVA95OrM7E20RMHrHDcEfxjoYZgeFONFh7HgQ 174 | 175 | We can test using curl. 176 | 177 | .. code-block:: bash 178 | 179 | curl --header 'Authorization: JWT TOKEN' server.example.com/ROUTE_PATH 180 | 181 | .. code-block:: python 182 | 183 | config.add_route('example', '/ROUTE_PATH') 184 | @view_config(route_name=example) 185 | def some_action(request): 186 | if request.authenticated_userid: 187 | # Do something 188 | 189 | 190 | Settings 191 | -------- 192 | 193 | There are a number of flags that specify how tokens are created and verified. 194 | You can either set this in your .ini-file, or pass/override them directly to the 195 | ``config.set_jwt_authentication_policy()`` function. 196 | 197 | +--------------+-----------------+---------------+--------------------------------------------+ 198 | | Parameter | ini-file entry | Default | Description | 199 | +==============+=================+===============+============================================+ 200 | | private_key | jwt.private_key | | Key used to hash or sign tokens. | 201 | +--------------+-----------------+---------------+--------------------------------------------+ 202 | | public_key | jwt.public_key | | Key used to verify token signatures. Only | 203 | | | | | used with asymmetric algorithms. | 204 | +--------------+-----------------+---------------+--------------------------------------------+ 205 | | algorithm | jwt.algorithm | HS512 | Hash or encryption algorithm | 206 | +--------------+-----------------+---------------+--------------------------------------------+ 207 | | expiration | jwt.expiration | | Number of seconds (or a datetime.timedelta | 208 | | | | | instance) before a token expires. | 209 | +--------------+-----------------+---------------+--------------------------------------------+ 210 | | audience | jwt.audience | | Proposed audience for the token | 211 | +--------------+-----------------+---------------+--------------------------------------------+ 212 | | leeway | jwt.leeway | 0 | Number of seconds a token is allowed to be | 213 | | | | | expired before it is rejected. | 214 | +--------------+-----------------+---------------+--------------------------------------------+ 215 | | http_header | jwt.http_header | Authorization | HTTP header used for tokens | 216 | +--------------+-----------------+---------------+--------------------------------------------+ 217 | | auth_type | jwt.auth_type | JWT | Authentication type used in Authorization | 218 | | | | | header. Unused for other HTTP headers. | 219 | +--------------+-----------------+---------------+--------------------------------------------+ 220 | | json_encoder | | None | A subclass of JSONEncoder to be used | 221 | | | | | to encode principal and claims infos. | 222 | +--------------+-----------------+---------------+--------------------------------------------+ 223 | 224 | The follow options applies to the cookie-based authentication policy: 225 | 226 | +----------------+---------------------------+---------------+--------------------------------------------+ 227 | | Parameter | ini-file entry | Default | Description | 228 | +================+===========================+===============+============================================+ 229 | | cookie_name | jwt.cookie_name | Authorization | Key used to identify the cookie. | 230 | +----------------+---------------------------+---------------+--------------------------------------------+ 231 | | cookie_path | jwt.cookie_path | None | Path for cookie. | 232 | +----------------+---------------------------+---------------+--------------------------------------------+ 233 | | https_only | jwt.https_only_cookie | True | Whether or not the token should only be | 234 | | | | | sent through a secure HTTPS transport | 235 | +----------------+---------------------------+---------------+--------------------------------------------+ 236 | | reissue_time | jwt.cookie_reissue_time | None | Number of seconds (or a datetime.timedelta | 237 | | | | | instance) before a cookie (and the token | 238 | | | | | within it) is reissued | 239 | +----------------+---------------------------+---------------+--------------------------------------------+ 240 | 241 | Pyramid JWT example use cases 242 | ============================= 243 | 244 | This is a basic guide (that will assume for all following statements that you 245 | have followed the Readme for this project) that will explain how (and why) to 246 | use JWT to secure/restrict access to a pyramid REST style backend API, this 247 | guide will explain a basic overview on: 248 | 249 | - Creating JWT's 250 | - Decoding JWT's 251 | - Restricting access to certain pyramid views via JWT's 252 | 253 | 254 | Creating JWT's 255 | -------------- 256 | 257 | First off, lets start with the first view in our pyramid project, this would 258 | normally be say a login view, this view has no permissions associated with it, 259 | any user can access and post login credentials to it, for example: 260 | 261 | .. code-block:: python 262 | 263 | def authenticate_user(login, password): 264 | # Note the below will not work, its just an example of returning a user 265 | # object back to the JWT creation. 266 | login_query = session.query(User).\ 267 | filter(User.login == login).\ 268 | filter(User.password == password).first() 269 | 270 | if login_query: 271 | user_dict = { 272 | 'userid': login_query.id, 273 | 'user_name': login_query.user_name, 274 | 'roles': login_query.roles 275 | } 276 | # An example of login_query.roles would be a list 277 | # print(login_query.roles) 278 | # ['admin', 'reports'] 279 | return user_dict 280 | else: 281 | # If we end up here, no logins have been found 282 | return None 283 | 284 | @view_config('login', request_method='POST', renderer='json') 285 | def login(request): 286 | '''Create a login view 287 | ''' 288 | login = request.POST['login'] 289 | password = request.POST['password'] 290 | user = authenticate(login, password) 291 | if user: 292 | return { 293 | 'result': 'ok', 294 | 'token': request.create_jwt_token( 295 | user['userid'], 296 | roles=user['roles'], 297 | userName=user['user_name'] 298 | ) 299 | } 300 | else: 301 | return { 302 | 'result': 'error', 303 | 'token': None 304 | } 305 | 306 | Now what this does is return your JWT back to whatever front end application 307 | you may have, with the user details, along with their permissions, this will 308 | return a decoded token such as: 309 | 310 | .. code-block:: 311 | 312 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyTmFtZSI6Imx1a2UiLCJyb2xlcyI6WyJhZG1pbiIsInJlcG9ydHMiXSwic3ViIjo0LCJpYXQiOjE1MTkwNDQyNzB9.__KjyW1U-tpAEvTbSJsasS-8CaFyXH784joUPONH6hQ 313 | 314 | Now I would suggest heading over to `JWT.io `_, copy this data 315 | into their page, and you will see the decoded token: 316 | 317 | .. code-block:: json 318 | 319 | { 320 | "userName": "luke", 321 | "roles": [ 322 | "admin", 323 | "reports" 324 | ], 325 | "sub": 4, 326 | "iat": 1519044270 327 | } 328 | 329 | Note, at the bottom of jwt.io's webpage, that the signature shows verified, if 330 | you change the "secret" at the bottom, it will say "NOT Verified" this is 331 | because in order for any JWT process to be verified, the valid "secret" or 332 | "private key" must be used. It is important to note that any data sent in a JWT 333 | is accessible and readable by anyone. 334 | 335 | Decoding JWT 336 | ------------ 337 | 338 | The following section would also work if pyramid did not create the JWT, all it 339 | needs to know to decode a JWT is the "secret" or "private key" used to 340 | create/sign the original JWT.By their nature JWT's aren't secure, but they can 341 | be used "to secure". In our example above, we returned the "roles" array in our 342 | JWT, this had two properties "admin" and "reports" so we could then in our 343 | pyramid application, setup an ACL to map JWT permissions to pyramid based 344 | security, for example in our projects __init__.py we could add: 345 | 346 | .. code-block:: python 347 | 348 | from pyramid.security import ALL_PERMISSIONS 349 | 350 | class RootACL(object): 351 | __acl__ = [ 352 | (Allow, 'admin', ALL_PERMISSIONS), 353 | (Allow, 'reports', ['reports']) 354 | ] 355 | 356 | def __init__(self, request): 357 | pass 358 | 359 | What this ACL will do is allow anyone with the "admin" role in their JWT access 360 | to all views protected via a permission, where as users with "reports" in their 361 | JWT will only have access to views protected via the "reports" permission. 362 | 363 | Now this ACL in itself is not enough to map the JWT permission to pyramids 364 | security backend, we need to also add the following to __init__.py: 365 | 366 | .. code-block:: python 367 | 368 | from pyramid.authorization import ACLAuthorizationPolicy 369 | 370 | 371 | def add_role_principals(userid, request): 372 | return request.jwt_claims.get('roles', []) 373 | 374 | def main(global_config, **settings): 375 | """ This function returns a Pyramid WSGI application. 376 | """ 377 | config = Configurator(settings=settings) 378 | ... 379 | # Enable JWT - JSON Web Token based authentication 380 | config.set_root_factory(RootACL) 381 | config.set_authorization_policy(ACLAuthorizationPolicy()) 382 | config.include('pyramid_jwt') 383 | config.set_jwt_authentication_policy('myJWTsecretKeepThisSafe', 384 | auth_type='Bearer', 385 | callback=add_role_principals) 386 | 387 | This code will map any properties of the "roles" attribute of the JWT, run them 388 | through the ACL and then tie them into pyramids security framework. 389 | 390 | Creating a JWT within a cookie 391 | ------------------------------ 392 | 393 | Since cookie-based authentication is already standardized within Pyramid by the 394 | ``remember()`` and ``forget()`` calls, you should simply use them: 395 | 396 | .. code-block:: python 397 | 398 | from pyramid.response import Response 399 | from pyramid.security import remember 400 | 401 | @view_config('login', request_method='POST', renderer='json') 402 | def login_with_cookies(request): 403 | '''Create a login view 404 | ''' 405 | login = request.POST['login'] 406 | password = request.POST['password'] 407 | user = authenticate(login, password) # From the previous snippet 408 | if user: 409 | headers = remember( 410 | user['userid'], 411 | roles=user['roles'], 412 | userName=user['user_name'] 413 | ) 414 | return Response(headers=headers, body="OK") # Or maybe redirect somewhere else 415 | return Response(status=403) # Or redirect back to login 416 | 417 | Please note that since the JWT cookies will be stored inside the cookies, 418 | there's no need for your app to explicitly include it on the response body. 419 | The browser (or whatever consuming this response) is responsible to keep that 420 | cookie for as long as it's valid, and re-send it on the following requests. 421 | 422 | Also note that there's no need to decode the cookie manually. The Policy 423 | handles that through the existing ``request.jwt_claims``. 424 | 425 | How is this secure? 426 | ------------------- 427 | 428 | For example, a JWT could easily be manipulated, anyone could hijack the token, 429 | change the values of the "roles" array to gain access to a view they do not 430 | actually have access to. WRONG! pyramid_jwt checks the signature of all JWT 431 | tokens as part of the decode process, if it notices that the signature of the 432 | token is not as expected, it means either the application has been setup 433 | correctly with the wrong private key, OR an attacker has tried to manipulate 434 | the token. 435 | 436 | The major security concern when working with JWT tokens is where to store the 437 | token itself: While pyramid_jwt is able to detect tampered tokens, nothing can 438 | be done if the actual valid token leaks. Any user with a valid token will be 439 | correctly authenticated within your app. Storing the token securely is outside 440 | the scope of this library. 441 | 442 | When using JWT within a cookie, the browser (or tool consuming the cookie) is 443 | responsible for storing it, but pyramid_jwt does set the ``http_only`` flag on 444 | all cookies, so javascript running on the page cannot access these cookies, 445 | which helps mitigate XSS attacks. It's still mentioning that the tokens are 446 | still visible through the browser's debugging/inspection tools. 447 | 448 | Securing views with JWT's 449 | ------------------------- 450 | 451 | In the example posted above we creating an "admin" role that we gave 452 | ALL_PERMISSIONS access in our ACL, so any user with this role could access any 453 | view e.g.: 454 | 455 | .. code-block:: python 456 | 457 | @view_config(route_name='view_a', request_method='GET', 458 | permission="admin", renderer='json') 459 | def view_a(request): 460 | return 461 | 462 | @view_config(route_name='view_b', request_method='GET', 463 | permission="cpanel", renderer='json') 464 | def view_b(request): 465 | return 466 | 467 | This user would be able to access both of these views, however any user with 468 | the "reports" permission would not be able to access any of these views, they 469 | could only access permissions with "reports". Obviously in our use case, one 470 | user had both "admin" and "reports" permissions, so they would be able to 471 | access any view regardless. 472 | 473 | -------------------------------------------------------------------------------- /changes.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.6.1 - October 9, 2020 5 | ----------------------- 6 | 7 | - `Pull request #46 `_: 8 | allow configurating the path for JWT cookies. 9 | From `surfjudge `_ 10 | 11 | 12 | 1.6.0 - July 9, 2020 13 | -------------------- 14 | 15 | - `Pull request #27 `_: 16 | add support for JWT tokens in cookies. 17 | From `phrfpeixoto `_ 18 | 19 | 1.5.1 - May 5, 2020 20 | ------------------- 21 | 22 | - Fix release versioning error. 23 | 24 | 25 | 1.5.0 - May 5, 2020 26 | ------------------- 27 | 28 | - Drop official support for Python 2.7. 29 | 30 | - Use GitHub actions for CI and automated releases. 31 | 32 | - `Pull request #42 `_: 33 | use the JSON encoder from Pyramid as default. 34 | From `phrfpeixoto `_ 35 | 36 | 1.4.1 - August 10, 2018 37 | ----------------------- 38 | 39 | - `Pull request #23 `_: 40 | Allow specifying the audience in the app configuration, from `John Stevens II 41 | `_. 42 | 43 | 44 | 1.4 - August 9, 2018 45 | -------------------- 46 | 47 | - `Pull request #21 `_: 48 | add support for JWT aud claims, from `Luke Crooks 49 | `_. 50 | 51 | 1.3 - March 20, 2018 52 | --------------------- 53 | 54 | - `Issue #20 `_: 55 | Fix handling of public keys. 56 | - `Pull request #17 `_: 57 | a lot of documentation improvements from `Luke Crooks 58 | `_. 59 | 60 | 61 | 1.2 - May 25, 2017 62 | ------------------ 63 | 64 | - Fix a `log.warn` deprecation warning on Python 3.6. 65 | 66 | - Documentation improvements, courtesy of `Éric Araujo `_ 67 | and `Guillermo Cruz `_. 68 | 69 | - `Pull request #10 `_ 70 | Allow use of a custom JSON encoder. 71 | Submitted by `Julien Meyer `_. 72 | 73 | 74 | 1.1 - May 4, 2016 75 | ----------------- 76 | 77 | - `Issue #2 `_: 78 | Support setting and reading extra claims in a JWT token. 79 | 80 | - `Pull request #4 `_: 81 | Fix parsing of expiration and leeway settings from a configuration value. 82 | Submitted by `Daniel Kraus `_. 83 | 84 | - `Pull request #3 `_: 85 | Allow overriding the expiration timestamp for a token when creating a new 86 | token. Submitted by `Daniel Kraus`_. 87 | 88 | 89 | 1.0 - December 17, 2015 90 | ----------------------- 91 | 92 | - First release 93 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[tests] 2 | beautifulsoup4==4.4.1 3 | coverage==4.0.3 4 | PasteDeploy==1.5.2 5 | py==1.4.31 6 | PyJWT==1.4.0 7 | pyramid==1.5.7 8 | pytest==3.1.0 9 | pytest-cov==2.2.0 10 | repoze.lru==0.6 11 | six==1.10.0 12 | translationstring==1.3 13 | venusian==1.0 14 | waitress==1.4.3 15 | WebOb==1.5.1 16 | WebTest==2.0.20 17 | zope.deprecation==4.1.2 18 | zope.interface==4.1.3 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = 99.0.0 3 | name = pyramid_jwt 4 | description = JWT authentication policy for Pyramid 5 | long-description = file: README.rst 6 | long-description-content-type = text/x-rst; charset=UTF-8 7 | classifiers = 8 | Intended Audience :: Developers 9 | License :: DFSG approved 10 | License :: OSI Approved :: BSD License 11 | Operating System :: OS Independent 12 | Programming Language :: Python :: 3 13 | Programming Language :: Python :: 3.6 14 | Programming Language :: Python :: 3.7 15 | Programming Language :: Python :: 3.8 16 | Topic :: Software Development :: Libraries :: Python Modules 17 | keywords = Pyramid JWT authentication security 18 | author = Wichert Akkerman 19 | author-email = wichert@wiggy.net 20 | home-page = https://github.com/wichert/pyramid_jwt 21 | license = BSD 22 | platforms = any 23 | 24 | 25 | [options] 26 | zip_safe = True 27 | packages = pyramid_jwt 28 | package_dir = = src 29 | include_package_data = True 30 | install_requires = 31 | pyramid 32 | PyJWT 33 | 34 | [options.extras_require] 35 | testing = 36 | WebTest 37 | pytest 38 | pytest-freezegun 39 | 40 | [tool:pytest] 41 | testpaths = tests 42 | addopts = 43 | --tb=short 44 | --verbose 45 | 46 | [bdist_wheel] 47 | universal=1 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/pyramid_jwt/__init__.py: -------------------------------------------------------------------------------- 1 | from .policy import ( 2 | JWTAuthenticationPolicy, 3 | JWTCookieAuthenticationPolicy, 4 | json_encoder_factory, 5 | ) 6 | 7 | 8 | def includeme(config): 9 | json_encoder_factory.registry = config.registry 10 | config.add_directive( 11 | "set_jwt_authentication_policy", set_jwt_authentication_policy, action_wrap=True 12 | ) 13 | config.add_directive( 14 | "set_jwt_cookie_authentication_policy", 15 | set_jwt_cookie_authentication_policy, 16 | action_wrap=True, 17 | ) 18 | 19 | 20 | def create_jwt_authentication_policy( 21 | config, 22 | private_key=None, 23 | public_key=None, 24 | algorithm=None, 25 | expiration=None, 26 | leeway=None, 27 | http_header=None, 28 | auth_type=None, 29 | callback=None, 30 | json_encoder=None, 31 | audience=None, 32 | ): 33 | settings = config.get_settings() 34 | private_key = private_key or settings.get("jwt.private_key") 35 | audience = audience or settings.get("jwt.audience") 36 | algorithm = algorithm or settings.get("jwt.algorithm") or "HS512" 37 | if not algorithm.startswith("HS"): 38 | public_key = public_key or settings.get("jwt.public_key") 39 | else: 40 | public_key = None 41 | if expiration is None and "jwt.expiration" in settings: 42 | expiration = int(settings.get("jwt.expiration")) 43 | leeway = int(settings.get("jwt.leeway", 0)) if leeway is None else leeway 44 | http_header = http_header or settings.get("jwt.http_header") or "Authorization" 45 | if http_header.lower() == "authorization": 46 | auth_type = auth_type or settings.get("jwt.auth_type") or "JWT" 47 | else: 48 | auth_type = None 49 | return JWTAuthenticationPolicy( 50 | private_key=private_key, 51 | public_key=public_key, 52 | algorithm=algorithm, 53 | leeway=leeway, 54 | expiration=expiration, 55 | http_header=http_header, 56 | auth_type=auth_type, 57 | callback=callback, 58 | json_encoder=json_encoder, 59 | audience=audience, 60 | ) 61 | 62 | 63 | def _request_create_token(request, principal, expiration=None, audience=None, **claims): 64 | 65 | return request.authentication_policy.create_token( 66 | principal, expiration, audience, **claims 67 | ) 68 | 69 | 70 | def _request_claims(request): 71 | return request.authentication_policy.get_claims(request) 72 | 73 | 74 | def _configure(config, auth_policy): 75 | config.set_authentication_policy(auth_policy) 76 | config.add_request_method( 77 | lambda request: auth_policy, "authentication_policy", reify=True 78 | ) 79 | config.add_request_method(_request_claims, "jwt_claims", reify=True) 80 | config.add_request_method(_request_create_token, "create_jwt_token") 81 | 82 | 83 | def set_jwt_cookie_authentication_policy( 84 | config, 85 | private_key=None, 86 | public_key=None, 87 | algorithm=None, 88 | expiration=None, 89 | leeway=None, 90 | http_header=None, 91 | auth_type=None, 92 | callback=None, 93 | json_encoder=None, 94 | audience=None, 95 | cookie_name=None, 96 | https_only=True, 97 | reissue_time=None, 98 | cookie_path=None, 99 | ): 100 | settings = config.get_settings() 101 | cookie_name = cookie_name or settings.get("jwt.cookie_name") 102 | cookie_path = cookie_path or settings.get("jwt.cookie_path") 103 | reissue_time = reissue_time or settings.get("jwt.cookie_reissue_time") 104 | if https_only is None: 105 | https_only = settings.get("jwt.https_only_cookie", True) 106 | 107 | auth_policy = create_jwt_authentication_policy( 108 | config, 109 | private_key, 110 | public_key, 111 | algorithm, 112 | expiration, 113 | leeway, 114 | http_header, 115 | auth_type, 116 | callback, 117 | json_encoder, 118 | audience, 119 | ) 120 | 121 | auth_policy = JWTCookieAuthenticationPolicy.make_from( 122 | auth_policy, 123 | cookie_name=cookie_name, 124 | https_only=https_only, 125 | reissue_time=reissue_time, 126 | cookie_path=cookie_path, 127 | ) 128 | 129 | _configure(config, auth_policy) 130 | 131 | 132 | def set_jwt_authentication_policy( 133 | config, 134 | private_key=None, 135 | public_key=None, 136 | algorithm=None, 137 | expiration=None, 138 | leeway=None, 139 | http_header=None, 140 | auth_type=None, 141 | callback=None, 142 | json_encoder=None, 143 | audience=None, 144 | ): 145 | policy = create_jwt_authentication_policy( 146 | config, 147 | private_key, 148 | public_key, 149 | algorithm, 150 | expiration, 151 | leeway, 152 | http_header, 153 | auth_type, 154 | callback, 155 | json_encoder, 156 | audience, 157 | ) 158 | 159 | _configure(config, policy) 160 | -------------------------------------------------------------------------------- /src/pyramid_jwt/policy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | import warnings 5 | from json import JSONEncoder 6 | 7 | import jwt 8 | from pyramid.renderers import JSON 9 | from webob.cookies import CookieProfile 10 | from zope.interface import implementer 11 | from pyramid.authentication import CallbackAuthenticationPolicy 12 | from pyramid.interfaces import IAuthenticationPolicy, IRendererFactory 13 | 14 | log = logging.getLogger("pyramid_jwt") 15 | marker = [] 16 | 17 | 18 | class PyramidJSONEncoderFactory(JSON): 19 | def __init__(self, pyramid_registry=None, **kw): 20 | super().__init__(**kw) 21 | self.registry = pyramid_registry 22 | 23 | def __call__(self, *args, **kwargs): 24 | json_renderer = None 25 | if self.registry is not None: 26 | json_renderer = self.registry.queryUtility( 27 | IRendererFactory, "json", default=JSONEncoder 28 | ) 29 | 30 | request = kwargs.get("request") 31 | if not kwargs.get("default") and isinstance(json_renderer, JSON): 32 | self.components = json_renderer.components 33 | kwargs["default"] = self._make_default(request) 34 | return JSONEncoder(*args, **kwargs) 35 | 36 | 37 | json_encoder_factory = PyramidJSONEncoderFactory(None) 38 | 39 | 40 | @implementer(IAuthenticationPolicy) 41 | class JWTAuthenticationPolicy(CallbackAuthenticationPolicy): 42 | def __init__( 43 | self, 44 | private_key, 45 | public_key=None, 46 | algorithm="HS512", 47 | leeway=0, 48 | expiration=None, 49 | default_claims=None, 50 | http_header="Authorization", 51 | auth_type="JWT", 52 | callback=None, 53 | json_encoder=None, 54 | audience=None, 55 | ): 56 | self.private_key = private_key 57 | self.public_key = public_key if public_key is not None else private_key 58 | self.algorithm = algorithm 59 | self.leeway = leeway 60 | self.default_claims = default_claims if default_claims else {} 61 | self.http_header = http_header 62 | self.auth_type = auth_type 63 | if expiration: 64 | if not isinstance(expiration, datetime.timedelta): 65 | expiration = datetime.timedelta(seconds=expiration) 66 | self.expiration = expiration 67 | else: 68 | self.expiration = None 69 | if audience: 70 | self.audience = audience 71 | else: 72 | self.audience = None 73 | self.callback = callback 74 | if json_encoder is None: 75 | json_encoder = json_encoder_factory 76 | self.json_encoder = json_encoder 77 | self.jwt_std_claims = ("sub", "iat", "exp", "aud") 78 | 79 | def create_token(self, principal, expiration=None, audience=None, **claims): 80 | payload = self.default_claims.copy() 81 | payload.update(claims) 82 | payload["sub"] = principal 83 | payload["iat"] = iat = datetime.datetime.utcnow() 84 | expiration = expiration or self.expiration 85 | audience = audience or self.audience 86 | if expiration: 87 | if not isinstance(expiration, datetime.timedelta): 88 | expiration = datetime.timedelta(seconds=expiration) 89 | payload["exp"] = iat + expiration 90 | if audience: 91 | payload["aud"] = audience 92 | token = jwt.encode( 93 | payload, 94 | self.private_key, 95 | algorithm=self.algorithm, 96 | json_encoder=self.json_encoder, 97 | ) 98 | if not isinstance(token, str): # Python3 unicode madness 99 | token = token.decode("ascii") 100 | return token 101 | 102 | def get_claims(self, request): 103 | if self.http_header == "Authorization": 104 | try: 105 | if request.authorization is None: 106 | return {} 107 | except ValueError: # Invalid Authorization header 108 | return {} 109 | (auth_type, token) = request.authorization 110 | if auth_type != self.auth_type: 111 | return {} 112 | else: 113 | token = request.headers.get(self.http_header) 114 | if not token: 115 | return {} 116 | return self.jwt_decode(request, token) 117 | 118 | def jwt_decode(self, request, token): 119 | try: 120 | claims = jwt.decode( 121 | token, 122 | self.public_key, 123 | algorithms=[self.algorithm], 124 | leeway=self.leeway, 125 | audience=self.audience, 126 | ) 127 | return claims 128 | except jwt.InvalidTokenError as e: 129 | log.warning("Invalid JWT token from %s: %s", request.remote_addr, e) 130 | return {} 131 | 132 | def unauthenticated_userid(self, request): 133 | return request.jwt_claims.get("sub") 134 | 135 | def remember(self, request, principal, **kw): 136 | warnings.warn( 137 | "JWT tokens need to be returned by an API. Using remember() " 138 | "has no effect.", 139 | stacklevel=3, 140 | ) 141 | return [] 142 | 143 | def forget(self, request): 144 | warnings.warn( 145 | "JWT tokens are managed by API (users) manually. Using forget() " 146 | "has no effect.", 147 | stacklevel=3, 148 | ) 149 | return [] 150 | 151 | 152 | class ReissueError(Exception): 153 | pass 154 | 155 | 156 | @implementer(IAuthenticationPolicy) 157 | class JWTCookieAuthenticationPolicy(JWTAuthenticationPolicy): 158 | def __init__( 159 | self, 160 | private_key, 161 | public_key=None, 162 | algorithm="HS512", 163 | leeway=0, 164 | expiration=None, 165 | default_claims=None, 166 | http_header="Authorization", 167 | auth_type="JWT", 168 | callback=None, 169 | json_encoder=None, 170 | audience=None, 171 | cookie_name=None, 172 | https_only=True, 173 | reissue_time=None, 174 | cookie_path=None, 175 | ): 176 | super(JWTCookieAuthenticationPolicy, self).__init__( 177 | private_key, 178 | public_key, 179 | algorithm, 180 | leeway, 181 | expiration, 182 | default_claims, 183 | http_header, 184 | auth_type, 185 | callback, 186 | json_encoder, 187 | audience, 188 | ) 189 | 190 | self.https_only = https_only 191 | self.cookie_name = cookie_name or "Authorization" 192 | self.max_age = self.expiration and self.expiration.total_seconds() 193 | 194 | if reissue_time and isinstance(reissue_time, datetime.timedelta): 195 | reissue_time = reissue_time.total_seconds() 196 | self.reissue_time = reissue_time 197 | 198 | self.cookie_profile = CookieProfile( 199 | cookie_name=self.cookie_name, 200 | secure=self.https_only, 201 | max_age=self.max_age, 202 | httponly=True, 203 | path=cookie_path, 204 | ) 205 | 206 | @staticmethod 207 | def make_from(policy, **kwargs): 208 | if not isinstance(policy, JWTAuthenticationPolicy): 209 | pol_type = policy.__class__.__name__ 210 | raise ValueError("Invalid policy type %s" % pol_type) 211 | 212 | return JWTCookieAuthenticationPolicy( 213 | private_key=policy.private_key, 214 | public_key=policy.public_key, 215 | algorithm=policy.algorithm, 216 | leeway=policy.leeway, 217 | expiration=policy.expiration, 218 | default_claims=policy.default_claims, 219 | http_header=policy.http_header, 220 | auth_type=policy.auth_type, 221 | callback=policy.callback, 222 | json_encoder=policy.json_encoder, 223 | audience=policy.audience, 224 | **kwargs 225 | ) 226 | 227 | def _get_cookies(self, request, value, max_age=None, domains=None): 228 | profile = self.cookie_profile(request) 229 | if domains is None: 230 | domains = [request.domain] 231 | 232 | kw = {"domains": domains} 233 | if max_age is not None: 234 | kw["max_age"] = max_age 235 | 236 | headers = profile.get_headers(value, **kw) 237 | return headers 238 | 239 | def remember(self, request, principal, **kw): 240 | token = self.create_token(principal, self.expiration, self.audience, **kw) 241 | 242 | if hasattr(request, "_jwt_cookie_reissued"): 243 | request._jwt_cookie_reissue_revoked = True 244 | 245 | domains = kw.get("domains") 246 | 247 | return self._get_cookies(request, token, self.max_age, domains=domains) 248 | 249 | def forget(self, request): 250 | request._jwt_cookie_reissue_revoked = True 251 | return self._get_cookies(request, None) 252 | 253 | def get_claims(self, request): 254 | profile = self.cookie_profile.bind(request) 255 | cookie = profile.get_value() 256 | 257 | reissue = self.reissue_time is not None 258 | 259 | if cookie is None: 260 | return {} 261 | 262 | claims = self.jwt_decode(request, cookie) 263 | 264 | if reissue and not hasattr(request, "_jwt_cookie_reissued"): 265 | self._handle_reissue(request, claims) 266 | return claims 267 | 268 | def _handle_reissue(self, request, claims): 269 | if not request or not claims: 270 | raise ValueError("Cannot handle JWT reissue: insufficient arguments") 271 | 272 | if "iat" not in claims: 273 | raise ReissueError("Token claim's is missing IAT") 274 | if "sub" not in claims: 275 | raise ReissueError("Token claim's is missing SUB") 276 | 277 | token_dt = claims["iat"] 278 | principal = claims["sub"] 279 | now = time.time() 280 | 281 | if now < token_dt + self.reissue_time: 282 | # Token not yet eligible for reissuing 283 | return 284 | 285 | extra_claims = dict( 286 | filter(lambda item: item[0] not in self.jwt_std_claims, claims.items()) 287 | ) 288 | headers = self.remember(request, principal, **extra_claims) 289 | 290 | def reissue_jwt_cookie(request, response): 291 | if not hasattr(request, "_jwt_cookie_reissue_revoked"): 292 | for k, v in headers: 293 | response.headerlist.append((k, v)) 294 | 295 | request.add_response_callback(reissue_jwt_cookie) 296 | request._jwt_cookie_reissued = True 297 | -------------------------------------------------------------------------------- /tests/test_cookies.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from pyramid.interfaces import IAuthenticationPolicy 6 | from webob import Request 7 | from zope.interface.verify import verifyObject 8 | 9 | from pyramid_jwt.policy import JWTCookieAuthenticationPolicy 10 | 11 | 12 | @pytest.fixture(scope="module") 13 | def principal(): 14 | return str(uuid.uuid4()) 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def dummy_request(): 19 | return Request.blank("/") 20 | 21 | 22 | def test_interface(): 23 | verifyObject(IAuthenticationPolicy, JWTCookieAuthenticationPolicy("secret")) 24 | 25 | 26 | def test_cookie(dummy_request, principal): 27 | policy = JWTCookieAuthenticationPolicy("secret") 28 | cookie = policy.remember(dummy_request, principal).pop() 29 | 30 | assert len(cookie) == 2 31 | 32 | header, cookie = cookie 33 | assert header == "Set-Cookie" 34 | assert len(cookie) > 0 35 | 36 | 37 | def test_cookie_name(dummy_request, principal): 38 | policy = JWTCookieAuthenticationPolicy("secret", cookie_name="auth") 39 | _, cookie = policy.remember(dummy_request, principal).pop() 40 | 41 | name, value = cookie.split("=", 1) 42 | assert name == "auth" 43 | 44 | 45 | def test_secure_cookie(): 46 | policy = JWTCookieAuthenticationPolicy("secret", https_only=True) 47 | dummy_request = Request.blank("/") 48 | _, cookie = policy.remember(dummy_request, str(uuid.uuid4())).pop() 49 | 50 | assert "; secure;" in cookie 51 | assert "; HttpOnly" in cookie 52 | 53 | 54 | def test_insecure_cookie(dummy_request, principal): 55 | policy = JWTCookieAuthenticationPolicy("secret", https_only=False) 56 | _, cookie = policy.remember(dummy_request, principal).pop() 57 | 58 | assert "; secure;" not in cookie 59 | assert "; HttpOnly" in cookie 60 | 61 | 62 | def test_cookie_decode(dummy_request, principal): 63 | policy = JWTCookieAuthenticationPolicy("secret", https_only=False) 64 | 65 | header, cookie = policy.remember(dummy_request, principal).pop() 66 | name, value = cookie.split("=", 1) 67 | 68 | value, _ = value.split(";", 1) 69 | dummy_request.cookies = {name: value} 70 | 71 | claims = policy.get_claims(dummy_request) 72 | assert claims["sub"] == principal 73 | 74 | 75 | def test_cookie_max_age(dummy_request, principal): 76 | policy = JWTCookieAuthenticationPolicy("secret", cookie_name="auth", expiration=100) 77 | _, cookie = policy.remember(dummy_request, principal).pop() 78 | _, value = cookie.split("=", 1) 79 | 80 | _, meta = value.split(";", 1) 81 | assert "Max-Age=100" in meta 82 | assert "expires" in meta 83 | 84 | 85 | @pytest.mark.freeze_time 86 | def test_expired_token(dummy_request, principal, freezer): 87 | policy = JWTCookieAuthenticationPolicy("secret", cookie_name="auth", expiration=1) 88 | _, cookie = policy.remember(dummy_request, principal).pop() 89 | name, value = cookie.split("=", 1) 90 | 91 | freezer.tick(delta=2) 92 | 93 | value, _ = value.split(";", 1) 94 | dummy_request.cookies = {name: value} 95 | claims = policy.get_claims(dummy_request) 96 | 97 | assert claims == {} 98 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from pyramid.config import Configurator 5 | from pyramid.authorization import ACLAuthorizationPolicy 6 | from pyramid.renderers import JSON 7 | from pyramid.response import Response 8 | from pyramid.security import Allow, Authenticated, remember, forget 9 | from webtest import TestApp 10 | 11 | 12 | def login_view(request): 13 | return {"token": request.create_jwt_token(1)} 14 | 15 | 16 | def login_cookie_view(request): 17 | headers = remember(request, 1) 18 | return Response(status=200, headers=headers, body="OK") 19 | 20 | 21 | def logout_cookie_view(request): 22 | headers = forget(request) 23 | return Response(status=200, headers=headers, body="OK") 24 | 25 | 26 | def suspicious_behaviour_view(request): 27 | request._jwt_cookie_reissue_revoked = True 28 | return Response( 29 | status=200, body="Suspicious behaviour detected! Revoking cookie reissue" 30 | ) 31 | 32 | 33 | def secure_view(request): 34 | return "OK" 35 | 36 | 37 | def dump_claims(request): 38 | return request.jwt_claims 39 | 40 | 41 | class Root: 42 | __acl__ = [ 43 | (Allow, Authenticated, ("read",)), 44 | ] 45 | 46 | def __init__(self, request): 47 | pass 48 | 49 | 50 | class NonSerializable(object): 51 | pass 52 | 53 | 54 | class Serializable(object): 55 | def __json__(self): 56 | return "This is JSON Serializable" 57 | 58 | 59 | def extra_claims(request): 60 | return { 61 | "token": request.create_jwt_token(principal=1, extra_claim=NonSerializable()) 62 | } 63 | 64 | 65 | @pytest.fixture(scope="function") 66 | def base_config() -> Configurator: 67 | config = Configurator() 68 | config.set_authorization_policy(ACLAuthorizationPolicy()) 69 | 70 | config.include("pyramid_jwt") 71 | config.set_root_factory(Root) 72 | config.add_route("secure", "/secure") 73 | config.add_view( 74 | secure_view, route_name="secure", renderer="string", permission="read" 75 | ) 76 | config.add_route("extra_claims", "/extra_claims") 77 | config.add_view(extra_claims, route_name="extra_claims", renderer="json") 78 | config.add_route("dump_claims", "/dump_claims") 79 | config.add_view( 80 | dump_claims, route_name="dump_claims", renderer="json", permission="read" 81 | ) 82 | return config 83 | 84 | 85 | @pytest.fixture(scope="function") 86 | def app_config(base_config) -> Configurator: 87 | base_config.add_route("login", "/login") 88 | base_config.add_view(login_view, route_name="login", renderer="json") 89 | 90 | # Enable JWT authentication. 91 | base_config.set_jwt_authentication_policy("secret", http_header="X-Token") 92 | return base_config 93 | 94 | 95 | @pytest.fixture(scope="function") 96 | def cookie_config(base_config): 97 | base_config.add_route("login", "/login") 98 | base_config.add_view(login_cookie_view, route_name="login", renderer="json") 99 | base_config.add_route("logout", "/logout") 100 | base_config.add_view( 101 | logout_cookie_view, route_name="logout", renderer="string", permission="read" 102 | ) 103 | 104 | base_config.add_route("suspicious", "/suspicious") 105 | base_config.add_view( 106 | suspicious_behaviour_view, 107 | route_name="suspicious", 108 | renderer="string", 109 | permission="read", 110 | ) 111 | 112 | # Enable JWT authentication on Cookies. 113 | reissue_time = timedelta(seconds=1) 114 | base_config.set_jwt_cookie_authentication_policy( 115 | "secret", 116 | cookie_name="Token", 117 | expiration=5, 118 | reissue_time=reissue_time, 119 | https_only=False, 120 | ) 121 | return base_config 122 | 123 | 124 | @pytest.fixture(scope="function") 125 | def app(app_config): 126 | app = app_config.make_wsgi_app() 127 | return TestApp(app) 128 | 129 | 130 | @pytest.fixture(scope="function") 131 | def cookie_app(cookie_config): 132 | app = cookie_config.make_wsgi_app() 133 | return TestApp(app) 134 | 135 | 136 | def test_secure_view_requires_auth(app): 137 | app.get("/secure", status=403) 138 | 139 | 140 | def test_login(app): 141 | r = app.get("/login") 142 | token = str(r.json_body["token"]) # Must be str on all Python versions 143 | r = app.get("/secure", headers={"X-Token": token}) 144 | assert r.unicode_body == "OK" 145 | 146 | 147 | def test_pyramid_json_encoder_fail(app): 148 | with pytest.raises(TypeError) as e: 149 | app.get("/extra_claims") 150 | 151 | assert "NonSerializable" in str(e.value) 152 | assert "is not JSON serializable" in str(e.value) 153 | 154 | 155 | def test_pyramid_json_encoder_with_adapter(app): 156 | """Test we can define a custom adapter using global json_renderer_factory""" 157 | from pyramid.renderers import json_renderer_factory 158 | 159 | def serialize_anyclass(obj, request): 160 | return obj.__class__.__name__ 161 | 162 | json_renderer_factory.add_adapter(NonSerializable, serialize_anyclass) 163 | 164 | response = app.get("/extra_claims") 165 | token = str(response.json_body["token"]) 166 | 167 | response = app.get("/dump_claims", headers={"X-Token": token}) 168 | assert response.json_body["extra_claim"] == "NonSerializable" 169 | 170 | 171 | def test_pyramid_custom_json_encoder(app_config: Configurator): 172 | """Test we can still use user-defined custom adapter""" 173 | from pyramid.renderers import json_renderer_factory 174 | 175 | def serialize_anyclass(obj, request): 176 | assert False # This asserts this method will not be called 177 | 178 | json_renderer_factory.add_adapter(NonSerializable, serialize_anyclass) 179 | 180 | def other_serializer(obj, request): 181 | return "other_serializer" 182 | 183 | my_renderer = JSON() 184 | my_renderer.add_adapter(NonSerializable, other_serializer) 185 | app_config.add_renderer("json", my_renderer) 186 | app = TestApp(app_config.make_wsgi_app()) 187 | 188 | response = app.get("/extra_claims") 189 | token = str(response.json_body["token"]) 190 | 191 | response = app.get("/dump_claims", headers={"X-Token": token}) 192 | assert response.json_body["extra_claim"] == "other_serializer" 193 | 194 | 195 | def test_cookie_secured(cookie_app): 196 | response = cookie_app.get("/secure", expect_errors=True) 197 | assert response.status_int == 403 198 | 199 | 200 | def test_cookie_login(cookie_app): 201 | response = cookie_app.get("/login") 202 | assert "Token" in cookie_app.cookies 203 | assert response.body == b"OK" 204 | 205 | response = cookie_app.get("/secure") 206 | assert response.body == b"OK" 207 | 208 | 209 | def test_cookie_logout(cookie_app): 210 | response = cookie_app.get("/login") 211 | assert "Token" in cookie_app.cookies 212 | assert response.body == b"OK" 213 | 214 | response = cookie_app.get("/secure") 215 | assert response.body == b"OK" 216 | 217 | response = cookie_app.get("/logout") 218 | assert response.body == b"OK" 219 | assert "Token" not in cookie_app.cookies 220 | 221 | response = cookie_app.get("/secure", expect_errors=True) 222 | assert response.status_int == 403 223 | 224 | 225 | @pytest.mark.freeze_time 226 | def test_cookie_reissue(cookie_app, freezer): 227 | cookie_app.get("/login") 228 | token = cookie_app.cookies.get("Token") 229 | 230 | freezer.tick(delta=4) 231 | 232 | cookie_app.get("/secure") 233 | other_token = cookie_app.cookies.get("Token") 234 | assert token != other_token 235 | 236 | 237 | @pytest.mark.freeze_time 238 | def test_cookie_reissue_revoke(cookie_app, freezer): 239 | cookie_app.get("/login") 240 | token = cookie_app.cookies.get("Token") 241 | 242 | freezer.tick(delta=4) 243 | 244 | cookie_app.get("/suspicious") 245 | other_token = cookie_app.cookies.get("Token") 246 | assert token == other_token 247 | -------------------------------------------------------------------------------- /tests/test_policy.py: -------------------------------------------------------------------------------- 1 | # vim: fileencoding=utf-8 2 | import warnings 3 | from datetime import timedelta 4 | 5 | from webob import Request 6 | from zope.interface.verify import verifyObject 7 | from pyramid.security import forget 8 | from pyramid.security import remember 9 | from pyramid.testing import testConfig 10 | from pyramid.testing import DummyRequest 11 | from pyramid.testing import DummySecurityPolicy 12 | from pyramid.interfaces import IAuthenticationPolicy 13 | from pyramid_jwt.policy import ( 14 | JWTAuthenticationPolicy, 15 | PyramidJSONEncoderFactory, 16 | JWTCookieAuthenticationPolicy, 17 | ) 18 | import uuid 19 | import pytest 20 | from json.encoder import JSONEncoder 21 | from uuid import UUID 22 | 23 | 24 | def test_interface(): 25 | verifyObject(IAuthenticationPolicy, JWTAuthenticationPolicy("secret")) 26 | 27 | 28 | def test_token_most_be_str(): 29 | policy = JWTAuthenticationPolicy("secret") 30 | token = policy.create_token(15) 31 | assert isinstance(token, str) 32 | 33 | 34 | def test_minimal_roundtrip(): 35 | policy = JWTAuthenticationPolicy("secret") 36 | request = Request.blank("/") 37 | request.authorization = ("JWT", policy.create_token(15)) 38 | request.jwt_claims = policy.get_claims(request) 39 | assert policy.unauthenticated_userid(request) == 15 40 | 41 | 42 | def test_audience_valid(): 43 | policy = JWTAuthenticationPolicy("secret", audience="example.org") 44 | token = policy.create_token(15, name="Jöhn", admin=True, audience="example.org") 45 | request = Request.blank("/") 46 | request.authorization = ("JWT", token) 47 | jwt_claims = policy.get_claims(request) 48 | assert jwt_claims["aud"] == "example.org" 49 | 50 | 51 | def test_audience_invalid(): 52 | policy = JWTAuthenticationPolicy("secret", audience="example.org") 53 | token = policy.create_token(15, name="Jöhn", admin=True, audience="example.com") 54 | request = Request.blank("/") 55 | request.authorization = ("JWT", token) 56 | jwt_claims = policy.get_claims(request) 57 | assert jwt_claims == {} 58 | 59 | 60 | def test_algorithm_unsupported(): 61 | policy = JWTAuthenticationPolicy("secret", algorithm="SHA1") 62 | with pytest.raises(NotImplementedError): 63 | token = policy.create_token(15, name="Jöhn", admin=True) 64 | 65 | 66 | def test_extra_claims(): 67 | policy = JWTAuthenticationPolicy("secret") 68 | token = policy.create_token(15, name="Jöhn", admin=True) 69 | request = Request.blank("/") 70 | request.authorization = ("JWT", token) 71 | jwt_claims = policy.get_claims(request) 72 | assert jwt_claims["name"] == "Jöhn" 73 | assert jwt_claims["admin"] 74 | 75 | 76 | def test_wrong_auth_scheme(): 77 | policy = JWTAuthenticationPolicy("secret") 78 | request = Request.blank("/") 79 | request.authorization = ("Other", policy.create_token(15)) 80 | request.jwt_claims = policy.get_claims(request) 81 | assert policy.unauthenticated_userid(request) is None 82 | 83 | 84 | def test_invalid_authorization_header(): 85 | policy = JWTAuthenticationPolicy("secret") 86 | request = Request.blank("/") 87 | request.environ["HTTP_AUTHORIZATION"] = "token" 88 | request.jwt_claims = policy.get_claims(request) 89 | assert policy.unauthenticated_userid(request) is None 90 | 91 | 92 | def test_other_header(): 93 | policy = JWTAuthenticationPolicy("secret", http_header="X-Token") 94 | request = Request.blank("/") 95 | request.headers["X-Token"] = policy.create_token(15) 96 | request.jwt_claims = policy.get_claims(request) 97 | assert policy.unauthenticated_userid(request) == 15 98 | 99 | 100 | def test_expired_token(): 101 | policy = JWTAuthenticationPolicy("secret", expiration=-1) 102 | request = Request.blank("/") 103 | request.authorization = ("JWT", policy.create_token(15)) 104 | request.jwt_claims = policy.get_claims(request) 105 | assert policy.unauthenticated_userid(request) is None 106 | policy.leeway = 5 107 | request.jwt_claims = policy.get_claims(request) 108 | assert policy.unauthenticated_userid(request) == 15 109 | 110 | 111 | def test_dynamic_expired_token(): 112 | policy = JWTAuthenticationPolicy("secret", expiration=-1) 113 | request = Request.blank("/") 114 | request.authorization = ("JWT", policy.create_token(15, expiration=5)) 115 | request.jwt_claims = policy.get_claims(request) 116 | assert policy.unauthenticated_userid(request) == 15 117 | 118 | policy = JWTAuthenticationPolicy("secret") 119 | request.authorization = ("JWT", policy.create_token(15, expiration=-1)) 120 | request.jwt_claims = policy.get_claims(request) 121 | assert policy.unauthenticated_userid(request) is None 122 | request.authorization = ("JWT", policy.create_token(15)) 123 | request.jwt_claims = policy.get_claims(request) 124 | assert policy.unauthenticated_userid(request) == 15 125 | 126 | 127 | def test_remember_warning(): 128 | policy = JWTAuthenticationPolicy("secret", http_header="X-Token") 129 | with testConfig() as config: 130 | config.set_authorization_policy(DummySecurityPolicy()) 131 | config.set_authentication_policy(policy) 132 | request = DummyRequest() 133 | with warnings.catch_warnings(record=True) as w: 134 | remember(request, 15) 135 | assert len(w) == 1 136 | assert issubclass(w[-1].category, UserWarning) 137 | assert "JWT tokens" in str(w[-1].message) 138 | assert w[-1].filename.endswith("test_policy.py") 139 | 140 | 141 | def test_forget_warning(): 142 | policy = JWTAuthenticationPolicy("secret", http_header="X-Token") 143 | with testConfig() as config: 144 | config.set_authorization_policy(DummySecurityPolicy()) 145 | config.set_authentication_policy(policy) 146 | request = DummyRequest() 147 | with warnings.catch_warnings(record=True) as w: 148 | forget(request) 149 | assert len(w) == 1 150 | assert issubclass(w[-1].category, UserWarning) 151 | assert "JWT tokens" in str(w[-1].message) 152 | assert w[-1].filename.endswith("test_policy.py") 153 | 154 | 155 | def test_default_json_encoder(): 156 | policy = JWTAuthenticationPolicy("secret") 157 | assert isinstance(policy.json_encoder, PyramidJSONEncoderFactory) 158 | assert isinstance(policy.json_encoder(), JSONEncoder) 159 | 160 | 161 | class MyCustomJsonEncoder(JSONEncoder): 162 | def default(self, o): 163 | if type(o) is UUID: 164 | return str(o) 165 | # Let the base class default method raise the TypeError 166 | return JSONEncoder.default(self, o) 167 | 168 | 169 | def test_custom_json_encoder(): 170 | policy = JWTAuthenticationPolicy("secret") 171 | principal_id = uuid.uuid4() 172 | claim_value = uuid.uuid4() 173 | with pytest.raises(TypeError): 174 | token = policy.create_token("subject", uuid_value=claim_value) 175 | policy = JWTAuthenticationPolicy("secret", json_encoder=MyCustomJsonEncoder) 176 | 177 | request = Request.blank("/") 178 | request.authorization = ( 179 | "JWT", 180 | policy.create_token(principal_id, uuid_value=claim_value), 181 | ) 182 | request.jwt_claims = policy.get_claims(request) 183 | assert policy.unauthenticated_userid(request) == str(principal_id) 184 | assert request.jwt_claims.get("uuid_value") == str(claim_value) 185 | 186 | 187 | def test_cookie_policy_creation(): 188 | token_policy = JWTAuthenticationPolicy("secret") 189 | request = Request.blank("/") 190 | cookie_policy = JWTCookieAuthenticationPolicy.make_from(token_policy) 191 | 192 | headers = cookie_policy.remember(request, "user") 193 | 194 | assert isinstance(headers, list) 195 | assert len(headers) == 1 196 | 197 | 198 | def test_cookie_policy_creation_fail(): 199 | with pytest.raises(ValueError) as e: 200 | JWTCookieAuthenticationPolicy.make_from(object()) 201 | 202 | assert "Invalid policy type" in str(e.value) 203 | 204 | 205 | def test_cookie_policy_remember(): 206 | policy = JWTCookieAuthenticationPolicy("secret") 207 | request = Request.blank("/") 208 | headers = policy.remember(request, "user") 209 | 210 | header, cookie = headers[0] 211 | assert header.lower() == "set-cookie" 212 | 213 | chunks = cookie.split("; ") 214 | assert chunks[0].startswith(f"{policy.cookie_name}=") 215 | 216 | assert "HttpOnly" in chunks 217 | assert "secure" in chunks 218 | 219 | 220 | def test_cookie_policy_forget(): 221 | policy = JWTCookieAuthenticationPolicy("secret") 222 | request = Request.blank("/") 223 | headers = policy.forget(request) 224 | 225 | header, cookie = headers[0] 226 | assert header.lower() == "set-cookie" 227 | 228 | chunks = cookie.split("; ") 229 | cookie_values = [c for c in chunks if "=" in c] 230 | assert cookie_values[0].startswith(f"{policy.cookie_name}=") 231 | 232 | assert "Max-Age=0" in chunks 233 | assert hasattr(request, "_jwt_cookie_reissue_revoked") 234 | 235 | 236 | def test_cookie_policy_custom_domain_list(): 237 | policy = JWTCookieAuthenticationPolicy("secret") 238 | request = Request.blank("/") 239 | domains = [request.domain, "other"] 240 | headers = policy.remember(request, "user", domains=domains) 241 | 242 | assert len(headers) == 2 243 | _, cookie1 = headers[0] 244 | _, cookie2 = headers[1] 245 | 246 | assert f"Domain={request.domain}" in cookie1 247 | assert f"Domain=other" in cookie2 248 | 249 | 250 | def test_insecure_cookie_policy(): 251 | policy = JWTCookieAuthenticationPolicy("secret", https_only=False) 252 | request = Request.blank("/") 253 | headers = policy.forget(request) 254 | 255 | _, cookie = headers[0] 256 | chunks = cookie.split("; ") 257 | 258 | assert "secure" not in chunks 259 | 260 | 261 | def test_insecure_cookie_policy(): 262 | policy = JWTCookieAuthenticationPolicy("secret", https_only=False) 263 | request = Request.blank("/") 264 | headers = policy.forget(request) 265 | 266 | _, cookie = headers[0] 267 | chunks = cookie.split("; ") 268 | 269 | assert "secure" not in chunks 270 | 271 | 272 | @pytest.mark.freeze_time 273 | def test_cookie_policy_max_age(): 274 | expiry = timedelta(seconds=10) 275 | policy = JWTCookieAuthenticationPolicy("secret", expiration=expiry) 276 | request = Request.blank("/") 277 | headers = policy.forget(request) 278 | 279 | _, cookie = headers[0] 280 | chunks = cookie.split("; ") 281 | 282 | assert "Max-Age=10" not in chunks 283 | --------------------------------------------------------------------------------