├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── cdu ├── __init__.py ├── manage.py ├── settings.py ├── urls.py └── wsgi.py ├── django_warrant ├── README.md ├── __init__.py ├── backend.py ├── forms.py ├── middleware.py ├── migrations │ └── __init__.py ├── models.py ├── templates │ └── warrant │ │ ├── admin-list-users.html │ │ ├── admin-subscriptions.html │ │ ├── base.html │ │ ├── login.html │ │ ├── logout.html │ │ ├── profile.html │ │ ├── subscriptions.html │ │ ├── update-profile.html │ │ └── user_info.html ├── templatetags │ ├── __init__.py │ └── cognito_tags.py ├── tests.py ├── urls.py ├── utils.py └── views │ ├── __init__.py │ ├── profile.py │ └── subscriptions.py ├── requirements.txt └── setup.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.idea/ 6 | # C extensions 7 | *.so 8 | *.ipynb 9 | *.sqlite3 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | # command to install dependencies 5 | install: 6 | - "pip install -r requirements.txt" 7 | # command to run tests 8 | before_script: cd cdu 9 | script: python manage.py test django_warrant 10 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Release History 4 | --------------- 5 | 6 | 0.1.0 (2017-04-26) 7 | +++++++++++++++++++ 8 | 9 | **Features** 10 | 11 | - Initial release 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, MetaMetrics Inc 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include django_warrant/static * 4 | recursive-include django_warrant/templates * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Django Warrant 2 | 3 | ### Install 4 | 5 | `pip install django-warrant` 6 | 7 | ### Django Auth Backend 8 | #### Using the CognitoBackend 9 | 1. In your Django project settings file, add the dotted path of 10 | `CognitoBackend` to your list of `AUTHENTICATION_BACKENDS`. 11 | Keep in mind that Django will attempt to authenticate a user using 12 | each backend listed, in the order listed until successful. 13 | 14 | ```python 15 | AUTHENTICATION_BACKENDS = [ 16 | 'django_warrant.backend.CognitoBackend', 17 | ... 18 | ] 19 | ``` 20 | 2. Set `COGNITO_USER_POOL_ID` and `COGNITO_APP_ID` in your settings file as well. 21 | Your User Pool ID can be found in the Pool Details tab in the AWS console. 22 | Your App ID is found in the Apps tab, listed as "App client id". 23 | 24 | 3. Set `COGNITO_ATTR_MAPPING` in your settings file to a dictionary mapping a 25 | Cognito attribute name to a Django User attribute name. 26 | If your Cognito User Pool has any custom attributes, it is automatically 27 | prefixed with `custom:`. Therefore, you will want to add a mapping to your 28 | mapping dictionary as such `{'custom:custom_attr': 'custom_attr'}`. 29 | Defaults to: 30 | ```python 31 | { 32 | 'email': 'email', 33 | 'given_name': 'first_name', 34 | 'family_name': 'last_name', 35 | } 36 | ``` 37 | 4. Optional - Set `COGNITO_CREATE_UNKNOWN_USERS` to `True` or `False`, depending on if 38 | you wish local Django users to be created upon successful login. If set to `False`, 39 | only existing local Django users are updated. 40 | Defaults to `True`. 41 | 42 | 5. Optional - Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` 43 | to the AWS access keys you would like to use. 44 | Defaults to `None`, which will use the default credentials in your `~/.aws/credentials` file. 45 | 46 | #### CognitoBackend Behavior 47 | Since the username of a Cognito User can never change, 48 | this is used by the backend to match a Cognito User with a local Django 49 | User. 50 | 51 | If a Django user is not found, one is created using the attributes 52 | fetched from Cognito. If an existing Django user is found, their 53 | attributes are updated. 54 | 55 | If the boto3 client comes back with either a `NotAuthorizedException` or 56 | `UserNotFoundException`, then `None` is returned instead of a User. 57 | Otherwise, the exception is raised. 58 | 59 | Upon successful login, the three identity tokens returned from Cognito 60 | (ID token, Refresh token, Access token) are stored in the user's request 61 | session. In Django >= 1.11, this is done directly in the backend class. 62 | Otherwise, this is done via the `user_logged_in` signal. 63 | 64 | Check the cdu directory for an example app with a login and 65 | user details page. 66 | 67 | #### Customizing CognitoBackend Behavior 68 | Setting the Django setting `COGNITO_CREATE_UNKNOWN_USERS` to `False` prevents the backend 69 | from creating a new local Django user and only updates existing users. 70 | 71 | If you create your own backend class that inhereits from `CognitoBackend`, you may 72 | want to also create your own custom `user_logged_in` so that it checks 73 | for the name of your custom class. 74 | 75 | ### API Gateway Integration 76 | 77 | #### API Key Middleware 78 | The `APIKeyMiddleware` checks for a `HTTP_AUTHORIZATION_ID` header 79 | in the request and attaches it to the request object as `api_key`. 80 | 81 | 82 | -------------------------------------------------------------------------------- /cdu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMetricsInc/django-warrant/ad19b9c9aefb9e44f6a01c07d11dc41809f88881/cdu/__init__.py -------------------------------------------------------------------------------- /cdu/manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if __name__ == "__main__": 5 | sys.path.append('../') 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | 24 | -------------------------------------------------------------------------------- /cdu/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cdu project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | from envs import env 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = '@3-8jpe#2yh601ektz8e9=vzo8496n=3w5o#du+%i2^qg%po%g' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | AUTHENTICATION_BACKENDS = [ 32 | 'django_warrant.backend.CognitoBackend', 33 | 'django.contrib.auth.backends.ModelBackend' 34 | ] 35 | 36 | COGNITO_TEST_USERNAME = env('COGNITO_TEST_USERNAME') 37 | 38 | COGNITO_TEST_PASSWORD = env('COGNITO_TEST_PASSWORD') 39 | 40 | COGNITO_USER_POOL_ID = env('COGNITO_USER_POOL_ID') 41 | 42 | COGNITO_APP_ID = env('COGNITO_APP_ID') 43 | 44 | COGNITO_ATTR_MAPPING = env( 45 | 'COGNITO_ATTR_MAPPING', 46 | { 47 | 'email': 'email', 48 | 'given_name': 'first_name', 49 | 'family_name': 'last_name', 50 | 'custom:api_key': 'api_key', 51 | 'custom:api_key_id': 'api_key_id' 52 | }, 53 | var_type='dict') 54 | 55 | AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') 56 | 57 | AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') 58 | 59 | # Application definition 60 | 61 | INSTALLED_APPS = [ 62 | 'django.contrib.admin', 63 | 'django.contrib.auth', 64 | 'django.contrib.contenttypes', 65 | 'django.contrib.sessions', 66 | 'django.contrib.messages', 67 | 'django.contrib.staticfiles', 68 | 'django_warrant', 69 | 'crispy_forms', 70 | 'django_extensions' 71 | ] 72 | 73 | MIDDLEWARE = [ 74 | 'django.middleware.security.SecurityMiddleware', 75 | 'django.contrib.sessions.middleware.SessionMiddleware', 76 | 'django.middleware.common.CommonMiddleware', 77 | 'django.middleware.csrf.CsrfViewMiddleware', 78 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 79 | 'django.contrib.messages.middleware.MessageMiddleware', 80 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 81 | ] 82 | 83 | ROOT_URLCONF = 'cdu.urls' 84 | 85 | TEMPLATES = [ 86 | { 87 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 88 | 'DIRS': [], 89 | 'APP_DIRS': True, 90 | 'OPTIONS': { 91 | 'context_processors': [ 92 | 'django.template.context_processors.debug', 93 | 'django.template.context_processors.request', 94 | 'django.contrib.auth.context_processors.auth', 95 | 'django.contrib.messages.context_processors.messages', 96 | ], 97 | }, 98 | }, 99 | ] 100 | 101 | SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' 102 | 103 | WSGI_APPLICATION = 'cdu.wsgi.application' 104 | 105 | 106 | # Database 107 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 108 | 109 | DATABASES = { 110 | 'default': { 111 | 'ENGINE': 'django.db.backends.sqlite3', 112 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 113 | } 114 | } 115 | 116 | 117 | # Password validation 118 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 119 | 120 | AUTH_PASSWORD_VALIDATORS = [ 121 | { 122 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 123 | }, 124 | { 125 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 126 | }, 127 | { 128 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 129 | }, 130 | { 131 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 132 | }, 133 | ] 134 | 135 | LOGIN_REDIRECT_URL = '/accounts/profile' 136 | 137 | # Internationalization 138 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 139 | 140 | LANGUAGE_CODE = 'en-us' 141 | 142 | TIME_ZONE = 'UTC' 143 | 144 | USE_I18N = True 145 | 146 | USE_L10N = True 147 | 148 | USE_TZ = True 149 | 150 | 151 | # Static files (CSS, JavaScript, Images) 152 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 153 | 154 | STATIC_URL = '/static/' 155 | 156 | CRISPY_TEMPLATE_PACK = 'bootstrap3' 157 | -------------------------------------------------------------------------------- /cdu/urls.py: -------------------------------------------------------------------------------- 1 | """cdu URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | url(r'^accounts/', include('django_warrant.urls',namespace='dw')) 22 | ] 23 | -------------------------------------------------------------------------------- /cdu/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cdu project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /django_warrant/README.md: -------------------------------------------------------------------------------- 1 | # Warrant + Django 2 | 3 | This package contains the following. 4 | 5 | - [Install](#install) 6 | - [Django Auth Backend](#django-auth-backend) 7 | 8 | - [Auth Backend](#django-auth-backend) `warrant.django.backend.CognitoBackend` 9 | - [Using the CognitoBackend](#using-the-cognitobackend) 10 | - [CognitoBackend Behavior](#cognitobackend-behavior) 11 | - [Customizing CognitoBackend Behavior](#customizing-cognitobackend-behavior) 12 | - [Profile Views](#profile-views) 13 | - Profile View 14 | - Update Profile View 15 | - Password Reset View 16 | - User Subscriptions View 17 | - Admin Subscriptions View 18 | 19 | - [API Gateway Integration](#api-gateway-integration) 20 | - [API Key Middleware](#api-key-middleware) `warrant.django.middleware.APIKeyMiddleware` 21 | - [Login view](#login-view) 22 | - [Middlware that adds the auth header to the Django Request object](#api-gateway-middleware) 23 | 24 | ## Install 25 | `pip install django-warrant` 26 | 27 | 28 | ### Django Auth Backend 29 | #### Using the CognitoBackend 30 | 1. In your Django project settings file, add the dotted path of 31 | `CognitoBackend` to your list of `AUTHENTICATION_BACKENDS`. 32 | Keep in mind that Django will attempt to authenticate a user using 33 | each backend listed, in the order listed until successful. 34 | 35 | ```python 36 | AUTHENTICATION_BACKENDS = [ 37 | 'warrant.django.backend.CognitoBackend', 38 | 39 | ] 40 | ``` 41 | 2. Set `COGNITO_USER_POOL_ID` and `COGNITO_APP_ID` in your settings file as well. 42 | Your User Pool ID can be found in the Pool Details tab in the AWS console. 43 | Your App ID is found in the Apps tab, listed as "App client id". 44 | 45 | 3. Set `COGNITO_ATTR_MAPPING` in your settings file to a dictionary mapping a 46 | Cognito attribute name to a Django User attribute name. 47 | If your Cognito User Pool has any custom attributes, it is automatically 48 | prefixed with `custom:`. Therefore, you will want to add a mapping to your 49 | mapping dictionary as such `{'custom:custom_attr': 'custom_attr'}`. 50 | Defaults to: 51 | ```python 52 | { 53 | 'email': 'email', 54 | 'given_name': 'first_name', 55 | 'family_name': 'last_name', 56 | } 57 | ``` 58 | 4. Optional - Set `COGNITO_CREATE_UNKNOWN_USERS` to `True` or `False`, depending on if 59 | you wish local Django users to be created upon successful login. If set to `False`, 60 | only existing local Django users are updated. 61 | Defaults to `True`. 62 | 63 | 5. Optional - Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` 64 | to the AWS access keys you would like to use. 65 | Defaults to `None`, which will use the default credentials in your `~/.aws/credentials` file. 66 | 67 | #### CognitoBackend Behavior 68 | Since the username of a Cognito User can never change, 69 | this is used by the backend to match a Cognito User with a local Django 70 | User. 71 | 72 | If a Django user is not found, one is created using the attributes 73 | fetched from Cognito. If an existing Django user is found, their 74 | attributes are updated. 75 | 76 | If the boto3 client comes back with either a `NotAuthorizedException` or 77 | `UserNotFoundException`, then `None` is returned instead of a User. 78 | Otherwise, the exception is raised. 79 | 80 | Upon successful login, the three identity tokens returned from Cognito 81 | (ID token, Refresh token, Access token) are stored in the user's request 82 | session. In Django >= 1.11, this is done directly in the backend class. 83 | Otherwise, this is done via the `user_logged_in` signal. 84 | 85 | Check the django/demo directory for an example app with a login and 86 | user details page. 87 | 88 | #### Customizing CognitoBackend Behavior 89 | Setting the Django setting `COGNITO_CREATE_UNKNOWN_USERS` to `False` prevents the backend 90 | from creating a new local Django user and only updates existing users. 91 | 92 | If you create your own backend class that inhereits from `CognitoBackend`, you may 93 | want to also create your own custom `user_logged_in` so that it checks 94 | for the name of your custom class. 95 | 96 | ### API Gateway Integration 97 | 98 | #### API Key Middleware 99 | The `APIKeyMiddleware` checks for a `HTTP_AUTHORIZATION_ID` header 100 | in the request and attaches it to the request object as `api_key`. 101 | -------------------------------------------------------------------------------- /django_warrant/__init__.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | from django.contrib.auth.signals import user_logged_in 3 | 4 | 5 | def add_user_tokens(sender, user, **kwargs): 6 | """ 7 | Add Cognito tokens to the session upon login 8 | """ 9 | if user.backend == 'django_warrant.backend.CognitoBackend': 10 | request = kwargs['request'] 11 | request.session['ACCESS_TOKEN'] = user.access_token 12 | request.session['ID_TOKEN'] = user.id_token 13 | request.session['REFRESH_TOKEN'] = user.refresh_token 14 | request.session['API_KEY'] = getattr(user, 'api_key', None) 15 | request.session['API_KEY_ID'] = getattr(user, 'api_key_id', None) 16 | request.session.save() 17 | 18 | # If using Django 1.11 or higher, CognitoUserPoolAuthBackend 19 | # handles storing the tokens in the session. 20 | if DJANGO_VERSION[1] < 11: 21 | user_logged_in.connect(add_user_tokens) 22 | -------------------------------------------------------------------------------- /django_warrant/backend.py: -------------------------------------------------------------------------------- 1 | """Custom Django authentication backend""" 2 | import abc 3 | 4 | from boto3.exceptions import Boto3Error 5 | from botocore.exceptions import ClientError 6 | from django.conf import settings 7 | from django.contrib.auth.backends import ModelBackend 8 | from django.contrib.auth import get_user_model 9 | from django.utils.six import iteritems 10 | 11 | from warrant import Cognito 12 | from .utils import cognito_to_dict 13 | 14 | 15 | class CognitoUser(Cognito): 16 | user_class = get_user_model() 17 | # Mapping of Cognito User attribute name to Django User attribute name 18 | COGNITO_ATTR_MAPPING = getattr(settings, 'COGNITO_ATTR_MAPPING', 19 | { 20 | 'email': 'email', 21 | 'given_name': 'first_name', 22 | 'family_name': 'last_name', 23 | } 24 | ) 25 | 26 | def get_user_obj(self,username=None,attribute_list=[],metadata={},attr_map={}): 27 | user_attrs = cognito_to_dict(attribute_list,CognitoUser.COGNITO_ATTR_MAPPING) 28 | django_fields = [f.name for f in CognitoUser.user_class._meta.get_fields()] 29 | extra_attrs = {} 30 | for k, v in user_attrs.items(): 31 | if k not in django_fields: 32 | extra_attrs.update({k: user_attrs.pop(k, None)}) 33 | if getattr(settings, 'COGNITO_CREATE_UNKNOWN_USERS', True): 34 | user, created = CognitoUser.user_class.objects.update_or_create( 35 | username=username, 36 | defaults=user_attrs) 37 | else: 38 | try: 39 | user = CognitoUser.user_class.objects.get(username=username) 40 | for k, v in iteritems(user_attrs): 41 | setattr(user, k, v) 42 | user.save() 43 | except CognitoUser.user_class.DoesNotExist: 44 | user = None 45 | if user: 46 | for k, v in extra_attrs.items(): 47 | setattr(user, k, v) 48 | return user 49 | 50 | 51 | class AbstractCognitoBackend(ModelBackend): 52 | __metaclass__ = abc.ABCMeta 53 | 54 | UNAUTHORIZED_ERROR_CODE = 'NotAuthorizedException' 55 | 56 | USER_NOT_FOUND_ERROR_CODE = 'UserNotFoundException' 57 | 58 | COGNITO_USER_CLASS = CognitoUser 59 | 60 | @abc.abstractmethod 61 | def authenticate(self, username=None, password=None): 62 | """ 63 | Authenticate a Cognito User 64 | :param username: Cognito username 65 | :param password: Cognito password 66 | :return: returns User instance of AUTH_USER_MODEL or None 67 | """ 68 | cognito_user = CognitoUser( 69 | settings.COGNITO_USER_POOL_ID, 70 | settings.COGNITO_APP_ID, 71 | access_key=getattr(settings, 'AWS_ACCESS_KEY_ID', None), 72 | secret_key=getattr(settings, 'AWS_SECRET_ACCESS_KEY', None), 73 | username=username) 74 | try: 75 | cognito_user.authenticate(password) 76 | except (Boto3Error, ClientError) as e: 77 | return self.handle_error_response(e) 78 | user = cognito_user.get_user() 79 | if user: 80 | user.access_token = cognito_user.access_token 81 | user.id_token = cognito_user.id_token 82 | user.refresh_token = cognito_user.refresh_token 83 | 84 | return user 85 | 86 | def handle_error_response(self, error): 87 | error_code = error.response['Error']['Code'] 88 | if error_code in [ 89 | AbstractCognitoBackend.UNAUTHORIZED_ERROR_CODE, 90 | AbstractCognitoBackend.USER_NOT_FOUND_ERROR_CODE 91 | ]: 92 | return None 93 | raise error 94 | 95 | 96 | class CognitoBackend(AbstractCognitoBackend): 97 | def authenticate(self, request, username=None, password=None): 98 | """ 99 | Authenticate a Cognito User and store an access, ID and 100 | refresh token in the session. 101 | """ 102 | user = super(CognitoBackend, self).authenticate( 103 | username=username, password=password) 104 | if user: 105 | request.session['ACCESS_TOKEN'] = user.access_token 106 | request.session['ID_TOKEN'] = user.id_token 107 | request.session['REFRESH_TOKEN'] = user.refresh_token 108 | request.session.save() 109 | return user 110 | -------------------------------------------------------------------------------- /django_warrant/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class ProfileForm(forms.Form): 5 | first_name = forms.CharField(max_length=200,required=True) 6 | last_name = forms.CharField(max_length=200,required=True) 7 | email = forms.EmailField(required=True) 8 | phone_number = forms.CharField(max_length=30,required=True) 9 | gender = forms.ChoiceField(choices=(('female','Female'),('male','Male')),required=True) 10 | address = forms.CharField(max_length=200,required=True) 11 | preferred_username = forms.CharField(max_length=200,required=True) 12 | api_key = forms.CharField(max_length=200, required=False) 13 | api_key_id = forms.CharField(max_length=200, required=False) 14 | 15 | 16 | class APIKeySubscriptionForm(forms.Form): 17 | plan = forms.ChoiceField(required=True) 18 | 19 | def __init__(self, plans=[], users_plans=[], *args, **kwargs): 20 | self.base_fields['plan'].choices = [(p.get('id'),p.get('name')) for p in plans if not p.get('id') in users_plans] 21 | super(APIKeySubscriptionForm, self).__init__(*args, **kwargs) 22 | -------------------------------------------------------------------------------- /django_warrant/middleware.py: -------------------------------------------------------------------------------- 1 | 2 | class APIKeyMiddleware(object): 3 | """ 4 | A simple middleware to pull the users API key from the headers and 5 | attach it to the request. 6 | 7 | It should be compatible with both old and new style middleware. 8 | """ 9 | 10 | def __init__(self, get_response=None): 11 | self.get_response = get_response 12 | 13 | def __call__(self, request): 14 | self.process_request(request) 15 | response = self.get_response(request) 16 | 17 | return response 18 | 19 | @staticmethod 20 | def process_request(request): 21 | if 'HTTP_AUTHORIZATION_ID' in request.META: 22 | request.api_key = request.META['HTTP_AUTHORIZATION_ID'] 23 | 24 | return None 25 | -------------------------------------------------------------------------------- /django_warrant/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMetricsInc/django-warrant/ad19b9c9aefb9e44f6a01c07d11dc41809f88881/django_warrant/migrations/__init__.py -------------------------------------------------------------------------------- /django_warrant/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMetricsInc/django-warrant/ad19b9c9aefb9e44f6a01c07d11dc41809f88881/django_warrant/models.py -------------------------------------------------------------------------------- /django_warrant/templates/warrant/admin-list-users.html: -------------------------------------------------------------------------------- 1 | {% extends 'warrant/base.html' %} 2 | 3 | {% block main_content %} 4 |
Username | 7 |First Name | 8 |Last Name | 9 |10 | |
---|---|---|---|
{{ obj.username}} | 14 |{{ obj.first_name }} | 15 |{{ obj.last_name }} | 16 |View | 17 |
Plan Name | 8 |Description | 9 | 10 |
---|---|
{{ obj.name }} | 14 |{{ obj.description }} | 15 | 16 |
Plan Name | 8 |Description | 9 | 10 |
---|---|
{{ obj.name }} | 14 |{{ obj.description }} | 15 | 16 |
Username: {{ request.user.username }}
5 |Email: {{ request.user.email }}
6 |First Name: {{ request.user.first_name }}
7 |Last Name: {{ request.user.last_name }}
8 |ID Token: {{ request.session.ID_TOKEN}}
9 |Access Token: {{ request.session.ACCESS_TOKEN }}
10 |Refresh Token: {{ request.session.REFRESH_TOKEN }}
11 |API Key: {{ request.session.API_KEY }}
12 |API Key ID: {{ request.session.API_KEY_ID }}
13 | {% endblock %} -------------------------------------------------------------------------------- /django_warrant/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMetricsInc/django-warrant/ad19b9c9aefb9e44f6a01c07d11dc41809f88881/django_warrant/templatetags/__init__.py -------------------------------------------------------------------------------- /django_warrant/templatetags/cognito_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | @register.filter('username') 6 | def username(user): 7 | return user._metadata.get('username') 8 | -------------------------------------------------------------------------------- /django_warrant/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from mock import patch, MagicMock 4 | from botocore.exceptions import ClientError 5 | from importlib import import_module 6 | from unittest import skipIf 7 | 8 | from django import VERSION as DJANGO_VERSION 9 | from django.contrib.auth.models import AnonymousUser, User 10 | from django.conf import settings 11 | from django.contrib.auth import get_user_model, signals, authenticate as django_authenticate 12 | from django.contrib.sessions.middleware import SessionMiddleware 13 | from django.http import HttpRequest 14 | from django.test import override_settings, TestCase, TransactionTestCase 15 | from django.test.client import RequestFactory 16 | from django.utils.six import iteritems 17 | 18 | from django_warrant.backend import CognitoBackend, CognitoUser 19 | from django_warrant.middleware import APIKeyMiddleware 20 | from warrant import Cognito 21 | 22 | def set_tokens(cls, *args, **kwargs): 23 | cls.access_token = 'accesstoken' 24 | cls.id_token = 'idtoken' 25 | cls.refresh_token = 'refreshtoken' 26 | 27 | def get_user(cls, *args, **kwargs): 28 | user = { 29 | 'user_status': kwargs.pop('user_status', 'CONFIRMED'), 30 | 'username': kwargs.pop('access_token', 'testuser'), 31 | 'email': kwargs.pop('email', 'test@email.com'), 32 | 'given_name': kwargs.pop('given_name', 'FirstName'), 33 | 'family_name': kwargs.pop('family_name', 'LastName'), 34 | 'UserAttributes': 35 | [ 36 | { 37 | "Name": "sub", 38 | "Value": "c7d890f6-eb38-498d-8f85-7a6c4af33d7a" 39 | }, 40 | { 41 | "Name": "email_verified", 42 | "Value": "true" 43 | }, 44 | { 45 | "Name": "gender", 46 | "Value": "male" 47 | }, 48 | { 49 | "Name": "name", 50 | "Value": "FirstName LastName" 51 | }, 52 | { 53 | "Name": "preferred_username", 54 | "Value": "testuser" 55 | }, 56 | { 57 | "Name": "given_name", 58 | "Value": "FirstName" 59 | }, 60 | { 61 | "Name": "family_name", 62 | "Value": "LastName" 63 | }, 64 | { 65 | "Name": "email", 66 | "Value": "test@email.com" 67 | }, 68 | { 69 | "Name": "custom:api_key", 70 | "Value": "abcdefg" 71 | }, 72 | { 73 | "Name": "custom:api_key_id", 74 | "Value": "ab-1234" 75 | } 76 | ] 77 | } 78 | user_metadata = { 79 | 'username': user.get('Username'), 80 | 'id_token': cls.id_token, 81 | 'access_token': cls.access_token, 82 | 'refresh_token': cls.refresh_token, 83 | 'api_key': user.get('custom:api_key', None), 84 | 'api_key_id': user.get('custom:api_key_id', None) 85 | } 86 | 87 | return cls.get_user_obj(username=cls.username, 88 | attribute_list=user.get('UserAttributes'), 89 | metadata=user_metadata) 90 | 91 | 92 | def create_request(): 93 | request = HttpRequest() 94 | engine = import_module(settings.SESSION_ENGINE) 95 | session = engine.SessionStore() 96 | session.save() 97 | request.session = session 98 | 99 | return request 100 | 101 | 102 | def authenticate(username, password): 103 | if DJANGO_VERSION[1] > 10: 104 | request = create_request() 105 | return django_authenticate(request=request, username=username, password=password) 106 | else: 107 | return django_authenticate(username=username, password=password) 108 | 109 | 110 | def login(client, username, password): 111 | if DJANGO_VERSION[1] > 10: 112 | request = create_request() 113 | return client.login(request=request, username=username, password=password) 114 | else: 115 | return client.login(username=username, password=password) 116 | 117 | 118 | class AuthTests(TransactionTestCase): 119 | @patch.object(Cognito, 'authenticate') 120 | @patch.object(Cognito, 'get_user') 121 | def test_user_authentication(self, mock_get_user, mock_authenticate): 122 | Cognito.authenticate = set_tokens 123 | Cognito.get_user = get_user 124 | user = authenticate(username='testuser', 125 | password='password') 126 | 127 | self.assertIsNotNone(user) 128 | 129 | @patch.object(Cognito, 'authenticate') 130 | def test_user_authentication_wrong_password(self, mock_authenticate): 131 | Cognito.authenticate.side_effect = ClientError( 132 | { 133 | 'Error': 134 | { 135 | 'Message': 'Incorrect username or password.', 'Code': 'NotAuthorizedException' 136 | } 137 | }, 138 | 'AdminInitiateAuth') 139 | user = authenticate(username='username', 140 | password='wrongpassword') 141 | 142 | self.assertIsNone(user) 143 | 144 | 145 | @patch.object(Cognito, 'authenticate') 146 | def test_user_authentication_wrong_username(self, mock_authenticate): 147 | Cognito.authenticate.side_effect = ClientError( 148 | { 149 | 'Error': 150 | { 151 | 'Message': 'Incorrect username or password.', 'Code': 'NotAuthorizedException' 152 | } 153 | }, 154 | 'AdminInitiateAuth') 155 | user = authenticate(username='wrongusername', 156 | password='password') 157 | 158 | self.assertIsNone(user) 159 | 160 | @patch.object(Cognito, 'authenticate') 161 | @patch.object(Cognito, 'get_user') 162 | def test_client_login(self, mock_get_user, mock_authenticate): 163 | Cognito.authenticate = set_tokens 164 | Cognito.get_user = get_user 165 | user = login(self.client, username='testuser', 166 | password='password') 167 | self.assertTrue(user) 168 | 169 | @patch.object(Cognito, 'authenticate') 170 | def test_boto_error_raised(self, mock_authenticate): 171 | """ 172 | Check that any error other than NotAuthorizedException is 173 | raised as an exception 174 | """ 175 | Cognito.authenticate.side_effect = ClientError( 176 | { 177 | 'Error': 178 | { 179 | 'Message': 'Generic Error Message.', 'Code': 'SomeError' 180 | } 181 | }, 182 | 'AdminInitiateAuth') 183 | with self.assertRaises(ClientError) as error: 184 | user = authenticate(username='testuser', 185 | password='password') 186 | self.assertEqual(error.exception.response['Error']['Code'], 'SomeError') 187 | 188 | @patch.object(Cognito, 'authenticate') 189 | @patch.object(Cognito, 'get_user') 190 | def test_new_user_created(self, mock_get_user, mock_authenticate): 191 | Cognito.authenticate = set_tokens 192 | Cognito.get_user = get_user 193 | 194 | User = get_user_model() 195 | self.assertEqual(User.objects.count(), 0) 196 | user = authenticate(username='testuser', 197 | password='password') 198 | 199 | self.assertEqual(User.objects.count(), 1) 200 | self.assertEqual(user.username, 'testuser') 201 | 202 | @patch.object(Cognito, 'authenticate') 203 | @patch.object(Cognito, 'get_user') 204 | def test_existing_user_updated(self, mock_get_user, mock_authenticate): 205 | Cognito.authenticate = set_tokens 206 | Cognito.get_user = get_user 207 | 208 | User = get_user_model() 209 | existing_user = User.objects.create(username='testuser', email='None') 210 | user = authenticate(username='testuser', 211 | password='password') 212 | self.assertEqual(user.id, existing_user.id) 213 | self.assertNotEqual(user.email, existing_user.email) 214 | self.assertEqual(User.objects.count(), 1) 215 | 216 | updated_user = User.objects.get(username='testuser') 217 | self.assertEqual(updated_user.email, user.email) 218 | self.assertEqual(updated_user.id, user.id) 219 | 220 | @override_settings(COGNITO_CREATE_UNKNOWN_USERS=False) 221 | @patch.object(Cognito, 'authenticate') 222 | @patch.object(Cognito, 'get_user') 223 | def test_existing_user_updated_disabled_create_unknown_user(self, mock_get_user, mock_authenticate): 224 | Cognito.authenticate = set_tokens 225 | Cognito.get_user = get_user 226 | 227 | User = get_user_model() 228 | existing_user = User.objects.create(username='testuser', email='None') 229 | 230 | user = authenticate(username='testuser', 231 | password='password') 232 | self.assertEqual(user.id, existing_user.id) 233 | self.assertNotEqual(user.email, existing_user) 234 | self.assertEqual(User.objects.count(), 1) 235 | 236 | updated_user = User.objects.get(username='testuser') 237 | self.assertEqual(updated_user.email, user.email) 238 | self.assertEqual(updated_user.id, user.id) 239 | 240 | @override_settings(COGNITO_CREATE_UNKNOWN_USERS=False) 241 | @patch.object(Cognito, 'authenticate') 242 | @patch.object(Cognito, 'get_user') 243 | def test_user_not_found_disabled_create_unknown_user(self, mock_get_user, mock_authenticate): 244 | Cognito.authenticate = set_tokens 245 | Cognito.get_user = get_user 246 | 247 | user = authenticate(username='testuser', 248 | password='password') 249 | 250 | self.assertIsNone(user) 251 | 252 | @skipIf(DJANGO_VERSION[1] > 10, "Signal not used if Django>1.10") 253 | def test_add_user_tokens_signal(self): 254 | User = get_user_model() 255 | user = User.objects.create(username=settings.COGNITO_TEST_USERNAME) 256 | user.access_token = 'access_token_value' 257 | user.id_token = 'id_token_value' 258 | user.refresh_token = 'refresh_token_value' 259 | user.backend = 'warrant.django.backend.CognitoBackend' 260 | user.api_key = 'abcdefg' 261 | user.api_key_id = 'ab-1234' 262 | 263 | request = RequestFactory().get('/login') 264 | middleware = SessionMiddleware() 265 | middleware.process_request(request) 266 | request.session.save() 267 | signals.user_logged_in.send(sender=user.__class__, request=request, user=user) 268 | 269 | self.assertEqual(request.session['ACCESS_TOKEN'], 'access_token_value') 270 | self.assertEqual(request.session['ID_TOKEN'], 'id_token_value') 271 | self.assertEqual(request.session['REFRESH_TOKEN'], 'refresh_token_value') 272 | self.assertEqual(request.session['API_KEY'], 'abcdefg') 273 | self.assertEqual(request.session['API_KEY_ID'], 'ab-1234') 274 | 275 | def test_model_backend(self): 276 | """ 277 | Check that the logged in signal plays nice with other backends 278 | """ 279 | User = get_user_model() 280 | user = User.objects.create(username=settings.COGNITO_TEST_USERNAME) 281 | user.backend = 'django.contrib.auth.backends.ModelBackend' 282 | 283 | request = RequestFactory().get('/login') 284 | middleware = SessionMiddleware() 285 | middleware.process_request(request) 286 | request.session.save() 287 | signals.user_logged_in.send(sender=user.__class__, request=request, user=user) 288 | 289 | 290 | class MiddleWareTests(TestCase): 291 | def setUp(self): 292 | self.factory = RequestFactory() 293 | 294 | def test_header_missing(self): 295 | request = self.factory.get('/does/not/matter') 296 | 297 | request.user = AnonymousUser() 298 | 299 | APIKeyMiddleware.process_request(request) 300 | 301 | # Test that missing headers responds properly 302 | self.assertFalse(hasattr(request, 'api_key')) 303 | 304 | def test_header_transfers(self): 305 | request = self.factory.get('/does/not/matter', HTTP_AUTHORIZATION_ID='testapikey') 306 | 307 | request.user = AnonymousUser() 308 | 309 | APIKeyMiddleware.process_request(request) 310 | 311 | # Now test with proper headers in place 312 | self.assertEqual(request.api_key, 'testapikey') 313 | -------------------------------------------------------------------------------- /django_warrant/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.contrib.auth import views as auth_views 3 | from django.urls import re_path 4 | 5 | from .views import ProfileView,UpdateProfileView,MySubsriptions,\ 6 | AdminListUsers,AdminSubscriptions,LogoutView 7 | 8 | app_name = 'dw' 9 | 10 | urlpatterns = ( 11 | re_path(r'^login/$', auth_views.login, {'template_name': 'warrant/login.html'}, name='login'), 12 | re_path(r'^logout/$', LogoutView.as_view(), {'template_name': 'warrant/logout.html'}, name='logout'), 13 | re_path(r'^profile/$', ProfileView.as_view(),name='profile'), 14 | re_path(r'^profile/update/$', UpdateProfileView.as_view(),name='update-profile'), 15 | re_path(r'^profile/subscriptions/$', MySubsriptions.as_view(),name='subscriptions'), 16 | re_path(r'^admin/cognito-users/$', AdminListUsers.as_view(),name='admin-cognito-users'), 17 | re_path(r'^admin/cognito-users/(?P