Skip to content
This repository has been archived by the owner on Jun 4, 2022. It is now read-only.

Commit

Permalink
Initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
chessbr committed Oct 29, 2017
1 parent a1211f6 commit 0d2bc06
Show file tree
Hide file tree
Showing 42 changed files with 1,155 additions and 0 deletions.
68 changes: 68 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# Distribution / packaging
.Python
env/
.env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py

# Sphinx documentation
docs/_build/

# PyBuilder
target/

#Ipython Notebook
.ipynb_checkpoints

# pyenv
.python-version

.egg-info/
coverage/
13 changes: 13 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
language: python
python:
- "3.4"
- "3.5"
install:
- pip install -U pip
- pip install coveralls
- pip install -r requirements.txt
- pip install -r dev-requirements.txt
- pip install -e .
script:
- py.test -ra -vvv --cov-config .coveragerc --cov rest_jwt_permission rest_jwt_permission_tests
after_success: coveralls
145 changes: 145 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
[![Build Status](https://travis-ci.org/chessbr/rest-jwt-permission.svg?branch=master)](https://travis-ci.org/chessbr/rest-jwt-permission)
[![Coverage Status](https://coveralls.io/repos/github/chessbr/rest-jwt-permission/badge.svg?branch=master)](https://coveralls.io/github/chessbr/rest-jwt-permission?branch=master)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)


# Django Rest Framework JWT permissions

Module that check API View permissions from JWT payloads.

## Installation

```
pip install rest_jwt_permission
```

## Using

Add `rest_jwt_permission` in your `INSTALLED_APPS` and configure the `settings` as you wish. Here is an example:

```python
REST_JWT_PERMISSION = {
"SCOPE_PROVIDERS": [
"rest_jwt_permission.providers.APIEndpointScopeProvider",
"rest_jwt_permission.providers.AdminScopeProvider"
],
"GET_PAYLOAD_FROM_SCOPES_HANDLER": (
"rest_jwt_permission.handlers.get_payload_from_scopes"
),
"GET_SCOPES_FROM_PAYLOAD_HANDLER": (
"rest_jwt_permission.handlers.get_scopes_from_payload"
),
"GET_PAYLOAD_FROM_REQUEST_HANDLER": (
"rest_jwt_permission.handlers.get_jwt_payload_from_request"
)
}
```

Now you can use `JWTAPIPermission` class in your API Views through `permission_classes` property or even setting it as the default permission class in your [settings](http://www.django-rest-framework.org/api-guide/permissions/#setting-the-permission-policy)

## Motivation

Inspired by GitHub [Personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) and by [Auth0 API Keys blog post](https://auth0.com/blog/using-json-web-tokens-as-api-keys/), this package provides a Django Rest Framework Permission object to check permissions from JWT payloads.

This enables your API to check permissions avoiding an extra database hit.

## How it works

Basically, it extracts a list of all Rest API Views and generate an unique ID for each endpoint + action. Then, after authenticaton, your API should inject which permission identifiers the user has access. The JWT payload should look like the following:

```json
{
"scopes": [
"myviewset:get"
"function_endpoint:get",
"basicview:get",
"simpleviewsetpermission:custom_action:put",
"modelviewsetpermission:retrieve:get",
"modelviewsetpermission:destroy:delete",
"modelviewsetpermission:some_detail_metod:patch",
]
}
```

On each authenticated request, the `JWTAPIPermission` permission class will generate the unique ID for the requested view and will check whether the JWT payloads contains the ID. If so, the user has access.

:warning: This package does not automatically injects the scopes payload into the JWT, although we have helpers (`rest_jwt_permission.handlers.get_payload_from_scopes`) you can use to do that. We strongly recommend you to use [REST framework JWT Auth](https://github.com/GetBlimp/django-rest-framework-jwt) package as it provides all you need to make this eaiser. You can change the payload handler though it's `JWT_PAYLOAD_HANDLER` setting.


You can also create some sort of admin page to select the permissions for user and/or groups like GitHub token scopes, and use that to inject the token into the JWT:

![GitHub Token Page](https://help.github.com/assets/images/help/settings/token_scopes.gif)


#### Scopes

Scopes are basically what users can access (has permission to do). Each scope should has an unique identifier (see [`Scope`](rest_jwt_permission/scopes/base.py) base class). You can extend the base `Scope` class and add extra properties and methods.


#### Providers

`Providers` are objects that returns a list of existing scopes. We currently have 2 built-in providers:

* `APIEndpointScopeProvider`: returns all scopes for Django REST Framework registered views.
* `AdminScopeProvider`: returns admin related scopes. Currently, only returns `superuser` scope.

You can develop new providers to your project as you need or even extend the built-ins.


### Settings

This project was build with extension in mind, so it is easy to extend, add or remove features.

See the list of settings you can customize, all of them are inside the `REST_JWT_PERMISSION` setting key:


**`SCOPE_PROVIDERS`**: List of providers used to extract the existing scopes.
Defaults to:
```
"SCOPE_PROVIDERS": [
"rest_jwt_permission.providers.APIEndpointScopeProvider",
"rest_jwt_permission.providers.AdminScopeProvider"
]
```

**`GET_PAYLOAD_FROM_SCOPES_HANDLER`**: Handler function to get the payload with scopes to be injected into JWT. Defaults to:
```
"GET_PAYLOAD_FROM_SCOPES_HANDLER": "rest_jwt_permission.handlers.get_payload_from_scopes"
```

**`GET_SCOPES_FROM_PAYLOAD_HANDLER`**: Handler function to get the scopes from a JWT payload. Defaults to:
```
"GET_SCOPES_FROM_PAYLOAD_HANDLER": "rest_jwt_permission.handlers.get_scopes_from_payload"
```

**`GET_PAYLOAD_FROM_REQUEST_HANDLER`**: Handler function to get JWT payload from Request. Defaults to:
```
"GET_PAYLOAD_FROM_REQUEST_HANDLER": "rest_jwt_permission.handlers.get_jwt_payload_from_request"
```


### Showing all available roles

You can use the `show_roles` management command to print all available roles according to your providers.

```
python manage.py show_roles
```

## Running tests

Install dependencies from `dev-requirements.txt` and run `py.tets --cov`:

```
pip install dev-requirements.txt && py.tets --cov
```

# Compatibility

* Python >= 3.4
* Django >= 1.10
* Django Rest Framework >= 3.6

# License

MIT
8 changes: 8 additions & 0 deletions dev-requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
pytest
isort
flake8
pip-tools
coverage
pytest-cov
pytest-django
pytest-sugar
22 changes: 22 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file dev-requirements.txt dev-requirements.in
#
click==6.7 # via pip-tools
coverage==4.4.1
first==2.0.1 # via pip-tools
flake8==3.4.1
isort==4.2.15
mccabe==0.6.1 # via flake8
pip-tools==1.10.1
py==1.4.34 # via pytest
pycodestyle==2.3.1 # via flake8
pyflakes==1.5.0 # via flake8
pytest-cov==2.5.1
pytest-django==3.1.2
pytest-sugar==0.9.0
pytest==3.2.2
six==1.11.0 # via pip-tools
termcolor==1.1.0 # via pytest-sugar
3 changes: 3 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
django>=1.10,<1.12
djangorestframework>=3.6,<4.0
djangorestframework-jwt>=1.11,<2
11 changes: 11 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements.txt requirements.in
#
django==1.11.5
djangorestframework-jwt==1.11.0
djangorestframework==3.6.4
pyjwt==1.5.3 # via djangorestframework-jwt
pytz==2017.2 # via django
Empty file added rest_jwt_permission/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions rest_jwt_permission/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig


class RestApiPermissionConfig(AppConfig):
name = 'rest_api_permission'
67 changes: 67 additions & 0 deletions rest_jwt_permission/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
import jwt
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import get_authorization_header


def get_payload_from_scopes(scopes):
"""
Get a dict to be used in JWT payload.
Just merge this dict with the JWT payload.
:type roles list[rest_jwt_permission.scopes.Scope]
:return dictionary to be merged with the JWT payload
:rtype dict
"""
return {
"scopes": [scope.identifier for scope in scopes]
}


def get_scopes_from_payload(payload):
"""
Get a list of Scope from the JWT payload
:type payload dict
:rtype list[rest_jwt_permission.scopes.Scope]
"""
return payload.get("scopes", [])


def get_jwt_payload_from_request(request):
# lazy load this if one want to use other rest lib for JWT
from rest_framework_jwt.settings import api_settings
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER

auth = get_authorization_header(request).split()
auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower()

if not auth:
if api_settings.JWT_AUTH_COOKIE:
return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
return None

if smart_text(auth[0].lower()) != auth_header_prefix:
return None

if len(auth) == 1:
raise exceptions.PermissionDenied(_('No credentials provided.'))
elif len(auth) > 2:
raise exceptions.PermissionDenied(_('Invalid Authorization header.'))

jwt_value = auth[1]
if jwt_value is None:
return None

try:
payload = jwt_decode_handler(jwt_value)
except jwt.ExpiredSignature:
raise exceptions.PermissionDenied(_('Signature has expired.'))
except jwt.DecodeError:
raise exceptions.PermissionDenied(_('Error decoding signature.'))
except jwt.InvalidTokenError:
raise exceptions.PermissionDenied(_('Invalid token.'))

return payload
Empty file.
Empty file.
10 changes: 10 additions & 0 deletions rest_jwt_permission/management/commands/show_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from django.core.management.base import BaseCommand

from rest_jwt_permission.scopes import get_all_permission_providers_scopes


class Command(BaseCommand):
def handle(self, *args, **options):
for x in get_all_permission_providers_scopes():
print(x)
1 change: 1 addition & 0 deletions rest_jwt_permission/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
25 changes: 25 additions & 0 deletions rest_jwt_permission/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from rest_framework.permissions import BasePermission

from .settings import get_imported_setting
from .utils import get_role_for, get_view_role


class JWTAPIPermission(BasePermission):
"""
Returns whether the scope is inside the JWT payload
"""
def has_permission(self, request, view):
get_jwt_payload_from_request = get_imported_setting("GET_PAYLOAD_FROM_REQUEST_HANDLER")
get_scopes_from_payload = get_imported_setting("GET_SCOPES_FROM_PAYLOAD_HANDLER")

payload = get_jwt_payload_from_request(request)

if not payload:
return False

payload_scopes = get_scopes_from_payload(payload)
role = get_role_for(request.method.lower(), getattr(view, "action", None))
scope = get_view_role(view, role)

return scope in payload_scopes
Loading

0 comments on commit 0d2bc06

Please sign in to comment.