From 30cc61cc3712410476fecee9c81abaa153bbefa9 Mon Sep 17 00:00:00 2001 From: Eshaan Bansal Date: Mon, 19 Oct 2020 02:10:38 +0530 Subject: [PATCH 1/4] initial commit --- .coveragerc | 16 ++ .dockerignore | 6 + .flake8 | 6 + .gitignore | 61 ++++++ .isort.cfg | 7 + .lgtm.yml | 17 ++ .travis.yml | 37 ++++ .vscode/settings.json | 6 + CHANGELOG.md | 2 + CONTRIBUTORS | 3 + MANIFEST.in | 4 + README.md | 65 ++++++ docker-run-tests.sh | 4 + docs/auth.md | 108 ++++++++++ docs/changelog.md | 1 + docs/index.md | 31 +++ docs/installation.md | 66 ++++++ docs/settings.md | 105 ++++++++++ docs/urls.md | 28 +++ docs/views.md | 78 ++++++++ durin/__init__.py | 0 durin/admin.py | 41 ++++ durin/auth.py | 100 ++++++++++ durin/migrations/0001_initial.py | 97 +++++++++ durin/migrations/__init__.py | 0 durin/models.py | 127 ++++++++++++ durin/permissions.py | 29 +++ durin/serializers.py | 12 ++ durin/settings.py | 32 +++ durin/signals.py | 7 + durin/urls.py | 10 + durin/views.py | 133 +++++++++++++ example_project/__init__.py | 0 example_project/settings.py | 60 ++++++ example_project/urls.py | 9 + example_project/views.py | 21 ++ example_project/wsgi.py | 16 ++ manage.py | 10 + mkdocs.sh | 12 ++ mkdocs.yml | 14 ++ pre-commit.sh | 3 + requirements.dev.txt | 10 + setup.py | 64 ++++++ tests/__init__.py | 0 tests/tests.py | 331 +++++++++++++++++++++++++++++++ tox.ini | 43 ++++ 46 files changed, 1832 insertions(+) create mode 100644 .coveragerc create mode 100644 .dockerignore create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 .lgtm.yml create mode 100644 .travis.yml create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTORS create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100755 docker-run-tests.sh create mode 100644 docs/auth.md create mode 120000 docs/changelog.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/settings.md create mode 100644 docs/urls.md create mode 100644 docs/views.md create mode 100644 durin/__init__.py create mode 100644 durin/admin.py create mode 100644 durin/auth.py create mode 100644 durin/migrations/0001_initial.py create mode 100644 durin/migrations/__init__.py create mode 100644 durin/models.py create mode 100644 durin/permissions.py create mode 100644 durin/serializers.py create mode 100644 durin/settings.py create mode 100644 durin/signals.py create mode 100644 durin/urls.py create mode 100644 durin/views.py create mode 100644 example_project/__init__.py create mode 100644 example_project/settings.py create mode 100644 example_project/urls.py create mode 100644 example_project/views.py create mode 100644 example_project/wsgi.py create mode 100755 manage.py create mode 100755 mkdocs.sh create mode 100644 mkdocs.yml create mode 100644 pre-commit.sh create mode 100644 requirements.dev.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/tests.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..00b41a5 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,16 @@ + +[run] +branch = True +source = django-rest-durin +omit = + durin/models.py + +[report] +exclude_lines = + if self.debug: + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: +ignore_errors = True +omit = + tests/* \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..83e9449 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +*venv/ +*.tox/ +*.egg-info/ +*.sqlite* +__pycache__ +.vscode/ \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6033f2f --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 88 +exclude = + *migrations*, + *venv* + virtualenv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8db6663 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +venv/ + +# C extensions +*.so + +# Distribution / packaging +.Python +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 + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ +db.sqlite3 +site/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..8584edd --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 \ No newline at end of file diff --git a/.lgtm.yml b/.lgtm.yml new file mode 100644 index 0000000..91c7209 --- /dev/null +++ b/.lgtm.yml @@ -0,0 +1,17 @@ +queries: + - exclude: py/similar-function + - exclude: py/empty-except + - exclude: py/call-to-non-callable + - include: py/undefined-placeholder-variable + - include: py/uninitialized-local-variable + - include: py/request-without-cert-validation + - include: py/return-or-yield-outside-function + - include: py/file-not-closed + - include: py/exit-from-finally + - include: py/ineffectual-statement + - include: py/unused-global-variable + - include: py/hardcoded-credentials + - include: py/import-of-mutable-attribute + - include: py/cyclic-import + - include: py/unnecessary-lambda + - include: py/print-during-import diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffd8eef --- /dev/null +++ b/.travis.yml @@ -0,0 +1,37 @@ +# Disable sudo to speed up the build +sudo: false +language: python +install: + - pip install tox flake8 codecov +matrix: + include: + - python: "3.5" + env: TOX_ENVS=py35-django22 + - python: "3.6" + dist: xenial + env: TOX_ENVS=py36-django22,py36-django30,py36-django31 + - python: "3.7" + env: TOX_ENVS=py37-django22,py37-django30,py37-django31 + dist: xenial + - python: "3.8" + env: TOX_ENVS=py38-django22,py38-django30,py38-django31 + - python: "3.9-dev" + env: TOX_ENVS=py39-django22,py39-django30,py39-django31 +before_script: + - flake8 . --count +script: + - tox -e $TOX_ENVS +after_success: + - codecov +deploy: + provider: pypi + user: eshaan7 + password: testpasswordfortravis + on: + tags: true + repo: eshaan7/django-rest-durin + only: + - main + distributions: "sdist bdist_wheel" +git: + depth: false diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c124094 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.formatting.provider": "black", + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d2a985a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## 0.1.0 +- Initial release diff --git a/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..833ed94 --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1,3 @@ +- James McMahon +- Eshaan Bansal +- Matteo Lodi diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f24cebf --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.md +include CHANGELOG.md +include LICENSE +include CONTRIBUTORS diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ba31a8 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# django-rest-durin + +[![django-rest-durin on pypi](https://img.shields.io/pypi/v/django-rest-durin)](https://pypi.org/project/django-rest-durin/) +[![Build Status](https://travis-ci.com/Eshaan7/django-rest-durin.svg?branch=main)](https://travis-ci.com/Eshaan7/django-rest-durin) +[![codecov](https://codecov.io/gh/Eshaan7/django-rest-durin/branch/main/graph/badge.svg?token=S9KEI0PU05)](https://codecov.io/gh/Eshaan7/django-rest-durin/) +[![CodeFactor](https://www.codefactor.io/repository/github/eshaan7/django-rest-durin/badge)](https://www.codefactor.io/repository/github/eshaan7/django-rest-durin) + + Language grade: Python + + +Per API client token authentication Module for [Django REST Framework](http://www.django-rest-framework.org/). + +The idea is to provide one library that does token auth for multiple Web/CLI/Mobile API clients via one interface but allows different token configuration for each client. + +Durin authentication is token based, similar to the `TokenAuthentication` +built in to DRF. However, it adds some extra sauce: + +- Durin allows multiple tokens per user. But only one token each user per API client. + +- Each user token is associated with an API Client. These API Clients are configurable via Django's Admin Interface. + +- All Durin tokens have an expiration time. This expiration time can be different per API client. + +- Durin provides an option for a logged in user to remove *all* + tokens that the server has - forcing him/her to re-authenticate for all API clients. + +- Durin tokens can be renewed to get a fresh expiry. + +- Durin provides a [Cached Token Authentication](#cache-backend) backend as well. + +More information can be found in the [Documentation]() + +## Cache Backend + +If you want to use a cache for the session store, you can install [django-memoize](https://pythonhosted.org/django-memoize/) and add `'memoize'` to `INSTALLED_APPS`. + +Then you need to use ``CachedTokenAuthentication`` instead of ``TokenAuthentication``. + +```bash +pip install django-memoize +``` + +## Django Compatibility Matrix + +If your project uses an older verison of Django or Django Rest Framework, you can choose an older version of this project. + +| This Project | Python Version | Django Version | Django Rest Framework | +|--------------|----------------|----------------|-----------------------| +| 0.1.* | 3.5 - 3.9 | 2.2, 3.0, 3.1 | 3.7>= | + +Make sure to use at least `DRF 3.10` when using `Django 3.0` or newer. + +## Changelog / Releases + +All releases should be listed in the [releases tab on github](https://github.com/Eshaan7/django-rest-durin/releases). + +See [CHANGELOG.md](CHANGELOG.md) for a more detailed listing. + +## License + +This project is published with the [MIT License](LICENSE). See [https://choosealicense.com/licenses/mit/](https://choosealicense.com/licenses/mit/) for more information about what this means. + +## Credits + +Durin is inpired by the [django-rest-knox](https://github.com/James1345/django-rest-knox) and [django-rest-multitokenauth](https://github.com/anexia-it/django-rest-multitokenauth) libraries and includes some learnings and code from both. \ No newline at end of file diff --git a/docker-run-tests.sh b/docker-run-tests.sh new file mode 100755 index 0000000..b2ccaca --- /dev/null +++ b/docker-run-tests.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +black . && flake8 . && isort . +MOUNT_FOLDER=/app +docker run --rm -it -v $(pwd):$MOUNT_FOLDER -w $MOUNT_FOLDER themattrix/tox diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..5872373 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,108 @@ +# Authentication `knox.auth` + +Knox provides one class to handle authentication. + +## TokenAuthentication + +This works using [DRF's authentication system](http://www.django-rest-framework.org/api-guide/authentication/). + +Knox tokens should be generated using the provided views. +Any `APIView` or `ViewSet` can be accessed using these tokens by adding `TokenAuthentication` +to the View's `authentication_classes`. +To authenticate, the `Authorization` header should be set on the request, with a +value of the word `"Token"`, then a space, then the authentication token provided by +`LoginView`. + +Example: +```python +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from knox.auth import TokenAuthentication + +class ExampleView(APIView): + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request, format=None): + content = { + 'foo': 'bar' + } + return Response(content) +``` + +Example auth header: + +```javascript +Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b9836F45E23A345 +``` + +Tokens expire after a preset time. See settings. + + +### Global usage on all views + +You can activate TokenAuthentication on all your views by adding it to `REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`. + +If it is your only default authentication class, remember to overwrite knox's LoginView, otherwise it'll not work, since the login view will require a authentication token to generate a new token, rendering it unusable. + +For instance, you can authenticate users using Basic Authentication by simply overwriting knox's LoginView and setting BasicAuthentication as one of the acceptable authentication classes, as follows: + +```python + +views.py: + +from knox.views import LoginView as KnoxLoginView +from rest_framework.authentication import BasicAuthentication + +class LoginView(KnoxLoginView): + authentication_classes = [BasicAuthentication] + +urls.py: + +from knox import views as knox_views +from yourapp.api.views import LoginView + +urlpatterns = [ + url(r'login/', LoginView.as_view(), name='knox_login'), + url(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), + url(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), +] +``` + +You can use any number of authentication classes if you want to be able to authenticate using different methods (eg.: Basic and JSON) in the same view. Just be sure not to set TokenAuthentication as your only authentication class on the login view. + +If you decide to use Token Authentication as your only authentication class, you can overwrite knox's login view as such: + +```python + +views.py: + +from django.contrib.auth import login + +from rest_framework import permissions +from rest_framework.authtoken.serializers import AuthTokenSerializer +from knox.views import LoginView as KnoxLoginView + +class LoginView(KnoxLoginView): + permission_classes = (permissions.AllowAny,) + + def post(self, request, format=None): + serializer = AuthTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + login(request, user) + return super(LoginView, self).post(request, format=None) + +urls.py: + +from knox import views as knox_views +from yourapp.api.views import LoginView + +urlpatterns = [ + url(r'login/', LoginView.as_view(), name='knox_login'), + url(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), + url(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), +] +``` \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8a498e9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +# Django-Rest-Knox +Knox provides easy to use authentication for [Django REST Framework](http://www.django-rest-framework.org/) +The aim is to allow for common patterns in applications that are REST based, +with little extra effort; and to ensure that connections remain secure. + +Knox authentication is token based, similar to the `TokenAuthentication` built +in to DRF. However, it overcomes some problems present in the default implementation: + +- DRF tokens are limited to one per user. This does not facilitate securely + signing in from multiple devices, as the token is shared. It also requires + *all* devices to be logged out if a server-side logout is required (i.e. the + token is deleted). + + Knox provides one token per call to the login view - allowing + each client to have its own token which is deleted on the server side when the client + logs out. Knox also provides an optional setting to limit the amount of tokens generated + per user. + + Knox also provides an option for a logged in client to remove *all* tokens + that the server has - forcing all clients to re-authenticate. + +- DRF tokens are stored unencrypted in the database. This would allow an attacker + unrestricted access to an account with a token if the database were compromised. + + Knox tokens are only stored in an encrypted form. Even if the database were + somehow stolen, an attacker would not be able to log in with the stolen + credentials. + +- DRF tokens track their creation time, but have no inbuilt mechanism for tokens + expiring. Knox tokens can have an expiry configured in the app settings (default is + 10 hours.) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ad7da04 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,66 @@ +# Installation + +## Requirements + +Knox depends on `cryptography` to provide bindings to `OpenSSL` for token generation +This requires the OpenSSL build libraries to be available. + +### Windows +Cryptography is a statically linked build, no extra steps are needed + +### Linux +`cryptography` should build very easily on Linux provided you have a C compiler, +headers for Python (if you’re not using `pypy`), and headers for the OpenSSL and +`libffi` libraries available on your system. + +Debian and Ubuntu: +```bash +sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python-dev +``` + +Fedora and RHEL-derivatives: +```bash +sudo yum install gcc libffi-devel python-devel openssl-devel +``` +For other systems or problems, see the [cryptography installation docs](https://cryptography.io/en/latest/installation/) + +## Installing Knox +Knox should be installed with pip + +```bash +pip install django-rest-knox +``` + +## Setup knox + +- Add `rest_framework` and `knox` to your `INSTALLED_APPS`, remove +`rest_framework.authtoken` if you were using it. + +```python +INSTALLED_APPS = ( + ... + 'rest_framework', + 'knox', + ... +) +``` + +- Make knox's TokenAuthentication your default authentification class +for django-rest-framework: + +```python +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), + ... +} +``` + +- Add the [knox url patterns](urls.md#urls-knoxurls) to your project. + +- If you set TokenAuthentication as the only default authentication class on the second step, [override knox's LoginView](auth.md#global-usage-on-all-views) to accept another authentication method and use it instead of knox's default login view. + +- Apply the migrations for the models + +```bash +python manage.py migrate +``` diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..4fb1b36 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,105 @@ +# Settings `knox.settings` + +Settings in Knox are handled in a similar way to the rest framework settings. +All settings are namespaced in the `'REST_KNOX'` setting. + +Example `settings.py` + +```python +#...snip... +# These are the default values if none are set +from datetime import timedelta +from rest_framework.settings import api_settings +REST_KNOX = { + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'AUTH_TOKEN_CHARACTER_LENGTH': 64, + 'TOKEN_TTL': timedelta(hours=10), + 'USER_SERIALIZER': 'knox.serializers.UserSerializer', + 'TOKEN_LIMIT_PER_USER': None, + 'AUTO_REFRESH': False, + 'EXPIRY_DATETIME_FORMAT': api_settings.DATETME_FORMAT, +} +#...snip... +``` + +## SECURE_HASH_ALGORITHM +This is a reference to the class used to provide the hashing algorithm for +token storage. + +*Do not change this unless you know what you are doing* + +By default, Knox uses SHA-512 to hash tokens in the database. + +`cryptography.hazmat.primitives.hashes.Whirlpool` is an acceptable alternative setting +for production use. + +### Tests +SHA-512 and Whirlpool are secure, however, they are slow. This should not be a +problem for your users, but when testing it may be noticable (as test cases tend +to use many more requests much more quickly than real users). In testing scenarios +it is acceptable to use `MD5` hashing.(`cryptography.hazmat.primitives.hashes.MD5`) + +MD5 is **not secure** and must *never* be used in production sites. + +## AUTH_TOKEN_CHARACTER_LENGTH +This is the length of the token that will be sent to the client. By default it +is set to 64 characters (this shouldn't need changing). + +## TOKEN_TTL +This is how long a token can exist before it expires. Expired tokens are automatically +removed from the system. + +The setting should be set to an instance of `datetime.timedelta`. The default is +10 hours ()`timedelta(hours=10)`). + +Setting the TOKEN_TTL to `None` will create tokens that never expire. + +Warning: setting a 0 or negative timedelta will create tokens that instantly expire, +the system will not prevent you setting this. + +## TOKEN_LIMIT_PER_USER +This allows you to control how many tokens can be issued per user. +By default this option is disabled and set to `None` -- thus no limit. + +## USER_SERIALIZER +This is the reference to the class used to serialize the `User` objects when +succesfully returning from `LoginView`. The default is `knox.serializers.UserSerializer` + +## AUTO_REFRESH +This defines if the token expiry time is extended by TOKEN_TTL each time the token +is used. + +## MIN_REFRESH_INTERVAL +This is the minimum time in seconds that needs to pass for the token expiry to be updated +in the database. + +## AUTH_HEADER_PREFIX +This is the Authorization header value prefix. The default is `Token` + +## EXPIRY_DATETIME_FORMAT +This is the expiry datetime format returned in the login view. The default is the +[DATETIME_FORMAT][DATETIME_FORMAT] of Django REST framework. May be any of `None`, `iso-8601` +or a Python [strftime format][strftime format] string. + +[DATETIME_FORMAT]: https://www.django-rest-framework.org/api-guide/settings/#date-and-time-formatting +[strftime format]: https://docs.python.org/3/library/time.html#time.strftime + +# Constants `knox.settings` +Knox also provides some constants for information. These must not be changed in +external code; they are used in the model definitions in knox and an error will +be raised if there is an attempt to change them. + +```python +from knox.settings import CONSTANTS + +print(CONSTANTS.DIGEST_LENGTH) #=> 128 +print(CONSTANTS.SALT_LENGTH) #=> 16 +``` + +## DIGEST_LENGTH +This is the length of the digest that will be stored in the database for each token. + +## SALT_LENGTH +This is the length of the [salt][salt] that will be stored in the database for each token. + +[salt]: https://en.wikipedia.org/wiki/Salt_(cryptography) diff --git a/docs/urls.md b/docs/urls.md new file mode 100644 index 0000000..9517712 --- /dev/null +++ b/docs/urls.md @@ -0,0 +1,28 @@ +#URLS `knox.urls` +Knox provides a url config ready with its three default views routed. + +This can easily be included in your url config: + +```python +urlpatterns = [ + #...snip... + url(r'api/auth/', include('knox.urls')) + #...snip... +] +``` +**Note** It is important to use the string syntax and not try to import `knox.urls`, +as the reference to the `User` model will cause the app to fail at import time. + +The views would then acessible as: + +- `/api/auth/login` -> `LoginView` +- `/api/auth/logout` -> `LogoutView` +- `/api/auth/logoutall` -> `LogoutAllView` + +they can also be looked up by name: + +```python +reverse('knox_login') +reverse('knox_logout') +reverse('knox_logoutall') +``` diff --git a/docs/views.md b/docs/views.md new file mode 100644 index 0000000..509c58d --- /dev/null +++ b/docs/views.md @@ -0,0 +1,78 @@ +# Views `knox.views` +Knox provides three views that handle token management for you. + +## LoginView +This view accepts only a post request with an empty body. + +The LoginView accepts the same sort of authentication as your Rest Framework +`DEFAULT_AUTHENTICATION_CLASSES` setting. If this is not set, it defaults to +`(SessionAuthentication, BasicAuthentication)`. + +LoginView was designed to work well with Basic authentication, or similar +schemes. If you would like to use a different authentication scheme to the +default, you can extend this class to provide your own value for +`authentication_classes` + +It is possible to customize LoginView behaviour by overriding the following +helper methods: +- `get_context(self)`, to change the context passed to the `UserSerializer` +- `get_token_ttl(self)`, to change the token ttl +- `get_token_limit_per_user(self)`, to change the number of tokens available for a user +- `get_user_serializer_class(self)`, to change the class used for serializing the user +- `get_expiry_datetime_format(self)`, to change the datetime format used for expiry +- `format_expiry_datetime(self, expiry)`, to format the expiry `datetime` object at your convinience + +Finally, if none of these helper methods are sufficient, you can also override `get_post_response_data` +to return a fully customized payload. + +```python +...snip... + def get_post_response_data(self, request, token, instance): + UserSerializer = self.get_user_serializer_class() + + data = { + 'expiry': self.format_expiry_datetime(instance.expiry), + 'token': token + } + if UserSerializer is not None: + data["user"] = UserSerializer( + request.user, + context=self.get_context() + ).data + return data +...snip... +``` + +--- +When the endpoint authenticates a request, a json object will be returned +containing the `token` key along with the actual value for the key by default. +The success response also includes a `expiry` key with a timestamp for when +the token expires. + +> *This is because `USER_SERIALIZER` setting is `None` by default.* + +If you wish to return custom data upon successful authentication +like `first_name`, `last_name`, and `username` then the included `UserSerializer` +class can be used inside `REST_KNOX` settings by adding `knox.serializers.UserSerializer` + +--- + +Obviously, if your app uses a custom user model that does not have these fields, +a custom serializer must be used. + +## LogoutView +This view accepts only a post request with an empty body. +It responds to Knox Token Authentication. On a successful request, +the token used to authenticate is deleted from the +system and can no longer be used to authenticate. + +## LogoutAllView +This view accepts only a post request with an empty body. It responds to Knox Token +Authentication. +On a successful request, the token used to authenticate, and *all other tokens* +registered to the same `User` account, are deleted from the +system and can no longer be used to authenticate. + +**Note** It is not recommended to alter the Logout views. They are designed +specifically for token management, and to respond to Knox authentication. +Modified forms of the class may cause unpredictable results. diff --git a/durin/__init__.py b/durin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/durin/admin.py b/durin/admin.py new file mode 100644 index 0000000..fd750f3 --- /dev/null +++ b/durin/admin.py @@ -0,0 +1,41 @@ +from django.contrib import admin + +from durin import models + + +@admin.register(models.AuthToken) +class AuthTokenAdmin(admin.ModelAdmin): + exclude = ("token", "expiry") + list_display = ( + "token", + "client_name", + "user", + "created", + "expires_in", + ) + list_filter = ("client__name", "user") + + fieldsets = [ + ( + "API Auth Token", + { + "fields": ("user", "client"), + "description": """ +

Token will be auto-generated on save.

+

Token will carry the same expiry as the + selected client's token TTL.

+ """, + }, + ), + ] + + def client_name(self, obj): + return obj.client.name + + def save_model(self, request, obj, form, change): + return models.AuthToken.objects.create(obj.user, obj.client) + + +@admin.register(models.Client) +class ClientAdmin(admin.ModelAdmin): + list_display = ("id", "name", "token_ttl") diff --git a/durin/auth.py b/durin/auth.py new file mode 100644 index 0000000..75ad268 --- /dev/null +++ b/durin/auth.py @@ -0,0 +1,100 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication, get_authorization_header + +from durin.models import AuthToken +from durin.settings import durin_settings +from durin.signals import token_expired + +# try to import memoize +memoize = None + +try: + from memoize import memoize +except ImportError: + pass + + +class TokenAuthentication(BaseAuthentication): + """ + This authentication scheme uses Durin AuthTokens for authentication. + + Similar to DRF's TokenAuthentication, it overrides it a bit to + accomodate that tokens can be expired. + + If successful + - `request.user` will be a django `User` instance + - `request.auth` will be an `AuthToken` instance + """ + + model = AuthToken + + def authenticate(self, request): + auth = get_authorization_header(request).split() + prefix = durin_settings.AUTH_HEADER_PREFIX.encode() + + if not auth or auth[0].lower() != prefix.lower(): + return None + if len(auth) == 1: + msg = _("Invalid token header. No credentials provided.") + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: + msg = _("Invalid token header. " "Token string should not contain spaces.") + raise exceptions.AuthenticationFailed(msg) + + return self.authenticate_credentials(auth[1]) + + @classmethod + def authenticate_credentials(cls, token): + """ + Verify that the given token exists in the database + """ + token = token.decode("utf-8") + try: + auth_token = AuthToken.objects.get(token=token) + if cls._cleanup_token(auth_token): + e = _("The given token has expired.") + raise exceptions.AuthenticationFailed(e) + return cls.validate_user(auth_token) + except exceptions.AuthenticationFailed as e: + raise exceptions.AuthenticationFailed(e) + except Exception: + msg = _("Invalid token.") + raise exceptions.AuthenticationFailed(msg) + + @staticmethod + def validate_user(auth_token: AuthToken): + if not auth_token.user.is_active: + raise exceptions.AuthenticationFailed(_("User inactive or deleted.")) + return (auth_token.user, auth_token) + + def authenticate_header(self, request): + return durin_settings.AUTH_HEADER_PREFIX + + @classmethod + def _cleanup_token(cls, auth_token: AuthToken): + if auth_token.expiry is not None: + if auth_token.has_expired: + username = auth_token.user.get_username() + auth_token.delete() + token_expired.send(sender=cls, username=username, source="auth_token") + return True + return False + + +# if memoize is available, create another token authentication class +# which uses django-memoize for caching +if memoize: + + class CachedTokenAuthentication(TokenAuthentication): + """ + Cached TokenAuthentication, using django-memoize + """ + + @classmethod + @memoize(timeout=int(durin_settings.TOKEN_CACHE_TIMEOUT)) + def authenticate_credentials(cls, token): + return super().authenticate_credentials(token) + + def __repr__(self): + return self.__class__.__name__ diff --git a/durin/migrations/0001_initial.py b/durin/migrations/0001_initial.py new file mode 100644 index 0000000..59023f9 --- /dev/null +++ b/durin/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# Generated by Django 3.1.2 on 2020-10-24 14:30 + +import datetime + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Client", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + db_index=True, + help_text="A unique identification name for the client.", + max_length=64, + unique=True, + ), + ), + ( + "token_ttl", + models.DurationField( + default=datetime.timedelta(days=1), + help_text="\n Token Time To Live (TTL) in timedelta. Format: DAYS HH:MM:SS.\n ", + verbose_name="Token Time To Live (TTL)", + ), + ), + ], + ), + migrations.CreateModel( + name="AuthToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.CharField( + db_index=True, + help_text="Token is auto-generated on save.", + max_length=64, + unique=True, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("expiry", models.DateTimeField()), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_token_set", + to="durin.client", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_token_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name="authtoken", + constraint=models.UniqueConstraint( + fields=("user", "client"), name="unique token for user per client" + ), + ), + ] diff --git a/durin/migrations/__init__.py b/durin/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/durin/models.py b/durin/models.py new file mode 100644 index 0000000..a64a827 --- /dev/null +++ b/durin/models.py @@ -0,0 +1,127 @@ +import binascii +from os import urandom + +import humanize +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from durin.settings import durin_settings +from durin.signals import token_renewed + +User = settings.AUTH_USER_MODEL + + +def _create_token_string() -> str: + return binascii.hexlify( + urandom(int(durin_settings.TOKEN_CHARACTER_LENGTH / 2)) + ).decode() + + +class Client(models.Model): + name = models.CharField( + max_length=64, + null=False, + blank=False, + db_index=True, + unique=True, + help_text=_("A unique identification name for the client."), + ) + token_ttl = models.DurationField( + null=False, + default=durin_settings.DEFAULT_TOKEN_TTL, + verbose_name=_("Token Time To Live (TTL)"), + help_text=_( + """ + Token Time To Live (TTL) in timedelta. Format: DAYS HH:MM:SS. + """ + ), + ) + + def __str__(self): + td = humanize.naturaldelta(self.token_ttl) + return "({0}, {1})".format(self.name, td) + + +class AuthTokenManager(models.Manager): + def create(self, user, client, delta_ttl=None): + token = _create_token_string() + + if delta_ttl is not None: + expiry = timezone.now() + delta_ttl + else: + expiry = timezone.now() + client.token_ttl + + instance = super(AuthTokenManager, self).create( + token=token, user=user, client=client, expiry=expiry + ) + return instance + + +class AuthToken(models.Model): + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "client"], name="unique token for user per client" + ) + ] + + objects = AuthTokenManager() + + token = models.CharField( + max_length=durin_settings.TOKEN_CHARACTER_LENGTH, + null=False, + blank=False, + db_index=True, + unique=True, + help_text=_("Token is auto-generated on save."), + ) + user = models.ForeignKey( + User, + null=False, + blank=False, + related_name="auth_token_set", + on_delete=models.CASCADE, + ) + client = models.ForeignKey( + Client, + null=False, + blank=False, + related_name="auth_token_set", + on_delete=models.CASCADE, + ) + created = models.DateTimeField(auto_now_add=True) + expiry = models.DateTimeField(null=False) + + def renew_token(self, renewed_by): + new_expiry = timezone.now() + self.client.token_ttl + self.expiry = new_expiry + self.save(update_fields=("expiry",)) + token_renewed.send( + sender=renewed_by, + username=self.user.get_username(), + token_id=self.pk, + expiry=new_expiry, + ) + return new_expiry + + @property + def expires_in(self) -> str: + if self.expiry: + td = self.expiry - self.created + return humanize.naturaldelta(td) + else: + return "N/A" + + @property + def has_expired(self) -> bool: + return timezone.now() > self.expiry + + def __repr__(self) -> str: + return "({0}, {1}/{2})".format( + self.token, self.user.get_username(), self.client.name + ) + + def __str__(self) -> str: + return self.token diff --git a/durin/permissions.py b/durin/permissions.py new file mode 100644 index 0000000..e65007a --- /dev/null +++ b/durin/permissions.py @@ -0,0 +1,29 @@ +from rest_framework.permissions import BasePermission + + +class AllowSpecificClients(BasePermission): + """ + Allows access to only specific clients. + Should be used along with `durin.auth.TokenAuthentication`. + """ + + allowed_clients_name = ("web",) + + def has_permission(self, request, view): + if not hasattr(request, "_auth"): + return False + return request._auth.client.name in self.allowed_clients_name + + +class DisallowSpecificClients(BasePermission): + """ + restrict specific clients from making requests. + Should be used along with `durin.auth.TokenAuthentication`. + """ + + disallowed_clients_name = () + + def has_permission(self, request, view): + if not hasattr(request, "_auth"): + return False + return request._auth.client.name not in self.disallowed_clients_name diff --git a/durin/serializers.py b/durin/serializers.py new file mode 100644 index 0000000..686b413 --- /dev/null +++ b/durin/serializers.py @@ -0,0 +1,12 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +User = get_user_model() + +username_field = User.USERNAME_FIELD if hasattr(User, "USERNAME_FIELD") else "username" + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = (username_field,) diff --git a/durin/settings.py b/durin/settings.py new file mode 100644 index 0000000..e5c6aa8 --- /dev/null +++ b/durin/settings.py @@ -0,0 +1,32 @@ +from datetime import timedelta + +from django.conf import settings +from django.test.signals import setting_changed +from rest_framework.settings import APISettings, api_settings + +USER_SETTINGS = getattr(settings, "REST_DURIN", None) + +DEFAULTS = { + "DEFAULT_TOKEN_TTL": timedelta(days=1), + "TOKEN_CHARACTER_LENGTH": 64, + "USER_SERIALIZER": None, + "AUTH_HEADER_PREFIX": "Token", + "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT, + "TOKEN_CACHE_TIMEOUT": 60, +} + +IMPORT_STRINGS = { + "USER_SERIALIZER", +} + +durin_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) + + +def reload_api_settings(*args, **kwargs): + global durin_settings + setting, value = kwargs["setting"], kwargs["value"] + if setting == "REST_DURIN": + durin_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) + + +setting_changed.connect(reload_api_settings) diff --git a/durin/signals.py b/durin/signals.py new file mode 100644 index 0000000..ce1a3c1 --- /dev/null +++ b/durin/signals.py @@ -0,0 +1,7 @@ +import django.dispatch + +token_expired = django.dispatch.Signal(providing_args=["username", "source"]) + +token_renewed = django.dispatch.Signal( + providing_args=["username", "token_id", "expiry"] +) diff --git a/durin/urls.py b/durin/urls.py new file mode 100644 index 0000000..128f518 --- /dev/null +++ b/durin/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url + +from durin import views + +urlpatterns = [ + url(r"login/", views.LoginView.as_view(), name="durin_login"), + url(r"refresh/", views.RefreshView.as_view(), name="durin_refresh"), + url(r"logout/", views.LogoutView.as_view(), name="durin_logout"), + url(r"logoutall/", views.LogoutAllView.as_view(), name="durin_logoutall"), +] diff --git a/durin/views.py b/durin/views.py new file mode 100644 index 0000000..4f015df --- /dev/null +++ b/durin/views.py @@ -0,0 +1,133 @@ +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import status +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.exceptions import ParseError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.serializers import DateTimeField +from rest_framework.views import APIView + +from durin.auth import TokenAuthentication +from durin.models import AuthToken, Client +from durin.settings import durin_settings + + +class LoginView(APIView): + """Durin's Login View. + It accepts user credentials (username and password) validates same + and returns token key, expiry and + user fields present in `durin.settings.USER_SERIALIZER`. + """ + + authentication_classes = [] + permission_classes = [] + + def get_context(self): + return {"request": self.request, "format": self.format_kwarg, "view": self} + + @staticmethod + def validate_and_return_user(request): + serializer = AuthTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + return serializer.validated_data["user"] + + @staticmethod + def get_token_client(request): + client_name = request.data.get("client", None) + if not client_name: + raise ParseError("No client specified.", status.HTTP_400_BAD_REQUEST) + return Client.objects.get(name=client_name) + + @staticmethod + def format_expiry_datetime(expiry): + datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT + return DateTimeField(format=datetime_format).to_representation(expiry) + + def get_post_response_data(self, request, instance): + UserSerializer = durin_settings.USER_SERIALIZER + + data = { + "expiry": self.format_expiry_datetime(instance.expiry), + "token": instance.token, + } + if UserSerializer is not None: + data["user"] = UserSerializer(request.user, context=self.get_context()).data + return data + + @staticmethod + def get_new_token(user, client): + return AuthToken.objects.create(user, client) + + def post(self, request, format=None): + request.user = self.validate_and_return_user(request) + client = self.get_token_client(request) + try: + # a token for this user and client already exists, + # so we can return the same one by renewing it's expiry + instance = AuthToken.objects.get(user=request.user, client=client) + instance.renew_token(renewed_by=self.__class__) + except ObjectDoesNotExist: + # create new token + instance = self.get_new_token(request.user, client) + + user_logged_in.send( + sender=request.user.__class__, request=request, user=request.user + ) + data = self.get_post_response_data(request, instance) + return Response(data) + + +class RefreshView(APIView): + """Durin's Refresh View + Refreshes the token present in request header and returns new expiry + """ + + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + @staticmethod + def format_expiry_datetime(expiry): + datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT + return DateTimeField(format=datetime_format).to_representation(expiry) + + def post(self, request, format=None): + auth_token = request._auth + new_expiry = auth_token.renew_token(renewed_by=self.__class__) + new_expiry_repr = self.format_expiry_datetime(new_expiry) + return Response({"expiry": new_expiry_repr}, status=status.HTTP_200_OK) + + +class LogoutView(APIView): + """Durin's Logout View. + Delete's the token present in request header. + + :returns: 204 (No content) + """ + + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def post(self, request, format=None): + request._auth.delete() + user_logged_out.send( + sender=request.user.__class__, request=request, user=request.user + ) + return Response(None, status=status.HTTP_204_NO_CONTENT) + + +class LogoutAllView(APIView): + """Durin's LogoutAllView + Log the user out of all sessions + I.E. deletes all auth tokens for the user + """ + + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def post(self, request, format=None): + request.user.auth_token_set.all().delete() + user_logged_out.send( + sender=request.user.__class__, request=request, user=request.user + ) + return Response(None, status=status.HTTP_204_NO_CONTENT) diff --git a/example_project/__init__.py b/example_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/settings.py b/example_project/settings.py new file mode 100644 index 0000000..b4a4014 --- /dev/null +++ b/example_project/settings.py @@ -0,0 +1,60 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SECRET_KEY = "supersecretexamplekey" +DEBUG = True +ALLOWED_HOSTS = [] +INSTALLED_APPS = ( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "memoize", + "rest_framework", + "durin", + "django_nose", +) + +MIDDLEWARE_CLASSES = ( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.auth.middleware.SessionAuthenticationMiddleware", + "django.middleware.security.SecurityMiddleware", +) + +ROOT_URLCONF = "example_project.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + ], + }, + }, +] + +WSGI_APPLICATION = "example_project.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +STATIC_URL = "/static/" + +TEST_RUNNER = "django_nose.NoseTestSuiteRunner" diff --git a/example_project/urls.py b/example_project/urls.py new file mode 100644 index 0000000..7eebe39 --- /dev/null +++ b/example_project/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, re_path + +from .views import CachedRootView, RootView + +urlpatterns = [ + re_path(r"^api/", include("durin.urls")), + re_path(r"^api/$", RootView.as_view(), name="api-root"), + re_path(r"^api/cached$", CachedRootView.as_view(), name="cached-auth-api"), +] diff --git a/example_project/views.py b/example_project/views.py new file mode 100644 index 0000000..a6477bc --- /dev/null +++ b/example_project/views.py @@ -0,0 +1,21 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from durin.auth import CachedTokenAuthentication, TokenAuthentication + + +class RootView(APIView): + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + return Response("api root") + + +class CachedRootView(APIView): + authentication_classes = (CachedTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + return Response("cached api root") diff --git a/example_project/wsgi.py b/example_project/wsgi.py new file mode 100644 index 0000000..550fca0 --- /dev/null +++ b/example_project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for example_project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..1aa86b6 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/mkdocs.sh b/mkdocs.sh new file mode 100755 index 0000000..ac64849 --- /dev/null +++ b/mkdocs.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e +MOUNT_FOLDER=/app +MKDOCS_DEV_ADDR=${MKDOCS_DEV_ADDR-"0.0.0.0"} +MKDOCS_DEV_PORT=${MKDOCS_DEV_PORT-"8000"} + +docker run --rm -it \ + -v $(pwd):$MOUNT_FOLDER \ + -w $MOUNT_FOLDER \ + -p $MKDOCS_DEV_PORT:$MKDOCS_DEV_PORT \ + -e MKDOCS_DEV_ADDR="$MKDOCS_DEV_ADDR:$MKDOCS_DEV_PORT" \ + squidfunk/mkdocs-material:3.2.0 $* diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d69b2c0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,14 @@ +site_name: Django-Rest-Durin +repo_url: https://github.com/eshaan7/django-rest-durin +theme: readthedocs +nav: + - Home: 'index.md' + - Installation: 'installation.md' + - API Guide: + - Views: 'views.md' + - URLs: 'urls.md' + - Authentication: 'auth.md' + - Settings: 'settings.md' + - Changelog: 'changelog.md' + +dev_addr: !!python/object/apply:os.getenv ["MKDOCS_DEV_ADDR"] diff --git a/pre-commit.sh b/pre-commit.sh new file mode 100644 index 0000000..bd00ae7 --- /dev/null +++ b/pre-commit.sh @@ -0,0 +1,3 @@ +black . +flake8 . +isort durin/ example_project/ tests/ setup.py \ No newline at end of file diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..6962424 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,10 @@ +djangorestframework>=3.7.0 +humanize +mkdocs +flake8 +django-nose +coverage +django-memoize +isort +markdown<3.0 +pytest-django \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2fd131f --- /dev/null +++ b/setup.py @@ -0,0 +1,64 @@ +""" +# django-rest-durin + +Per API client token authentication Module for django rest framework. + +## Docs & Example Usage: https://github.com/eshaan7/django-rest-durin +""" +from setuptools import find_packages, setup + +# Get the long description from the relevant file +with open("README.md", encoding="utf-8") as f: + long_description = f.read() + +GITHUB_URL = "https://github.com/eshaan7/django-rest-durin" + +setup( + name="django-rest-durin", + url=GITHUB_URL, + version="0.1.0", + license="MIT", + description=""" + Per API client token authentication Module for django rest framework. + """, + long_description=long_description, + long_description_content_type="text/markdown", + author="Eshaan Bansal", + author_email="eshaan7bansal@gmail.com", + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 5 - Production/Stable", + # Indicate who your project is intended for + "Intended Audience :: Developers", + "Topic :: Internet :: WWW/HTTP :: Session", + "Environment :: Web Environment", + # Pick your license as you wish (should match "license" above) + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="django rest authentication login token client", + packages=find_packages(exclude=["contrib", "docs", "tests*", "example_project"]), + install_requires=["django>=2.2", "djangorestframework>=3.7.0", "humanize"], + project_urls={ + "Documentation": GITHUB_URL, + "Funding": "https://www.paypal.me/eshaanbansal", + "Source": GITHUB_URL, + "Tracker": "{}/issues".format(GITHUB_URL), + }, + # List additional groups of dependencies here (e.g. development + # dependencies). You can install these using the following syntax, + # for example: + # $ pip install -e .[dev,test] + extras_require={ + "dev": ["black==20.8b1", "flake8", "django-nose", "django-memoize", "isort"], + "test": ["black==20.8b1", "flake8", "django-nose", "django-memoize", "isort"], + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..4af045e --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,331 @@ +import time +from datetime import timedelta +from importlib import reload + +from django.contrib.auth import get_user_model +from django.test import override_settings +from django.urls import reverse +from rest_framework.serializers import DateTimeField +from rest_framework.test import APIRequestFactory, APITestCase + +from durin import views +from durin.auth import TokenAuthentication +from durin.models import AuthToken, Client +from durin.serializers import UserSerializer +from durin.settings import durin_settings +from durin.signals import token_expired, token_renewed + +User = get_user_model() +root_url = reverse("api-root") +cached_auth_url = reverse("cached-auth-api") +login_url = reverse("durin_login") +logout_url = reverse("durin_logout") +logoutall_url = reverse("durin_logoutall") +refresh_url = reverse("durin_refresh") + +new_settings = durin_settings.defaults.copy() +EXPIRY_DATETIME_FORMAT = "%H:%M %d/%m/%y" +new_settings["EXPIRY_DATETIME_FORMAT"] = EXPIRY_DATETIME_FORMAT + + +class AuthTestCase(APITestCase): + def setUp(self): + username = "john.doe" + email = "john.doe@example.com" + password = "hunter2" + self.user = User.objects.create_user(username, email, password) + self.creds = { + "username": username, + "password": password, + "client": "authclientfortest", + } + + username2 = "jane.doe" + email2 = "jane.doe@example.com" + password2 = "hunter2" + self.user2 = User.objects.create_user(username2, email2, password2) + self.creds2 = { + "username": username2, + "password": password2, + "client": "authclientfortest", + } + + self.client_names = ["web", "mobile", "cli"] + self.authclient = Client.objects.create(name="authclientfortest") + + def test_create_clients(self): + self.assertEqual(Client.objects.count(), 1) + Client.objects.all().delete() + self.assertEqual(Client.objects.count(), 0) + for name in self.client_names: + Client.objects.create(name=name) + self.assertEqual(Client.objects.count(), len(self.client_names)) + + def test_create_tokens_for_users(self): + self.assertEqual(AuthToken.objects.count(), 0) + self.test_create_clients() + creds = self.creds.copy() + creds2 = self.creds2.copy() + for c in Client.objects.all(): + creds["client"] = c.name + creds2["client"] = c.name + # for user #1 + self.client.post( + login_url, + creds, + format="json", + ) + # for user #2 + self.client.post( + login_url, + creds2, + format="json", + ) + self.assertEqual(self.user.auth_token_set.count(), Client.objects.count()) + self.assertEqual(self.user2.auth_token_set.count(), Client.objects.count()) + self.assertTrue(all(t.token for t in AuthToken.objects.all())) + + def test_login_returns_serialized_token(self): + self.assertEqual(AuthToken.objects.count(), 0) + response = self.client.post( + login_url, + self.creds, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(durin_settings.USER_SERIALIZER, None) + self.assertIn("token", response.data) + self.assertNotIn("user", response.data) + self.assertNotIn(self.user.USERNAME_FIELD, response.data) + + def test_login_returns_serialized_token_and_username_field(self): + new_settings["USER_SERIALIZER"] = UserSerializer + with override_settings(REST_DURIN=new_settings): + reload(views) + self.assertEqual(AuthToken.objects.count(), 0) + response = self.client.post(login_url, self.creds, format="json") + self.assertEqual(new_settings["USER_SERIALIZER"], UserSerializer) + reload(views) + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.data) + username_field = self.user.USERNAME_FIELD + self.assertIn("user", response.data) + self.assertIn(username_field, response.data["user"]) + + def test_login_returns_configured_expiry_datetime_format(self): + self.assertEqual(AuthToken.objects.count(), 0) + + with override_settings(REST_DURIN=new_settings): + reload(views) + self.assertEqual( + new_settings["EXPIRY_DATETIME_FORMAT"], + EXPIRY_DATETIME_FORMAT, + ) + response = self.client.post(login_url, self.creds, format="json") + + reload(views) + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.data) + self.assertNotIn("user", response.data) + self.assertEqual( + response.data["expiry"], + DateTimeField(format=EXPIRY_DATETIME_FORMAT).to_representation( + AuthToken.objects.first().expiry + ), + ) + + def test_login_expiry_is_present(self): + self.assertEqual(AuthToken.objects.count(), 0) + response = self.client.post(login_url, self.creds, format="json") + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.data) + self.assertIn("expiry", response.data) + self.assertEqual( + response.data["expiry"], + DateTimeField().to_representation(AuthToken.objects.first().expiry), + ) + + def test_login_should_fail_if_no_client_in_request(self): + self.assertEqual(AuthToken.objects.count(), 0) + self.creds.pop("client") + response = self.client.post(login_url, self.creds, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "No client specified.") + + def test_expired_token_fails(self): + self.assertEqual(AuthToken.objects.count(), 0) + instance = AuthToken.objects.create( + self.user, self.authclient, delta_ttl=timedelta(seconds=0) + ) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + response = self.client.get(root_url) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data, {"detail": "The given token has expired."}) + + def test_logout_deletes_keys(self): + self.assertEqual(AuthToken.objects.count(), 0) + instance = AuthToken.objects.create(user=self.user, client=self.authclient) + AuthToken.objects.create(user=self.user2, client=self.authclient) + self.assertEqual(AuthToken.objects.count(), 2) + + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + self.client.post(logout_url, {}, format="json") + self.assertEqual( + AuthToken.objects.count(), 1, "other tokens should remain after logout" + ) + + def test_logout_all_deletes_keys(self): + self.assertEqual(AuthToken.objects.count(), 0) + self.test_create_clients() + for c in Client.objects.all(): + token = AuthToken.objects.create(user=self.user, client=c) + self.assertEqual(AuthToken.objects.count(), len(self.client_names)) + + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % token)) + self.client.post(logoutall_url, {}, format="json") + self.assertEqual(AuthToken.objects.count(), 0) + + def test_logout_all_deletes_only_targets_keys(self): + self.assertEqual(AuthToken.objects.count(), 0) + self.test_create_clients() + for c in Client.objects.all(): + instance = AuthToken.objects.create(user=self.user, client=c) + AuthToken.objects.create(user=self.user2, client=c) + # 2 x len(self.client_names) tokens were created + self.assertEqual(AuthToken.objects.count(), 2 * len(self.client_names)) + + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + self.client.post(logoutall_url, {}, format="json") + # now half of the tokens (for user #1) should have been deleted + self.assertEqual( + AuthToken.objects.count(), + len(self.client_names), + "tokens from other users should not be affected by logout all", + ) + + def test_update_token_key(self): + self.assertEqual(AuthToken.objects.count(), 0) + self.assertEqual(Client.objects.count(), 1) + instance = AuthToken.objects.create(self.user, self.authclient) + rf = APIRequestFactory() + request = rf.get("/") + request.META = {"HTTP_AUTHORIZATION": "Token {}".format(instance.token)} + (auth_user, auth_token) = TokenAuthentication().authenticate(request) + self.assertEqual( + instance.token, + auth_token.token, + ) + self.assertEqual(self.user, auth_user) + + def test_invalid_token_length_returns_401_code(self): + invalid_token = "1" * (durin_settings.TOKEN_CHARACTER_LENGTH - 1) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % invalid_token)) + response = self.client.get(root_url) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data, {"detail": "Invalid token."}) + + def test_invalid_odd_length_token_returns_401_code(self): + self.assertEqual(Client.objects.count(), 1) + instance = AuthToken.objects.create(self.user, self.authclient) + odd_length_token = instance.token + "1" + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % odd_length_token)) + response = self.client.get(root_url) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data, {"detail": "Invalid token."}) + + def test_expiry_signals(self): + self.signal_was_called = False + + def handler(sender, username, **kwargs): + self.signal_was_called = True + + token_expired.connect(handler) + + instance = AuthToken.objects.create( + user=self.user, client=self.authclient, delta_ttl=timedelta(seconds=0) + ) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + self.client.get(root_url) + + self.assertTrue(self.signal_was_called) + + def test_invalid_auth_prefix_return_401(self): + instance = AuthToken.objects.create(user=self.user, client=self.authclient) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + ok_response = self.client.get(root_url) + self.client.credentials(HTTP_AUTHORIZATION=("Baerer %s" % instance.token)) + failed_response = self.client.get(root_url) + self.assertEqual(ok_response.status_code, 200) + self.assertEqual(failed_response.status_code, 401) + + def test_invalid_auth_header_return_401(self): + instance = AuthToken.objects.create(user=self.user, client=self.authclient) + self.client.credentials(HTTP_AUTHORIZATION=("Token")) + resp1 = self.client.get(root_url) + self.assertEqual(resp1.status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s typo" % instance.token)) + resp2 = self.client.get(root_url) + self.assertEqual(resp2.status_code, 401) + + def test_login_should_renew_token_for_existing_client(self): + self.assertEqual(AuthToken.objects.count(), 0) + resp1 = self.client.post(login_url, self.creds, format="json") + self.assertEqual(resp1.status_code, 200) + self.assertIn("token", resp1.data) + self.assertEqual(AuthToken.objects.count(), 1) + resp2 = self.client.post(login_url, self.creds, format="json") + self.assertEqual(resp2.status_code, 200) + self.assertIn("token", resp2.data) + self.assertEqual( + AuthToken.objects.count(), + 1, + "should renew token, instead of creating new.", + ) + self.assertNotEqual( + resp1.data["expiry"], + resp2.data["expiry"], + "token expiry should be renewed by login", + ) + self.assertEqual( + resp1.data["token"], + resp2.data["token"], + "login should return existing token", + ) + + def test_refresh_view_and_renewed_signal(self): + self.signal_was_called = False + + def handler(sender, username, **kwargs): + self.signal_was_called = True + + token_renewed.connect(handler) + + self.assertEqual(AuthToken.objects.count(), 0) + instance = AuthToken.objects.create(user=self.user, client=self.authclient) + self.assertEqual(AuthToken.objects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + resp = self.client.post(refresh_url, {}, format="json") + self.assertEqual( + AuthToken.objects.count(), 1, "refresh view should not create new token." + ) + self.assertEqual(resp.status_code, 200) + self.assertIn("expiry", resp.data) + self.assertNotEqual(resp.data["expiry"], instance.expiry) + self.assertTrue(self.signal_was_called, "token_renewed signal was called.") + + def test_cached_api(self): + self.assertEqual(AuthToken.objects.count(), 0) + instance = AuthToken.objects.create( + self.user, self.authclient, delta_ttl=timedelta(seconds=2) + ) + self.client.credentials(HTTP_AUTHORIZATION=("Token %s" % instance.token)) + resp1 = self.client.get(cached_auth_url) + self.assertEqual(resp1.status_code, 200) + time.sleep(2) + self.assertTrue(instance.has_expired, "token expiry was set to 2 seconds.") + resp2 = self.client.get(cached_auth_url) + self.assertEqual( + resp2.status_code, + 200, + "token state was cached even though token has expired.", + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4bedb96 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +[tox] +envlist = + isort + flake8, + py{35,36,37,38,39}-django22, + py{36,37,38,39}-django30 + py{36,37,38,39}-django31 + +[testenv:flake8] +changedir = {toxinidir} +commands = flake8 . +deps = flake8 + +[testenv:isort] +deps = isort +changedir = {toxinidir} +commands = isort --check-only --diff \ + durin/ \ + example_project/ \ + setup.py \ + tests + +[testenv] +commands = + python manage.py migrate + python manage.py test --with-coverage +setenv = + DJANGO_SETTINGS_MODULE = example_project.settings + PIP_INDEX_URL = https://pypi.python.org/simple/ +deps = + -r requirements.dev.txt + django22: Django>=2.2,<2.3 + django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2 + setuptools + twine + wheel + +# Configuration for coverage and flake8 is being set in `./setup.cfg` +[testenv:codecov] +passenv = TOXENV CI TRAVIS TRAVIS_* CODECOV_* +commands = codecov -e TOXENV +deps = codecov>=2.1.10 \ No newline at end of file From 8222f5db167ab4f583f74b3c2aed131cc874c3a6 Mon Sep 17 00:00:00 2001 From: Matteo Lodi <30625432+mlodic@users.noreply.github.com> Date: Thu, 22 Oct 2020 16:23:40 +0200 Subject: [PATCH 2/4] added automatic publish on pypi + REFRESH_TOKEN_ON_USE option --- .github/workflows/pythonpublish.yml | 26 ++++++++++++++++++++++++++ durin/settings.py | 1 + durin/views.py | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pythonpublish.yml diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..7471cda --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* \ No newline at end of file diff --git a/durin/settings.py b/durin/settings.py index e5c6aa8..ee3244b 100644 --- a/durin/settings.py +++ b/durin/settings.py @@ -13,6 +13,7 @@ "AUTH_HEADER_PREFIX": "Token", "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT, "TOKEN_CACHE_TIMEOUT": 60, + "REFRESH_TOKEN_ON_USE": True } IMPORT_STRINGS = { diff --git a/durin/views.py b/durin/views.py index 4f015df..14b8a59 100644 --- a/durin/views.py +++ b/durin/views.py @@ -66,7 +66,8 @@ def post(self, request, format=None): # a token for this user and client already exists, # so we can return the same one by renewing it's expiry instance = AuthToken.objects.get(user=request.user, client=client) - instance.renew_token(renewed_by=self.__class__) + if durin_settings.REFRESH_TOKEN_ON_USE: + instance.renew_token(renewed_by=self.__class__) except ObjectDoesNotExist: # create new token instance = self.get_new_token(request.user, client) From e53658863a96824b5fbf2660feb7820ff7fcf0c5 Mon Sep 17 00:00:00 2001 From: Eshaan Bansal Date: Sat, 24 Oct 2020 19:04:17 +0530 Subject: [PATCH 3/4] allow renew_token as pluggable and tests for same --- durin/settings.py | 2 +- durin/views.py | 44 ++++++++++++++++++++++++-------------------- tests/tests.py | 41 +++++++++++++++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/durin/settings.py b/durin/settings.py index ee3244b..707a38c 100644 --- a/durin/settings.py +++ b/durin/settings.py @@ -13,7 +13,7 @@ "AUTH_HEADER_PREFIX": "Token", "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT, "TOKEN_CACHE_TIMEOUT": 60, - "REFRESH_TOKEN_ON_USE": True + "REFRESH_TOKEN_ON_LOGIN": False, } IMPORT_STRINGS = { diff --git a/durin/views.py b/durin/views.py index 14b8a59..83d5633 100644 --- a/durin/views.py +++ b/durin/views.py @@ -33,7 +33,7 @@ def validate_and_return_user(request): return serializer.validated_data["user"] @staticmethod - def get_token_client(request): + def get_client_obj(request) -> "Client": client_name = request.data.get("client", None) if not client_name: raise ParseError("No client specified.", status.HTTP_400_BAD_REQUEST) @@ -44,38 +44,42 @@ def format_expiry_datetime(expiry): datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT return DateTimeField(format=datetime_format).to_representation(expiry) - def get_post_response_data(self, request, instance): + def get_post_response_data(self, request, token_obj: "AuthToken"): UserSerializer = durin_settings.USER_SERIALIZER data = { - "expiry": self.format_expiry_datetime(instance.expiry), - "token": instance.token, + "expiry": self.format_expiry_datetime(token_obj.expiry), + "token": token_obj.token, } if UserSerializer is not None: data["user"] = UserSerializer(request.user, context=self.get_context()).data return data - @staticmethod - def get_new_token(user, client): - return AuthToken.objects.create(user, client) + @classmethod + def renew_token(cls, token_obj: "AuthToken"): + token_obj.renew_token(renewed_by=cls) - def post(self, request, format=None): - request.user = self.validate_and_return_user(request) - client = self.get_token_client(request) + @classmethod + def get_token_obj(cls, request, client: "Client") -> "AuthToken": try: - # a token for this user and client already exists, - # so we can return the same one by renewing it's expiry - instance = AuthToken.objects.get(user=request.user, client=client) - if durin_settings.REFRESH_TOKEN_ON_USE: - instance.renew_token(renewed_by=self.__class__) + # a token for this user and client already exists, so we can just return it + token = AuthToken.objects.get(user=request.user, client=client) + if durin_settings.REFRESH_TOKEN_ON_LOGIN: + cls.renew_token(token) except ObjectDoesNotExist: # create new token - instance = self.get_new_token(request.user, client) + token = AuthToken.objects.create(request.user, client) + return token + + def post(self, request, *args, **kwargs): + request.user = self.validate_and_return_user(request) + client = self.get_client_obj(request) + token_obj = self.get_token_obj(request, client) user_logged_in.send( sender=request.user.__class__, request=request, user=request.user ) - data = self.get_post_response_data(request, instance) + data = self.get_post_response_data(request, token_obj) return Response(data) @@ -92,7 +96,7 @@ def format_expiry_datetime(expiry): datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT return DateTimeField(format=datetime_format).to_representation(expiry) - def post(self, request, format=None): + def post(self, request, *args, **kwargs): auth_token = request._auth new_expiry = auth_token.renew_token(renewed_by=self.__class__) new_expiry_repr = self.format_expiry_datetime(new_expiry) @@ -109,7 +113,7 @@ class LogoutView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) - def post(self, request, format=None): + def post(self, request, *args, **kwargs): request._auth.delete() user_logged_out.send( sender=request.user.__class__, request=request, user=request.user @@ -126,7 +130,7 @@ class LogoutAllView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) - def post(self, request, format=None): + def post(self, request, *args, **kwargs): request.user.auth_token_set.all().delete() user_logged_out.send( sender=request.user.__class__, request=request, user=request.user diff --git a/tests/tests.py b/tests/tests.py index 4af045e..d06d6de 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -24,8 +24,6 @@ refresh_url = reverse("durin_refresh") new_settings = durin_settings.defaults.copy() -EXPIRY_DATETIME_FORMAT = "%H:%M %d/%m/%y" -new_settings["EXPIRY_DATETIME_FORMAT"] = EXPIRY_DATETIME_FORMAT class AuthTestCase(APITestCase): @@ -114,7 +112,8 @@ def test_login_returns_serialized_token_and_username_field(self): def test_login_returns_configured_expiry_datetime_format(self): self.assertEqual(AuthToken.objects.count(), 0) - + EXPIRY_DATETIME_FORMAT = "%H:%M %d/%m/%y" + new_settings["EXPIRY_DATETIME_FORMAT"] = EXPIRY_DATETIME_FORMAT with override_settings(REST_DURIN=new_settings): reload(views) self.assertEqual( @@ -267,7 +266,7 @@ def test_invalid_auth_header_return_401(self): resp2 = self.client.get(root_url) self.assertEqual(resp2.status_code, 401) - def test_login_should_renew_token_for_existing_client(self): + def test_login_same_token_existing_client(self): self.assertEqual(AuthToken.objects.count(), 0) resp1 = self.client.post(login_url, self.creds, format="json") self.assertEqual(resp1.status_code, 200) @@ -281,10 +280,10 @@ def test_login_should_renew_token_for_existing_client(self): 1, "should renew token, instead of creating new.", ) - self.assertNotEqual( + self.assertEqual( resp1.data["expiry"], resp2.data["expiry"], - "token expiry should be renewed by login", + "token expiry should be same after login", ) self.assertEqual( resp1.data["token"], @@ -292,6 +291,36 @@ def test_login_should_renew_token_for_existing_client(self): "login should return existing token", ) + def test_login_should_renew_token_for_existing_client(self): + self.assertEqual(AuthToken.objects.count(), 0) + new_settings["REFRESH_TOKEN_ON_LOGIN"] = True + with override_settings(REST_DURIN=new_settings): + reload(views) + resp1 = self.client.post(login_url, self.creds, format="json") + self.assertEqual(resp1.status_code, 200) + self.assertIn("token", resp1.data) + self.assertEqual(AuthToken.objects.count(), 1) + resp2 = self.client.post(login_url, self.creds, format="json") + self.assertEqual(resp2.status_code, 200) + self.assertIn("token", resp2.data) + + reload(views) + self.assertEqual( + AuthToken.objects.count(), + 1, + "should renew token, instead of creating new.", + ) + self.assertNotEqual( + resp1.data["expiry"], + resp2.data["expiry"], + "token expiry should be renewed after login", + ) + self.assertEqual( + resp1.data["token"], + resp2.data["token"], + "token key must remain same", + ) + def test_refresh_view_and_renewed_signal(self): self.signal_was_called = False From 0057049cbf5e1590dba59ab7a49f09d4c84d6853 Mon Sep 17 00:00:00 2001 From: Eshaan Bansal Date: Sat, 24 Oct 2020 21:16:21 +0530 Subject: [PATCH 4/4] writing docs --- .vscode/settings.json | 3 +- CONTRIBUTORS | 1 + README.md | 23 ++----- docs/Makefile | 20 ++++++ docs/auth.md | 108 -------------------------------- docs/changelog.md | 1 - docs/index.md | 31 ---------- docs/installation.md | 66 -------------------- docs/make.bat | 35 +++++++++++ docs/requirements.docs.txt | 11 ++++ docs/serve_docs.sh | 4 ++ docs/settings.md | 105 ------------------------------- docs/source/auth.rst | 61 ++++++++++++++++++ docs/source/conf.py | 95 ++++++++++++++++++++++++++++ docs/source/contribute.rst | 28 +++++++++ docs/source/durin.rst | 25 ++++++++ docs/source/index.rst | 55 ++++++++++++++++ docs/source/installation.rst | 57 +++++++++++++++++ docs/source/permissions.rst | 20 ++++++ docs/source/settings.rst | 86 +++++++++++++++++++++++++ docs/source/signals.rst | 20 ++++++ docs/source/urls.rst | 33 ++++++++++ docs/source/views.rst | 90 +++++++++++++++++++++++++++ docs/urls.md | 28 --------- docs/views.md | 78 ----------------------- durin/admin.py | 7 +++ durin/auth.py | 32 ++++++++-- durin/permissions.py | 23 +++++-- durin/signals.py | 11 ++++ durin/views.py | 117 ++++++++++++++++++++++++++--------- mkdocs.sh | 12 ---- mkdocs.yml | 14 ----- requirements.dev.txt | 3 +- tests/tests.py | 9 ++- 34 files changed, 801 insertions(+), 511 deletions(-) create mode 100644 docs/Makefile delete mode 100644 docs/auth.md delete mode 120000 docs/changelog.md delete mode 100644 docs/index.md delete mode 100644 docs/installation.md create mode 100644 docs/make.bat create mode 100644 docs/requirements.docs.txt create mode 100755 docs/serve_docs.sh delete mode 100644 docs/settings.md create mode 100644 docs/source/auth.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/contribute.rst create mode 100644 docs/source/durin.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/permissions.rst create mode 100644 docs/source/settings.rst create mode 100644 docs/source/signals.rst create mode 100644 docs/source/urls.rst create mode 100644 docs/source/views.rst delete mode 100644 docs/urls.md delete mode 100644 docs/views.md delete mode 100755 mkdocs.sh delete mode 100644 mkdocs.yml diff --git a/.vscode/settings.json b/.vscode/settings.json index c124094..25813f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "python.formatting.provider": "black", "python.linting.pylintEnabled": false, "python.linting.flake8Enabled": true, - "python.linting.enabled": true + "python.linting.enabled": true, + "restructuredtext.confPath": "${workspaceFolder}/venv/lib/python3.8/site-packages/django/urls" } \ No newline at end of file diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 833ed94..e609d71 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -1,3 +1,4 @@ - James McMahon - Eshaan Bansal +- Shabda Raaj - Matteo Lodi diff --git a/README.md b/README.md index 2ba31a8..009faf8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# django-rest-durin +# Django-Rest-Durin [![django-rest-durin on pypi](https://img.shields.io/pypi/v/django-rest-durin)](https://pypi.org/project/django-rest-durin/) [![Build Status](https://travis-ci.com/Eshaan7/django-rest-durin.svg?branch=main)](https://travis-ci.com/Eshaan7/django-rest-durin) @@ -16,29 +16,14 @@ Durin authentication is token based, similar to the `TokenAuthentication` built in to DRF. However, it adds some extra sauce: - Durin allows multiple tokens per user. But only one token each user per API client. - - Each user token is associated with an API Client. These API Clients are configurable via Django's Admin Interface. - - All Durin tokens have an expiration time. This expiration time can be different per API client. - - Durin provides an option for a logged in user to remove *all* tokens that the server has - forcing him/her to re-authenticate for all API clients. - - Durin tokens can be renewed to get a fresh expiry. +- Durin provides a `CachedTokenAuthentication` backend as well which uses memoization for faster look ups. -- Durin provides a [Cached Token Authentication](#cache-backend) backend as well. - -More information can be found in the [Documentation]() - -## Cache Backend - -If you want to use a cache for the session store, you can install [django-memoize](https://pythonhosted.org/django-memoize/) and add `'memoize'` to `INSTALLED_APPS`. - -Then you need to use ``CachedTokenAuthentication`` instead of ``TokenAuthentication``. - -```bash -pip install django-memoize -``` +More information can be found in the [Documentation](https://django-rest-durin.readthedocs.io/). ## Django Compatibility Matrix @@ -62,4 +47,4 @@ This project is published with the [MIT License](LICENSE). See [https://chooseal ## Credits -Durin is inpired by the [django-rest-knox](https://github.com/James1345/django-rest-knox) and [django-rest-multitokenauth](https://github.com/anexia-it/django-rest-multitokenauth) libraries and includes some learnings and code from both. \ No newline at end of file +Durin is inpired by the [django-rest-knox](https://github.com/James1345/django-rest-knox) and [django-rest-multitokenauth](https://github.com/anexia-it/django-rest-multitokenauth) libraries and includes some learnings, docs and code from both. \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/auth.md b/docs/auth.md deleted file mode 100644 index 5872373..0000000 --- a/docs/auth.md +++ /dev/null @@ -1,108 +0,0 @@ -# Authentication `knox.auth` - -Knox provides one class to handle authentication. - -## TokenAuthentication - -This works using [DRF's authentication system](http://www.django-rest-framework.org/api-guide/authentication/). - -Knox tokens should be generated using the provided views. -Any `APIView` or `ViewSet` can be accessed using these tokens by adding `TokenAuthentication` -to the View's `authentication_classes`. -To authenticate, the `Authorization` header should be set on the request, with a -value of the word `"Token"`, then a space, then the authentication token provided by -`LoginView`. - -Example: -```python -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from knox.auth import TokenAuthentication - -class ExampleView(APIView): - authentication_classes = (TokenAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request, format=None): - content = { - 'foo': 'bar' - } - return Response(content) -``` - -Example auth header: - -```javascript -Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b9836F45E23A345 -``` - -Tokens expire after a preset time. See settings. - - -### Global usage on all views - -You can activate TokenAuthentication on all your views by adding it to `REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`. - -If it is your only default authentication class, remember to overwrite knox's LoginView, otherwise it'll not work, since the login view will require a authentication token to generate a new token, rendering it unusable. - -For instance, you can authenticate users using Basic Authentication by simply overwriting knox's LoginView and setting BasicAuthentication as one of the acceptable authentication classes, as follows: - -```python - -views.py: - -from knox.views import LoginView as KnoxLoginView -from rest_framework.authentication import BasicAuthentication - -class LoginView(KnoxLoginView): - authentication_classes = [BasicAuthentication] - -urls.py: - -from knox import views as knox_views -from yourapp.api.views import LoginView - -urlpatterns = [ - url(r'login/', LoginView.as_view(), name='knox_login'), - url(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), - url(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), -] -``` - -You can use any number of authentication classes if you want to be able to authenticate using different methods (eg.: Basic and JSON) in the same view. Just be sure not to set TokenAuthentication as your only authentication class on the login view. - -If you decide to use Token Authentication as your only authentication class, you can overwrite knox's login view as such: - -```python - -views.py: - -from django.contrib.auth import login - -from rest_framework import permissions -from rest_framework.authtoken.serializers import AuthTokenSerializer -from knox.views import LoginView as KnoxLoginView - -class LoginView(KnoxLoginView): - permission_classes = (permissions.AllowAny,) - - def post(self, request, format=None): - serializer = AuthTokenSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.validated_data['user'] - login(request, user) - return super(LoginView, self).post(request, format=None) - -urls.py: - -from knox import views as knox_views -from yourapp.api.views import LoginView - -urlpatterns = [ - url(r'login/', LoginView.as_view(), name='knox_login'), - url(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), - url(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), -] -``` \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 120000 index 04c99a5..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 8a498e9..0000000 --- a/docs/index.md +++ /dev/null @@ -1,31 +0,0 @@ -# Django-Rest-Knox -Knox provides easy to use authentication for [Django REST Framework](http://www.django-rest-framework.org/) -The aim is to allow for common patterns in applications that are REST based, -with little extra effort; and to ensure that connections remain secure. - -Knox authentication is token based, similar to the `TokenAuthentication` built -in to DRF. However, it overcomes some problems present in the default implementation: - -- DRF tokens are limited to one per user. This does not facilitate securely - signing in from multiple devices, as the token is shared. It also requires - *all* devices to be logged out if a server-side logout is required (i.e. the - token is deleted). - - Knox provides one token per call to the login view - allowing - each client to have its own token which is deleted on the server side when the client - logs out. Knox also provides an optional setting to limit the amount of tokens generated - per user. - - Knox also provides an option for a logged in client to remove *all* tokens - that the server has - forcing all clients to re-authenticate. - -- DRF tokens are stored unencrypted in the database. This would allow an attacker - unrestricted access to an account with a token if the database were compromised. - - Knox tokens are only stored in an encrypted form. Even if the database were - somehow stolen, an attacker would not be able to log in with the stolen - credentials. - -- DRF tokens track their creation time, but have no inbuilt mechanism for tokens - expiring. Knox tokens can have an expiry configured in the app settings (default is - 10 hours.) diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index ad7da04..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,66 +0,0 @@ -# Installation - -## Requirements - -Knox depends on `cryptography` to provide bindings to `OpenSSL` for token generation -This requires the OpenSSL build libraries to be available. - -### Windows -Cryptography is a statically linked build, no extra steps are needed - -### Linux -`cryptography` should build very easily on Linux provided you have a C compiler, -headers for Python (if you’re not using `pypy`), and headers for the OpenSSL and -`libffi` libraries available on your system. - -Debian and Ubuntu: -```bash -sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python-dev -``` - -Fedora and RHEL-derivatives: -```bash -sudo yum install gcc libffi-devel python-devel openssl-devel -``` -For other systems or problems, see the [cryptography installation docs](https://cryptography.io/en/latest/installation/) - -## Installing Knox -Knox should be installed with pip - -```bash -pip install django-rest-knox -``` - -## Setup knox - -- Add `rest_framework` and `knox` to your `INSTALLED_APPS`, remove -`rest_framework.authtoken` if you were using it. - -```python -INSTALLED_APPS = ( - ... - 'rest_framework', - 'knox', - ... -) -``` - -- Make knox's TokenAuthentication your default authentification class -for django-rest-framework: - -```python -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), - ... -} -``` - -- Add the [knox url patterns](urls.md#urls-knoxurls) to your project. - -- If you set TokenAuthentication as the only default authentication class on the second step, [override knox's LoginView](auth.md#global-usage-on-all-views) to accept another authentication method and use it instead of knox's default login view. - -- Apply the migrations for the models - -```bash -python manage.py migrate -``` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.docs.txt b/docs/requirements.docs.txt new file mode 100644 index 0000000..8edc918 --- /dev/null +++ b/docs/requirements.docs.txt @@ -0,0 +1,11 @@ +../requirements.dev.txt +commonmark==0.9.1 +docutils==0.16 +Sphinx==3.2.1 +sphinx-rtd-theme +sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==1.0.3 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.4 \ No newline at end of file diff --git a/docs/serve_docs.sh b/docs/serve_docs.sh new file mode 100755 index 0000000..f5c2a5c --- /dev/null +++ b/docs/serve_docs.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +export DJANGO_SETTINGS_MODULE="example_project.settings" +make html +cd build/html && python3 -m http.server 6969 && cd ../../ \ No newline at end of file diff --git a/docs/settings.md b/docs/settings.md deleted file mode 100644 index 4fb1b36..0000000 --- a/docs/settings.md +++ /dev/null @@ -1,105 +0,0 @@ -# Settings `knox.settings` - -Settings in Knox are handled in a similar way to the rest framework settings. -All settings are namespaced in the `'REST_KNOX'` setting. - -Example `settings.py` - -```python -#...snip... -# These are the default values if none are set -from datetime import timedelta -from rest_framework.settings import api_settings -REST_KNOX = { - 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', - 'AUTH_TOKEN_CHARACTER_LENGTH': 64, - 'TOKEN_TTL': timedelta(hours=10), - 'USER_SERIALIZER': 'knox.serializers.UserSerializer', - 'TOKEN_LIMIT_PER_USER': None, - 'AUTO_REFRESH': False, - 'EXPIRY_DATETIME_FORMAT': api_settings.DATETME_FORMAT, -} -#...snip... -``` - -## SECURE_HASH_ALGORITHM -This is a reference to the class used to provide the hashing algorithm for -token storage. - -*Do not change this unless you know what you are doing* - -By default, Knox uses SHA-512 to hash tokens in the database. - -`cryptography.hazmat.primitives.hashes.Whirlpool` is an acceptable alternative setting -for production use. - -### Tests -SHA-512 and Whirlpool are secure, however, they are slow. This should not be a -problem for your users, but when testing it may be noticable (as test cases tend -to use many more requests much more quickly than real users). In testing scenarios -it is acceptable to use `MD5` hashing.(`cryptography.hazmat.primitives.hashes.MD5`) - -MD5 is **not secure** and must *never* be used in production sites. - -## AUTH_TOKEN_CHARACTER_LENGTH -This is the length of the token that will be sent to the client. By default it -is set to 64 characters (this shouldn't need changing). - -## TOKEN_TTL -This is how long a token can exist before it expires. Expired tokens are automatically -removed from the system. - -The setting should be set to an instance of `datetime.timedelta`. The default is -10 hours ()`timedelta(hours=10)`). - -Setting the TOKEN_TTL to `None` will create tokens that never expire. - -Warning: setting a 0 or negative timedelta will create tokens that instantly expire, -the system will not prevent you setting this. - -## TOKEN_LIMIT_PER_USER -This allows you to control how many tokens can be issued per user. -By default this option is disabled and set to `None` -- thus no limit. - -## USER_SERIALIZER -This is the reference to the class used to serialize the `User` objects when -succesfully returning from `LoginView`. The default is `knox.serializers.UserSerializer` - -## AUTO_REFRESH -This defines if the token expiry time is extended by TOKEN_TTL each time the token -is used. - -## MIN_REFRESH_INTERVAL -This is the minimum time in seconds that needs to pass for the token expiry to be updated -in the database. - -## AUTH_HEADER_PREFIX -This is the Authorization header value prefix. The default is `Token` - -## EXPIRY_DATETIME_FORMAT -This is the expiry datetime format returned in the login view. The default is the -[DATETIME_FORMAT][DATETIME_FORMAT] of Django REST framework. May be any of `None`, `iso-8601` -or a Python [strftime format][strftime format] string. - -[DATETIME_FORMAT]: https://www.django-rest-framework.org/api-guide/settings/#date-and-time-formatting -[strftime format]: https://docs.python.org/3/library/time.html#time.strftime - -# Constants `knox.settings` -Knox also provides some constants for information. These must not be changed in -external code; they are used in the model definitions in knox and an error will -be raised if there is an attempt to change them. - -```python -from knox.settings import CONSTANTS - -print(CONSTANTS.DIGEST_LENGTH) #=> 128 -print(CONSTANTS.SALT_LENGTH) #=> 16 -``` - -## DIGEST_LENGTH -This is the length of the digest that will be stored in the database for each token. - -## SALT_LENGTH -This is the length of the [salt][salt] that will be stored in the database for each token. - -[salt]: https://en.wikipedia.org/wiki/Salt_(cryptography) diff --git a/docs/source/auth.rst b/docs/source/auth.rst new file mode 100644 index 0000000..9d17ac5 --- /dev/null +++ b/docs/source/auth.rst @@ -0,0 +1,61 @@ +*************************************** +Authentication (``durin.auth``) +*************************************** + +Durin provides one ``TokenAuthentication`` backend and +``CachedTokenAuthentication`` which uses memoization for faster look ups. + +TokenAuthentication +-------------------------- + +.. autoclass:: durin.auth.TokenAuthentication + :show-inheritance: + +Durin tokens should be generated using the provided views. +Any ``APIView`` or ``ViewSet`` can be accessed using these tokens by adding ``TokenAuthentication`` +to the View's ``authentication_classes``. +To authenticate, the ``Authorization`` header should be set on the request, like:: + + Authorization: Token adee69d0e4bbdc6e4m9836F45E23A325 + +**Note**: The prefix can be configured by setting the ``REST_DURIN["AUTH_HEADER_PREFIX"]`` (`ref `__). + +**Example Usage**:: + + from rest_framework.permissions import IsAuthenticated + from rest_framework.response import Response + from rest_framework.views import APIView + + from durin.auth import TokenAuthentication + + class ExampleView(APIView): + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): + content = { + 'foo': 'bar' + } + return Response(content) + +Tokens expire after a preset time. See `settings.DEFAULT_TOKEN_TTL `__. + +-------------------------- + +CachedTokenAuthentication +-------------------------- + +.. autoclass:: durin.auth.CachedTokenAuthentication + :show-inheritance: + +-------------------------- + +Global usage on all views +-------------------------- + +You can activate Durin's :class:`durin.auth.TokenAuthentication` or +:class:`durin.auth.CachedTokenAuthentication` on all your views by adding it to +``REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`` under your app's ``settings.py``. +Make sure to not use both of these together. + +.. Warning:: If you use `Token Authentication` in production you must ensure that your API is only available over HTTPS (SSL). diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..b81f970 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,95 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import django + +# I've simplified this a little to use append instead of insert. +sys.path.append(os.path.abspath("../../")) + +# Specify settings module +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") + +# Setup Django +django.setup() + + +# -- Project information ----------------------------------------------------- + +project = "Django-Rest-Durin" +copyright = "2020, Eshaan Bansal" +author = "Eshaan Bansal" + +# The full version, including alpha/beta/rc tags +release = "0.1.0" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx_rtd_theme", + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.autosectionlabel", +] + +source_suffix = [".rst", ".md"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" +html_theme_path = [ + "_themes", +] + +# Custom options + +# html_context = { +# "project_links": [ +# ProjectLink("Donate To The Author", "https://paypal.me/eshaanbansal"), +# ProjectLink(f"{project} Website", f"https://{project}.readthedocs.io/"), +# ProjectLink("PyPI releases", f"https://pypi.org/project/{project}/"), +# ProjectLink("Source Code", f"https://github.com/eshaan7/{project}"), +# ProjectLink("Issue Tracker", f"https://github.com/eshaan7/{project}/issues"), +# ] +# } +html_sidebars = { + "index": ["localtoc.html", "searchbox.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html"], +} +singlehtml_sidebars = {"index": ["localtoc.html"]} +html_static_path = ["_static"] +# html_favicon = "_static/flask-icon.png" +# html_logo = "_static/flask-icon.png" +html_title = f"{project} Documentation ({release})" + + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] diff --git a/docs/source/contribute.rst b/docs/source/contribute.rst new file mode 100644 index 0000000..7d925d7 --- /dev/null +++ b/docs/source/contribute.rst @@ -0,0 +1,28 @@ +Development +================================ + +If you would like to contribute to django-rest-durin, you can clone the `respository `__ from GitHub. + +.. parsed-literal:: + git clone https://github.com/Eshaan7/django-rest-durin + +Extra dependencies required during testing or development can be installed with: + +.. parsed-literal:: + pip install django-rest-durin[dev] + +Before committing your changes with git or pushing them to remote, please run the following: + + .. parsed-literal:: +bash pre-commit.sh + +Run the tests locally +================================ + +If you need to debug a test locally and if you have `docker `__ installed: + +simply run the ``./docker-run-tests.sh`` script and it will run the test suite in every Python / +Django versions. + +You could also simply run regular ``tox`` in the root folder as well, but that would make testing the matrix of +Python / Django versions a bit more tricky. diff --git a/docs/source/durin.rst b/docs/source/durin.rst new file mode 100644 index 0000000..c38ec92 --- /dev/null +++ b/docs/source/durin.rst @@ -0,0 +1,25 @@ +Durin submodules +================== + +``durin.admin`` module +------------------------------ + +.. automodule:: durin.admin + :members: + :undoc-members: + :show-inheritance: + +``durin.models`` module +------------------------------ + +.. automodule:: durin.models + :members: + :show-inheritance: + +``durin.serializers`` module +------------------------------ + +.. automodule:: durin.serializers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..5ea4e4a --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,55 @@ +Welcome to Django-Rest-Durin! +================================ + +.. image:: https://img.shields.io/pypi/v/django-rest-durin +.. image:: https://img.shields.io/lgtm/grade/python/g/Eshaan7/django-rest-durin.svg?logo=lgtm&logoWidth=18 +.. image:: https://travis-ci.com/Eshaan7/django-rest-durin.svg?branch=main +.. image:: https://codecov.io/gh/Eshaan7/django-rest-durin/branch/main/graph/badge.svg?token=S9KEI0PU05 + +Per API client token authentication Module for Django-REST-Framework_ + +The idea is to provide one library that does token auth for multiple Web/CLI/Mobile API clients via one interface but allows different token configuration for each client. + +Durin authentication is token based, similar to the ``TokenAuthentication`` +built in to DRF. However, it adds some extra sauce: + +- Durin allows **multiple tokens** per user. But only one token each user per API client. +- Each user token is associated with an API Client. These API Clients (:class:`durin.models.Client`) are configurable via Django's Admin Interface. +- All Durin tokens have an expiration time. This expiration time can be different per API client. +- Durin provides an option for a logged in user to remove **all** tokens that the server has - forcing him/her to re-authenticate for all API clients. +- Durin **tokens can be renewed** to get a fresh expiry. +- Durin provides a :class:`durin.auth.CachedTokenAuthentication` backend as well which uses memoization for faster look ups. + +.. _Django-REST-Framework: http://www.django-rest-framework.org/ + +Index +------------------------------- +Get started at :doc:`installation`. + +.. toctree:: + :maxdepth: 2 + + installation + settings + auth + views + urls + permissions + signals + contribute + +API Reference +------------------------------- +If you are looking for information on a specific function, class or +method, this part of the documentation is for you. + +.. toctree:: + :maxdepth: 2 + + durin + +Indices and tables +================================ + +* :ref:`genindex` +* :ref:`modindex` \ No newline at end of file diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..97c20fa --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,57 @@ +Installation +================ + +Django Compatibility Matrix +-------------------------------- +If your project uses an older verison of Django or Django Rest Framework, you can choose an older version of this project. + ++--------------+----------------+----------------+----------------------+ +| This Project | Python Version | Django Version | Django Rest Framework| ++--------------+----------------+----------------+----------------------+ +| 0.1.* | 3.5 - 3.9 | 2.2, 3.0, 3.1 | 3.7>= | ++--------------+----------------+----------------+----------------------+ + + +Make sure to use at least ``DRF 3.10`` when using ``Django 3.0`` or newer. + +Install Durin +-------------- + +Durin should be installed with ``pip``: + +.. parsed-literal:: + $ pip install django-rest-durin + + +Setup Durin +-------------- + +- Add ``rest_framework`` and :mod:`durin` to your ``INSTALLED_APPS``, remove + ``rest_framework.authtoken`` if you were using it.:: + + INSTALLED_APPS = ( + ... + 'rest_framework', + 'durin', + ... + ) + +- Make Durin's :class:`durin.auth.TokenAuthentication` your default authentication class + for django-rest-framework:: + + REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ('durin.auth.TokenAuthentication',), + ... + } + +- Add the Durin's :doc:`urls` patterns to your project. + +- Customize Durin's :doc:`settings` for your project. + +- Apply the migrations for the models: + + .. parsed-literal:: + $ python manage.py migrate + + +.. Hint:: To use the cache backend for faster lookups, see :class:`durin.auth.CachedTokenAuthentication`. \ No newline at end of file diff --git a/docs/source/permissions.rst b/docs/source/permissions.rst new file mode 100644 index 0000000..49b7ba8 --- /dev/null +++ b/docs/source/permissions.rst @@ -0,0 +1,20 @@ +Permissions (``durin.permissions``) +==================================== + +-------------------------- + +AllowSpecificClients +-------------------------- + +.. autoclass:: durin.permissions.AllowSpecificClients + :members: + :show-inheritance: + +-------------------------- + +DisallowSpecificClients +-------------------------- + +.. autoclass:: durin.permissions.DisallowSpecificClients + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/settings.rst b/docs/source/settings.rst new file mode 100644 index 0000000..a07810b --- /dev/null +++ b/docs/source/settings.rst @@ -0,0 +1,86 @@ +Settings (``durin.settings``) +================================ + +Settings in durin are handled in a similar way to the rest framework settings. +All settings are namespaced in the ``'REST_DURIN'`` setting. + +Example ``settings.py``:: + + #...snip... + # These are the default values if none are set + from datetime import timedelta + from rest_framework.settings import api_settings + REST_DURIN = { + "DEFAULT_TOKEN_TTL": timedelta(days=1), + "TOKEN_CHARACTER_LENGTH": 64, + "USER_SERIALIZER": None, + "AUTH_HEADER_PREFIX": "Token", + "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT, + "TOKEN_CACHE_TIMEOUT": 60, + "REFRESH_TOKEN_ON_LOGIN": False, + } + #...snip... + +.. data:: DEFAULT_TOKEN_TTL + + Default: ``timedelta(days=1)`` + + This is how long a token can exist before it expires. Expired tokens are automatically removed from the system. + + The setting should be set to an instance of ``datetime.timedelta``. + + Durin provides setting a different token Time To Live (``token_ttl``) value per client object. + So this is the default value the :class:`durin.models.Client` model uses incase a custom value wasn't specified. + + **Warning:** setting a 0 or negative timedelta will create tokens that instantly expire, + the system will not prevent you setting this. + +.. data:: TOKEN_CHARACTER_LENGTH + + Default: ``64`` + + This is the length of the token that will be sent to the client. This shouldn't need changing. + +.. data:: USER_SERIALIZER + + Default: ``None`` + + This is the reference to the class used to serialize the ``User`` objects when + succesfully returning from :class:`durin.views.LoginView`. The default is :class:`durin.serializers.UserSerializer`. + +.. data:: AUTH_HEADER_PREFIX + + Default: ``"Token"`` + + This is the Authorization header value prefix. + +.. data:: EXPIRY_DATETIME_FORMAT + + Default: `DATETIME_FORMAT `__ + (of Django REST framework) + + This is the expiry datetime format returned in the login and refresh views. + + May be any of ``None``, ``iso-8601`` or a Python + `strftime format `__ string. + +.. data:: TOKEN_CACHE_TIMEOUT + + Default: ``60`` + + This is the cache timeout (in seconds) used by ``django-memoize`` + in case you are using :class:`durin.auth.CachedTokenAuthentication` backend in your app. + +.. data:: REFRESH_TOKEN_ON_LOGIN + + Default: ``False`` + + When a request is made to the :class:`durin.views.LoginView`. One of two things happen: + + 1. Token instance for a particular user-client pair already exists. + + 2. A new token instance is generated for the provided user-client pair. + + In the first case, the already existing token is sent in response. + So this setting if set to ``True`` should extend the expiry time of the + token by it's :class:`durin.models.Client` ``token_ttl`` everytime login happens. \ No newline at end of file diff --git a/docs/source/signals.rst b/docs/source/signals.rst new file mode 100644 index 0000000..5ef2740 --- /dev/null +++ b/docs/source/signals.rst @@ -0,0 +1,20 @@ +Signals (``durin.signals``) +================================== + +-------------------------- + +``token_expired`` +-------------------------- + +.. automodule:: durin.signals + :members: token_expired + :show-inheritance: + +-------------------------- + +``token_renewed`` +-------------------------- + +.. automodule:: durin.signals + :members: token_renewed + :show-inheritance: \ No newline at end of file diff --git a/docs/source/urls.rst b/docs/source/urls.rst new file mode 100644 index 0000000..d9e16cd --- /dev/null +++ b/docs/source/urls.rst @@ -0,0 +1,33 @@ +URLs (``durin.urls``) +======================== + +Durin provides a URL config ready with its 4 default views routed. + +This can easily be included in your url config: + +.. code-block:: python + :linenos: + :emphasize-lines: 3 + + urlpatterns = [ + #...snip... + url(r'api/auth/', include('durin.urls')) + #...snip... + ] + +**Note**: It is important to use the string syntax and not try to import ``durin.urls``, +as the reference to the ``User`` model will cause the app to fail at import time. + +The views would then accessible as: + +- ``/api/auth/login`` -> ``LoginView`` +- ``/api/auth/refresh`` - ``RefreshView`` +- ``/api/auth/logout`` -> ``LogoutView`` +- ``/api/auth/logoutall`` -> ``LogoutAllView`` + +they can also be looked up by name:: + + reverse('durin_login') + reverse('durin_logout') + reverse('durin_refresh') + reverse('durin_logoutall') diff --git a/docs/source/views.rst b/docs/source/views.rst new file mode 100644 index 0000000..054b3a4 --- /dev/null +++ b/docs/source/views.rst @@ -0,0 +1,90 @@ +Views (``durin.views``) +================================ + +Durin provides four views that handle token management for you. + +-------------------------- + +LoginView +-------------------------- + +.. autoclass:: durin.views.LoginView + :members: + :show-inheritance: + +When the endpoint authenticates a request, a JSON object will be returned +containing the ``token`` as a string, ``expiry`` as a timestamp for when +the token expires. + +*This is because ``USER_SERIALIZER`` setting is `None` by default.* + +If you wish to return custom data upon successful authentication +like ``first_name``, ``last_name``, and ``username`` then the included ``UserSerializer`` +class can be used inside ``REST_DURIN`` settings by adding :class:`durin.serializers.UserSerializer`. + +Obviously, if your app uses a custom user model that does not have these fields, +a custom serializer must be used. + +Client Configuration +^^^^^^^^^^^^^^^^^^^^ + +In most cases, you would want to customize how the login view gets the +client object to associate with the token. By default, it is the ``client`` attribute in POSTed request body. +Here's an example snippet of how you can override this behaviour:: + + ### views.py: + + from durin.models import Client as APIClient + from durin.views import LoginView as DurinLoginView + + class LoginView(DurinLoginView): + + @staticmethod + def get_client_obj(request): + # get the client's name from a request header + client_name = request.META.get("X-my-personal-header", None) + if not client_name: + raise ParseError("No client specified.", status.HTTP_400_BAD_REQUEST) + return APIClient.objects.get_or_create(name=client_name) + + + ### urls.py: + + from durin import views as durin_views + from yourapp.api.views import LoginView + + urlpatterns = [ + url(r'login/', LoginView.as_view(), name='durin_login'), + url(r'refresh/', durin_views.RefreshView.as_view(), name='durin_refresh'), + url(r'logout/', durin_views.LogoutView.as_view(), name='durin_logout'), + url(r'logoutall/', durin_views.LogoutAllView.as_view(), name='durin_logoutall'), + ] + +-------------------------- + +RefreshView +-------------------------- + +.. autoclass:: durin.views.RefreshView + :members: + :show-inheritance: + +-------------------------- + +LogoutView +-------------------------- + +.. autoclass:: durin.views.LogoutView + :show-inheritance: + +-------------------------- + +LogoutAllView +-------------------------- + +.. autoclass:: durin.views.LogoutAllView + :show-inheritance: + +.. Note:: It is not recommended to alter the Logout views. They are designed + specifically for token management, and to respond to durin authentication. + Modified forms of the class may cause unpredictable results. diff --git a/docs/urls.md b/docs/urls.md deleted file mode 100644 index 9517712..0000000 --- a/docs/urls.md +++ /dev/null @@ -1,28 +0,0 @@ -#URLS `knox.urls` -Knox provides a url config ready with its three default views routed. - -This can easily be included in your url config: - -```python -urlpatterns = [ - #...snip... - url(r'api/auth/', include('knox.urls')) - #...snip... -] -``` -**Note** It is important to use the string syntax and not try to import `knox.urls`, -as the reference to the `User` model will cause the app to fail at import time. - -The views would then acessible as: - -- `/api/auth/login` -> `LoginView` -- `/api/auth/logout` -> `LogoutView` -- `/api/auth/logoutall` -> `LogoutAllView` - -they can also be looked up by name: - -```python -reverse('knox_login') -reverse('knox_logout') -reverse('knox_logoutall') -``` diff --git a/docs/views.md b/docs/views.md deleted file mode 100644 index 509c58d..0000000 --- a/docs/views.md +++ /dev/null @@ -1,78 +0,0 @@ -# Views `knox.views` -Knox provides three views that handle token management for you. - -## LoginView -This view accepts only a post request with an empty body. - -The LoginView accepts the same sort of authentication as your Rest Framework -`DEFAULT_AUTHENTICATION_CLASSES` setting. If this is not set, it defaults to -`(SessionAuthentication, BasicAuthentication)`. - -LoginView was designed to work well with Basic authentication, or similar -schemes. If you would like to use a different authentication scheme to the -default, you can extend this class to provide your own value for -`authentication_classes` - -It is possible to customize LoginView behaviour by overriding the following -helper methods: -- `get_context(self)`, to change the context passed to the `UserSerializer` -- `get_token_ttl(self)`, to change the token ttl -- `get_token_limit_per_user(self)`, to change the number of tokens available for a user -- `get_user_serializer_class(self)`, to change the class used for serializing the user -- `get_expiry_datetime_format(self)`, to change the datetime format used for expiry -- `format_expiry_datetime(self, expiry)`, to format the expiry `datetime` object at your convinience - -Finally, if none of these helper methods are sufficient, you can also override `get_post_response_data` -to return a fully customized payload. - -```python -...snip... - def get_post_response_data(self, request, token, instance): - UserSerializer = self.get_user_serializer_class() - - data = { - 'expiry': self.format_expiry_datetime(instance.expiry), - 'token': token - } - if UserSerializer is not None: - data["user"] = UserSerializer( - request.user, - context=self.get_context() - ).data - return data -...snip... -``` - ---- -When the endpoint authenticates a request, a json object will be returned -containing the `token` key along with the actual value for the key by default. -The success response also includes a `expiry` key with a timestamp for when -the token expires. - -> *This is because `USER_SERIALIZER` setting is `None` by default.* - -If you wish to return custom data upon successful authentication -like `first_name`, `last_name`, and `username` then the included `UserSerializer` -class can be used inside `REST_KNOX` settings by adding `knox.serializers.UserSerializer` - ---- - -Obviously, if your app uses a custom user model that does not have these fields, -a custom serializer must be used. - -## LogoutView -This view accepts only a post request with an empty body. -It responds to Knox Token Authentication. On a successful request, -the token used to authenticate is deleted from the -system and can no longer be used to authenticate. - -## LogoutAllView -This view accepts only a post request with an empty body. It responds to Knox Token -Authentication. -On a successful request, the token used to authenticate, and *all other tokens* -registered to the same `User` account, are deleted from the -system and can no longer be used to authenticate. - -**Note** It is not recommended to alter the Logout views. They are designed -specifically for token management, and to respond to Knox authentication. -Modified forms of the class may cause unpredictable results. diff --git a/durin/admin.py b/durin/admin.py index fd750f3..aa8fd0f 100644 --- a/durin/admin.py +++ b/durin/admin.py @@ -5,6 +5,10 @@ @admin.register(models.AuthToken) class AuthTokenAdmin(admin.ModelAdmin): + """Django's ModelAdmin for AuthToken. + In most cases, you would want to override this to make + ``AuthTokenAdmin.raw_id_fields = ("user",)`` + """ exclude = ("token", "expiry") list_display = ( "token", @@ -38,4 +42,7 @@ def save_model(self, request, obj, form, change): @admin.register(models.Client) class ClientAdmin(admin.ModelAdmin): + """ + Django's ModelAdmin for Client. + """ list_display = ("id", "name", "token_ttl") diff --git a/durin/auth.py b/durin/auth.py index 75ad268..d3e9ddc 100644 --- a/durin/auth.py +++ b/durin/auth.py @@ -17,14 +17,18 @@ class TokenAuthentication(BaseAuthentication): """ - This authentication scheme uses Durin AuthTokens for authentication. + This authentication scheme uses Durin's + :class:`durin.models.AuthToken` for authentication. - Similar to DRF's TokenAuthentication, it overrides it a bit to + Similar to `DRF's authentication system + `__, + it overrides it a bit to accomodate that tokens can be expired. - If successful - - `request.user` will be a django `User` instance - - `request.auth` will be an `AuthToken` instance + If successful, + + - ``request.user`` will be a django ``User`` instance + - ``request.auth`` will be an ``AuthToken`` instance """ model = AuthToken @@ -88,7 +92,23 @@ def _cleanup_token(cls, auth_token: AuthToken): class CachedTokenAuthentication(TokenAuthentication): """ - Cached TokenAuthentication, using django-memoize + Similar to ``TokenAuthentication`` but uses + `django-memoize `__ + as cache backend for faster lookups. + + The cache timeout is configurable by setting the + ``REST_DURIN["TOKEN_CACHE_TIMEOUT"]`` under your app's ``settings.py``. + + **How To Enable:** + + 1. Install django-memoize + + .. parsed-literal:: + pip install django-memoize + + 2. Add ``'memoize'`` to ``INSTALLED_APPS``. + 3. Then you need to use ``CachedTokenAuthentication`` + instead of ``TokenAuthentication``. """ @classmethod diff --git a/durin/permissions.py b/durin/permissions.py index e65007a..0a35d30 100644 --- a/durin/permissions.py +++ b/durin/permissions.py @@ -1,13 +1,25 @@ +""" +Durin provides two permission classes which make use of the :class:`durin.models.Client` +model it offers. + +You can use these the same way as other +`DRF permissions `__ or +activate them on all your views by adding +them to ``REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]`` +under your app's ``settings.py`` +""" + from rest_framework.permissions import BasePermission class AllowSpecificClients(BasePermission): """ - Allows access to only specific clients. - Should be used along with `durin.auth.TokenAuthentication`. + Allows access to only specific clients.\n + Should be used along with :doc:`auth`. """ - allowed_clients_name = ("web",) + #: Include names of allowed clients to ``allowed_clients_name``. + allowed_clients_name = () def has_permission(self, request, view): if not hasattr(request, "_auth"): @@ -17,10 +29,11 @@ def has_permission(self, request, view): class DisallowSpecificClients(BasePermission): """ - restrict specific clients from making requests. - Should be used along with `durin.auth.TokenAuthentication`. + Restrict specific clients from making requests.\n + Should be used along with :doc:`auth`. """ + #: Include names of disallowed clients to ``disallowed_clients_name``. disallowed_clients_name = () def has_permission(self, request, view): diff --git a/durin/signals.py b/durin/signals.py index ce1a3c1..6d00532 100644 --- a/durin/signals.py +++ b/durin/signals.py @@ -1,7 +1,18 @@ import django.dispatch token_expired = django.dispatch.Signal(providing_args=["username", "source"]) +""" +When a token is expired and deleted. + + providing_args=["username", "source"] +""" token_renewed = django.dispatch.Signal( providing_args=["username", "token_id", "expiry"] ) +""" +When a token is renewed by either :class:`durin.views.LoginView` +or :class:`durin.views.RefreshView`. + + providing_args=["username", "token_id", "expiry"] +""" diff --git a/durin/views.py b/durin/views.py index 83d5633..584aa26 100644 --- a/durin/views.py +++ b/durin/views.py @@ -14,16 +14,26 @@ class LoginView(APIView): - """Durin's Login View. - It accepts user credentials (username and password) validates same - and returns token key, expiry and - user fields present in `durin.settings.USER_SERIALIZER`. + """Durin's Login View.\n + This view will return a JSON response when valid ``username``, ``password`` and + if not overwritten ``client`` fields are POSTed to the view using + form data or JSON. + + It uses the default serializer provided by + Django-Rest-Framework (``rest_framework.authtoken.serializers.AuthTokenSerializer``) + to validate the user credentials. + + It is possible to customize LoginView behaviour by overriding the following + helper methods: """ authentication_classes = [] permission_classes = [] def get_context(self): + """ + to change the context passed to the ``UserSerializer``. + """ return {"request": self.request, "format": self.format_kwarg, "view": self} @staticmethod @@ -34,19 +44,57 @@ def validate_and_return_user(request): @staticmethod def get_client_obj(request) -> "Client": + """ + To get and return the associated :class:`durin.models.Client` object. + """ client_name = request.data.get("client", None) if not client_name: raise ParseError("No client specified.", status.HTTP_400_BAD_REQUEST) return Client.objects.get(name=client_name) + @classmethod + def get_token_obj(cls, request, client: "Client") -> "AuthToken": + """ + Flow used to return the :class:`durin.models.AuthToken` object. + """ + try: + # a token for this user and client already exists, so we can just return it + token = AuthToken.objects.get(user=request.user, client=client) + if durin_settings.REFRESH_TOKEN_ON_LOGIN: + cls.renew_token(token) + except ObjectDoesNotExist: + # create new token + token = AuthToken.objects.create(request.user, client) + + return token + + @classmethod + def renew_token(cls, token_obj: "AuthToken") -> None: + """ + How to renew the token instance in case + ``settings.REFRESH_TOKEN_ON_LOGIN`` is set to ``True``. + """ + token_obj.renew_token(renewed_by=cls) + @staticmethod def format_expiry_datetime(expiry): + """ + To format the expiry ``datetime`` object at your convenience. + """ datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT return DateTimeField(format=datetime_format).to_representation(expiry) - def get_post_response_data(self, request, token_obj: "AuthToken"): - UserSerializer = durin_settings.USER_SERIALIZER + def get_user_serializer_class(self): + """ + To change the class used for serializing the user. + """ + return durin_settings.USER_SERIALIZER + def get_post_response_data(self, request, token_obj: "AuthToken"): + """ + Override this to return a fully customized payload. + """ + UserSerializer = self.get_user_serializer_class() data = { "expiry": self.format_expiry_datetime(token_obj.expiry), "token": token_obj.token, @@ -55,23 +103,6 @@ def get_post_response_data(self, request, token_obj: "AuthToken"): data["user"] = UserSerializer(request.user, context=self.get_context()).data return data - @classmethod - def renew_token(cls, token_obj: "AuthToken"): - token_obj.renew_token(renewed_by=cls) - - @classmethod - def get_token_obj(cls, request, client: "Client") -> "AuthToken": - try: - # a token for this user and client already exists, so we can just return it - token = AuthToken.objects.get(user=request.user, client=client) - if durin_settings.REFRESH_TOKEN_ON_LOGIN: - cls.renew_token(token) - except ObjectDoesNotExist: - # create new token - token = AuthToken.objects.create(request.user, client) - - return token - def post(self, request, *args, **kwargs): request.user = self.validate_and_return_user(request) client = self.get_client_obj(request) @@ -84,8 +115,16 @@ def post(self, request, *args, **kwargs): class RefreshView(APIView): - """Durin's Refresh View - Refreshes the token present in request header and returns new expiry + """Durin's Refresh View\n + This view accepts only a post request with an empty body. + It responds to Durin Token Authentication. On a successful request, + + 1. The given token's expiry is extended by it's associated + :py:attr:`durin.models.Client.token_ttl` + duration and a JSON object will be returned containing a single ``expiry`` + key as the new timestamp for when the token expires. + + 2. :meth:`durin.signals.token_renewed` is called. """ authentication_classes = (TokenAuthentication,) @@ -93,6 +132,9 @@ class RefreshView(APIView): @staticmethod def format_expiry_datetime(expiry): + """ + To format the expiry ``datetime`` object at your convenience. + """ datetime_format = durin_settings.EXPIRY_DATETIME_FORMAT return DateTimeField(format=datetime_format).to_representation(expiry) @@ -104,8 +146,14 @@ def post(self, request, *args, **kwargs): class LogoutView(APIView): - """Durin's Logout View. - Delete's the token present in request header. + """Durin's Logout View.\n + This view accepts only a post request with an empty body. + It responds to Durin Token Authentication. On a successful request, + + 1. The token used to authenticate is deleted from + the database and can no longer be used to authenticate. + + 2. :meth:`django.contrib.auth.signals.user_logged_out` is called. :returns: 204 (No content) """ @@ -122,9 +170,18 @@ def post(self, request, *args, **kwargs): class LogoutAllView(APIView): - """Durin's LogoutAllView - Log the user out of all sessions - I.E. deletes all auth tokens for the user + """Durin's LogoutAllView.\n + This view accepts only a post request with an empty body. It responds to Durin Token + Authentication. + On a successful request, + + 1. The token used to authenticate, and **all other tokens** + registered to the same ``User`` account, are deleted from the + system and can no longer be used to authenticate. + + 2. :meth:`django.contrib.auth.signals.user_logged_out` is called. + + :returns: 204 (No content) """ authentication_classes = (TokenAuthentication,) diff --git a/mkdocs.sh b/mkdocs.sh deleted file mode 100755 index ac64849..0000000 --- a/mkdocs.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -e -MOUNT_FOLDER=/app -MKDOCS_DEV_ADDR=${MKDOCS_DEV_ADDR-"0.0.0.0"} -MKDOCS_DEV_PORT=${MKDOCS_DEV_PORT-"8000"} - -docker run --rm -it \ - -v $(pwd):$MOUNT_FOLDER \ - -w $MOUNT_FOLDER \ - -p $MKDOCS_DEV_PORT:$MKDOCS_DEV_PORT \ - -e MKDOCS_DEV_ADDR="$MKDOCS_DEV_ADDR:$MKDOCS_DEV_PORT" \ - squidfunk/mkdocs-material:3.2.0 $* diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index d69b2c0..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,14 +0,0 @@ -site_name: Django-Rest-Durin -repo_url: https://github.com/eshaan7/django-rest-durin -theme: readthedocs -nav: - - Home: 'index.md' - - Installation: 'installation.md' - - API Guide: - - Views: 'views.md' - - URLs: 'urls.md' - - Authentication: 'auth.md' - - Settings: 'settings.md' - - Changelog: 'changelog.md' - -dev_addr: !!python/object/apply:os.getenv ["MKDOCS_DEV_ADDR"] diff --git a/requirements.dev.txt b/requirements.dev.txt index 6962424..970e15b 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,10 +1,9 @@ +django>=2.2 djangorestframework>=3.7.0 humanize -mkdocs flake8 django-nose coverage django-memoize isort -markdown<3.0 pytest-django \ No newline at end of file diff --git a/tests/tests.py b/tests/tests.py index d06d6de..39d7647 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -28,6 +28,7 @@ class AuthTestCase(APITestCase): def setUp(self): + self.authclient = Client.objects.create(name="authclientfortest") username = "john.doe" email = "john.doe@example.com" password = "hunter2" @@ -35,7 +36,7 @@ def setUp(self): self.creds = { "username": username, "password": password, - "client": "authclientfortest", + "client": self.authclient.name, } username2 = "jane.doe" @@ -45,11 +46,10 @@ def setUp(self): self.creds2 = { "username": username2, "password": password2, - "client": "authclientfortest", + "client": self.authclient.name, } self.client_names = ["web", "mobile", "cli"] - self.authclient = Client.objects.create(name="authclientfortest") def test_create_clients(self): self.assertEqual(Client.objects.count(), 1) @@ -291,7 +291,7 @@ def test_login_same_token_existing_client(self): "login should return existing token", ) - def test_login_should_renew_token_for_existing_client(self): + def test_login_renew_token_existing_client(self): self.assertEqual(AuthToken.objects.count(), 0) new_settings["REFRESH_TOKEN_ON_LOGIN"] = True with override_settings(REST_DURIN=new_settings): @@ -299,7 +299,6 @@ def test_login_should_renew_token_for_existing_client(self): resp1 = self.client.post(login_url, self.creds, format="json") self.assertEqual(resp1.status_code, 200) self.assertIn("token", resp1.data) - self.assertEqual(AuthToken.objects.count(), 1) resp2 = self.client.post(login_url, self.creds, format="json") self.assertEqual(resp2.status_code, 200) self.assertIn("token", resp2.data)