diff --git a/.travis.yml b/.travis.yml index 34d9022..68188b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "2.7" + - "3.6" # command to install dependencies install: - "pip install -r requirements.txt" diff --git a/cdu/settings.py b/cdu/settings.py index 0803bd6..a7839b7 100644 --- a/cdu/settings.py +++ b/cdu/settings.py @@ -29,8 +29,7 @@ ALLOWED_HOSTS = [] AUTHENTICATION_BACKENDS = [ - 'django_warrant.backend.CognitoBackend', - 'django.contrib.auth.backends.ModelBackend' + 'django_warrant.backend.CognitoNoModelBackend' ] COGNITO_TEST_USERNAME = env('COGNITO_TEST_USERNAME') @@ -39,14 +38,25 @@ COGNITO_USER_POOL_ID = env('COGNITO_USER_POOL_ID') +COGNITO_ADMIN_GROUP = env('COGNITO_ADMIN_GROUP','Admins') + COGNITO_APP_ID = env('COGNITO_APP_ID') +COGNITO_CLIENT_SECRET = env('COGNITO_CLIENT_SECRET') + COGNITO_ATTR_MAPPING = env( 'COGNITO_ATTR_MAPPING', { 'email': 'email', 'given_name': 'first_name', 'family_name': 'last_name', + 'name':'name', + 'username':'username', + 'address':'address', + 'gender':'gender', + 'preferred_username':'preferred_username', + 'phone_number':'phone_number', + 'phone_number_verified':'phone_number_verified', 'custom:api_key': 'api_key', 'custom:api_key_id': 'api_key_id' }, @@ -75,7 +85,7 @@ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_warrant.middleware.CognitoAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -132,8 +142,8 @@ }, ] -LOGIN_REDIRECT_URL = '/accounts/profile' - +LOGIN_REDIRECT_URL = '/' +LOGIN_URL = '/login/' # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ @@ -154,3 +164,5 @@ STATIC_URL = '/static/' CRISPY_TEMPLATE_PACK = 'bootstrap3' + +# AUTH_USER_MODEL = 'django_warrant.UserObj' \ No newline at end of file diff --git a/cdu/urls.py b/cdu/urls.py index 9414425..9dc1bdb 100644 --- a/cdu/urls.py +++ b/cdu/urls.py @@ -18,5 +18,5 @@ urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^accounts/', include('django_warrant.urls',namespace='dw')) + url(r'^', include('django_warrant.urls',namespace='dw')) ] diff --git a/django_warrant/__init__.py b/django_warrant/__init__.py index 1945ad5..39a4597 100644 --- a/django_warrant/__init__.py +++ b/django_warrant/__init__.py @@ -6,7 +6,8 @@ def add_user_tokens(sender, user, **kwargs): """ Add Cognito tokens to the session upon login """ - if user.backend == 'django_warrant.backend.CognitoBackend': + if user.backend in ['django_warrant.backend.CognitoBackend', + 'django_warrant.backend.CognitoNoModelBackend']: request = kwargs['request'] request.session['ACCESS_TOKEN'] = user.access_token request.session['ID_TOKEN'] = user.id_token diff --git a/django_warrant/backend.py b/django_warrant/backend.py index 114aef5..16ec4d7 100644 --- a/django_warrant/backend.py +++ b/django_warrant/backend.py @@ -6,48 +6,13 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend from django.contrib.auth import get_user_model -from django.utils.six import iteritems +from django.utils.crypto import salted_hmac +from warrant_lite import WarrantLite -from warrant import Cognito +from django_warrant.models import UserObj from .utils import cognito_to_dict -class CognitoUser(Cognito): - user_class = get_user_model() - # Mapping of Cognito User attribute name to Django User attribute name - COGNITO_ATTR_MAPPING = getattr(settings, 'COGNITO_ATTR_MAPPING', - { - 'email': 'email', - 'given_name': 'first_name', - 'family_name': 'last_name', - } - ) - - def get_user_obj(self,username=None,attribute_list=[],metadata={},attr_map={}): - user_attrs = cognito_to_dict(attribute_list,CognitoUser.COGNITO_ATTR_MAPPING) - django_fields = [f.name for f in CognitoUser.user_class._meta.get_fields()] - extra_attrs = {} - for k, v in user_attrs.items(): - if k not in django_fields: - extra_attrs.update({k: user_attrs.pop(k, None)}) - if getattr(settings, 'COGNITO_CREATE_UNKNOWN_USERS', True): - user, created = CognitoUser.user_class.objects.update_or_create( - username=username, - defaults=user_attrs) - else: - try: - user = CognitoUser.user_class.objects.get(username=username) - for k, v in iteritems(user_attrs): - setattr(user, k, v) - user.save() - except CognitoUser.user_class.DoesNotExist: - user = None - if user: - for k, v in extra_attrs.items(): - setattr(user, k, v) - return user - - class AbstractCognitoBackend(ModelBackend): __metaclass__ = abc.ABCMeta @@ -55,7 +20,6 @@ class AbstractCognitoBackend(ModelBackend): USER_NOT_FOUND_ERROR_CODE = 'UserNotFoundException' - COGNITO_USER_CLASS = CognitoUser @abc.abstractmethod def authenticate(self, username=None, password=None): @@ -65,24 +29,58 @@ def authenticate(self, username=None, password=None): :param password: Cognito password :return: returns User instance of AUTH_USER_MODEL or None """ - cognito_user = CognitoUser( - settings.COGNITO_USER_POOL_ID, - settings.COGNITO_APP_ID, - access_key=getattr(settings, 'AWS_ACCESS_KEY_ID', None), - secret_key=getattr(settings, 'AWS_SECRET_ACCESS_KEY', None), - username=username) + wl = WarrantLite(username=username, password=password, + pool_id=settings.COGNITO_USER_POOL_ID, + client_id=settings.COGNITO_APP_ID, + client_secret=settings.COGNITO_CLIENT_SECRET) + try: - cognito_user.authenticate(password) + tokens = wl.authenticate_user() except (Boto3Error, ClientError) as e: return self.handle_error_response(e) - user = cognito_user.get_user() + + access_token = tokens['AuthenticationResult']['AccessToken'] + refresh_token = tokens['AuthenticationResult']['RefreshToken'] + id_token = tokens['AuthenticationResult']['IdToken'] + wl.verify_token(access_token, 'access_token', 'access') + wl.verify_token(id_token, 'id_token', 'id') + + cognito_user = wl.client.get_user( + AccessToken=access_token + ) + user = self.get_user_obj(username,cognito_user) if user: - user.access_token = cognito_user.access_token - user.id_token = cognito_user.id_token - user.refresh_token = cognito_user.refresh_token + user.access_token = access_token + user.id_token = id_token + user.refresh_token = refresh_token return user + def get_user_obj(self,username,cognito_user): + user_attrs = cognito_to_dict(cognito_user.get('UserAttributes'), + settings.COGNITO_ATTR_MAPPING or { + 'email':'email', + 'given_name':'first_name', + 'family_name':'last_name' + }) + User = get_user_model() + django_fields = [f.name for f in User._meta.get_fields()] + extra_attrs = {} + new_user_attrs = user_attrs.copy() + + for k, v in user_attrs.items(): + if k not in django_fields: + extra_attrs.update({k: new_user_attrs.pop(k, None)}) + user_attrs = new_user_attrs + try: + u = User.objects.get(username=username) + except User.DoesNotExist: + u = None + if u: + for k, v in extra_attrs.items(): + setattr(u, k, v) + return u + def handle_error_response(self, error): error_code = error.response['Error']['Code'] if error_code in [ @@ -107,3 +105,56 @@ def authenticate(self, request, username=None, password=None): request.session['REFRESH_TOKEN'] = user.refresh_token request.session.save() return user + + +class CognitoNoModelBackend(ModelBackend): + + def authenticate(self, request, username=None, password=None): + wl = WarrantLite(username=username, password=password, + pool_id=settings.COGNITO_USER_POOL_ID, + client_id=settings.COGNITO_APP_ID, + client_secret=settings.COGNITO_CLIENT_SECRET) + + try: + tokens = wl.authenticate_user() + except (Boto3Error, ClientError) as e: + return self.handle_error_response(e) + + access_token = tokens['AuthenticationResult']['AccessToken'] + refresh_token = tokens['AuthenticationResult']['RefreshToken'] + id_token = tokens['AuthenticationResult']['IdToken'] + wl.verify_token(access_token, 'access_token', 'access') + wl.verify_token(id_token, 'id_token', 'id') + + user = UserObj(wl.client.get_user( + AccessToken=access_token + ),access_token=access_token,is_authenticated=True) + user.refresh_token = refresh_token + user.access_token = access_token + user.id_token = id_token + user.session_auth_hash = get_session_auth_hash(password) + if user: + request.session['ACCESS_TOKEN'] = user.access_token + request.session['ID_TOKEN'] = user.id_token + request.session['REFRESH_TOKEN'] = user.refresh_token + request.session.save() + return user + + def user_can_authenticate(self, user): + """ + Reject users with is_active=False. Custom user models that don't have + that attribute are allowed. + """ + is_active = getattr(user, 'is_active', None) + return is_active or is_active is None + + def get_user(self,user_id): + pass + + +def get_session_auth_hash(password): + """ + Return an HMAC of the password field. + """ + key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash" + return salted_hmac(key_salt, password).hexdigest() \ No newline at end of file diff --git a/django_warrant/forms.py b/django_warrant/forms.py index cfd66c1..3586fde 100644 --- a/django_warrant/forms.py +++ b/django_warrant/forms.py @@ -1,14 +1,19 @@ from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ class ProfileForm(forms.Form): first_name = forms.CharField(max_length=200,required=True) last_name = forms.CharField(max_length=200,required=True) email = forms.EmailField(required=True) - phone_number = forms.CharField(max_length=30,required=True) + phone_number = forms.CharField(max_length=30,required=True,help_text='ex. +14325551212') gender = forms.ChoiceField(choices=(('female','Female'),('male','Male')),required=True) address = forms.CharField(max_length=200,required=True) preferred_username = forms.CharField(max_length=200,required=True) + + +class AdminProfileForm(ProfileForm): api_key = forms.CharField(max_length=200, required=False) api_key_id = forms.CharField(max_length=200, required=False) @@ -19,3 +24,38 @@ class APIKeySubscriptionForm(forms.Form): def __init__(self, plans=[], users_plans=[], *args, **kwargs): self.base_fields['plan'].choices = [(p.get('id'),p.get('name')) for p in plans if not p.get('id') in users_plans] super(APIKeySubscriptionForm, self).__init__(*args, **kwargs) + + +class ForgotPasswordForm(forms.Form): + username = forms.CharField(max_length=200,required=True) + + +class PasswordForm(forms.Form): + password = forms.CharField(widget=forms.PasswordInput, required=True, max_length=200) + confirm_password = forms.CharField(widget=forms.PasswordInput, required=True, max_length=200) + + def clean_confirm_password(self): + password = self.cleaned_data['password'] + confirm_password = self.cleaned_data['confirm_password'] + if password != confirm_password: + raise ValidationError(_('The passwords entered do not match.')) + return confirm_password + + +class VerificationCodeForm(forms.Form): + username = forms.CharField(max_length=200,required=True) + verification_code = forms.CharField(max_length=6) + + +class ConfirmForgotPasswordForm(VerificationCodeForm,PasswordForm): + pass + + +class RegistrationForm(ProfileForm,PasswordForm,ForgotPasswordForm): + pass + + +class UpdatePasswordForm(PasswordForm): + previous_password = forms.CharField(max_length=200, + widget=forms.PasswordInput, + required=True) \ No newline at end of file diff --git a/django_warrant/middleware.py b/django_warrant/middleware.py index 8fb1475..99e09f6 100644 --- a/django_warrant/middleware.py +++ b/django_warrant/middleware.py @@ -1,3 +1,7 @@ +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin +from django_warrant.models import get_user as gu + class APIKeyMiddleware(object): """ @@ -22,3 +26,20 @@ def process_request(request): request.api_key = request.META['HTTP_AUTHORIZATION_ID'] return None + + +def get_user(request): + if not hasattr(request, '_cached_user'): + request._cached_user = gu(request) + return request._cached_user + + +class CognitoAuthenticationMiddleware(MiddlewareMixin): + def process_request(self, request): + assert hasattr(request, 'session'), ( + "The Django authentication middleware requires session middleware " + "to be installed. Edit your MIDDLEWARE%s setting to insert " + "'django.contrib.sessions.middleware.SessionMiddleware' before " + "'django.contrib.auth.middleware.AuthenticationMiddleware'." + ) % ("_CLASSES" if settings.MIDDLEWARE is None else "") + request.user = get_user(request) diff --git a/django_warrant/models.py b/django_warrant/models.py index e69de29..5566e38 100644 --- a/django_warrant/models.py +++ b/django_warrant/models.py @@ -0,0 +1,206 @@ +import datetime + +from django.conf import settings +from jose import jwt + +from django_warrant.utils import cognito_to_dict, dict_to_cognito, cog_client, \ + refresh_access_token, attr_map_inverse + + +class Group(object): + + def __init__(self,attr_dict): + self._data = attr_dict + + def __repr__(self): + return '<{class_name}: {uni}>'.format( + class_name=self.__class__.__name__, uni=self.__unicode__()) + + def __unicode__(self): + return self.username + + def __getattr__(self, name): + if name in list(self.__dict__.get('_data',{}).keys()): + return self._data.get(name) + + +class Meta(object): + + def __init__(self,pk): + self.pk = pk + + +class PK(object): + + def __init__(self,sub): + self.value = sub + + def value_to_string(self,obj): + return self.value + + def to_python(self,obj): + return obj + + +class AnonUserObj(object): + is_authenticated = False + + +class UserObj(object): + + def __init__(self, attribute_list, metadata=None, request=None, + password_hash=None,access_token=None,is_authenticated=False): + """ + :param attribute_list: + :param metadata: Dictionary of User metadata + """ + + self._attr_map = settings.COGNITO_ATTR_MAPPING + self.username = attribute_list['Username'] + self._data = cognito_to_dict( + attribute_list.get('UserAttributes') + or attribute_list.get('Attributes'),self._attr_map) + self.is_authenticated = is_authenticated + self.sub = self._data.pop('sub',None) + self.pk = self.sub + self.id = self.sub + self.email_verified = self._data.pop('email_verified',None) + self.phone_number_verified = self._data.pop('phone_number_verified',None) + self._metadata = {} if metadata is None else metadata + self._cached_groups = None + self._meta = Meta(PK(self._data.get('sub'))) + if request: + self.access_token = request.session['ACCESS_TOKEN'] + self.refresh_token = request.session['REFRESH_TOKEN'] + self.id_token = request.session['ID_TOKEN'] + elif access_token: + self.access_token = access_token + + def __repr__(self): + return '<{class_name}: {uni}>'.format( + class_name=self.__class__.__name__, uni=self.__unicode__()) + + def __unicode__(self): + return self.username + + def __getattr__(self, name): + if name in list(self.__dict__.get('_data',{}).keys()): + return self._data.get(name) + if name in list(self.__dict__.get('_metadata',{}).keys()): + return self._metadata.get(name) + + def __setattr__(self, name, value): + if name in list(attr_map_inverse().keys()): + try: + self._data[name] = value + except TypeError: + self._data = {} + self._data[name] = value + else: + super(UserObj, self).__setattr__(name, value) + + @property + def groups(self): + if not self._cached_groups: + self._cached_groups = [Group(i) for i in + cog_client.admin_list_groups_for_user( + Username=self.username, + UserPoolId=settings.COGNITO_USER_POOL_ID, + ).get('Groups')] + return self._cached_groups + else: + return self._cached_groups + + @property + def group_names(self): + return [i.GroupName for i in self.groups] + + @property + def is_staff(self): + return settings.COGNITO_ADMIN_GROUP in self.group_names + + @property + def is_active(self): + return self._data.get('email') + + def get_session_auth_hash(self): + return self.session_auth_hash + + def check_token(self, renew=True): + """ + Checks the exp attribute of the access_token and either refreshes + the tokens by calling the renew_access_tokens method or does nothing + :param renew: bool indicating whether to refresh on expiration + :return: bool indicating whether access_token has expired + """ + if not self.access_token: + raise AttributeError('Access Token Required to Check Token') + now = datetime.datetime.now() + dec_access_token = jwt.get_unverified_claims(self.access_token) + + if now > datetime.datetime.fromtimestamp(dec_access_token['exp']): + expired = True + if renew: + self.renew_access_token() + else: + expired = False + return expired + + def renew_access_token(self): + """ + Sets a new access token on the User using the refresh token. + """ + auth_params = {'REFRESH_TOKEN': self.refresh_token} + self._add_secret_hash(auth_params, 'SECRET_HASH') + refresh_response = cog_client.initiate_auth( + ClientId=settings.COGNITO_APP_ID, + AuthFlow='REFRESH_TOKEN', + AuthParameters=auth_params, + ) + + self.access_token = refresh_response['AuthenticationResult']['AccessToken'] + self.id_token = refresh_response['AuthenticationResult']['IdToken'] + self.token_type = refresh_response['AuthenticationResult']['TokenType'] + + def save(self,admin=False,create=False,password=None,update_fields=None): + if not create: + if admin: + cog_client.admin_update_user_attributes( + UserPoolId=settings.COGNITO_USER_POOL_ID, + Username=self.username, + UserAttributes=dict_to_cognito(self._data,self._attr_map)) + return + cog_client.update_user_attributes( + UserAttributes=dict_to_cognito(self._data, self._attr_map), + AccessToken=self.access_token + ) + return + else: + cog_client.sign_up( + ClientId=settings.COGNITO_APP_ID, + Username=self.username, + Password=password, + UserAttributes=dict_to_cognito(self._data, self._attr_map) + ) + return + + def delete(self,admin=False): + cog_client.admin_disable_user( + UserPoolId=settings.COGNITO_USER_POOL_ID, + Username=self.username + ) + return + + +def get_user(request): + if not request.session.get('ACCESS_TOKEN'): + return AnonUserObj() + try: + return UserObj(cog_client.get_user( + AccessToken=request.session['ACCESS_TOKEN']), + request=request,is_authenticated=True) + except Exception: + refresh_access_token(request) + return UserObj(cog_client.get_user( + AccessToken=request.session['ACCESS_TOKEN']), + request=request,is_authenticated=True) diff --git a/django_warrant/templates/warrant/admin-list-users.html b/django_warrant/templates/warrant/admin-list-users.html index f778597..f931eb7 100644 --- a/django_warrant/templates/warrant/admin-list-users.html +++ b/django_warrant/templates/warrant/admin-list-users.html @@ -7,6 +7,7 @@ First Name Last Name + {% for obj in object_list %} @@ -14,6 +15,7 @@ {{ obj.first_name }} {{ obj.last_name }} View + Update {% endfor %} diff --git a/django_warrant/templates/warrant/admin-profile.html b/django_warrant/templates/warrant/admin-profile.html new file mode 100644 index 0000000..a9fdf4d --- /dev/null +++ b/django_warrant/templates/warrant/admin-profile.html @@ -0,0 +1,3 @@ +{% extends 'warrant/profile.html' %} + +{% block h1_title %}{{ user.username }}'s Profile{% endblock h1_title %} \ No newline at end of file diff --git a/django_warrant/templates/warrant/admin-update-profile.html b/django_warrant/templates/warrant/admin-update-profile.html new file mode 100644 index 0000000..2f82738 --- /dev/null +++ b/django_warrant/templates/warrant/admin-update-profile.html @@ -0,0 +1,3 @@ +{% extends 'warrant/update-profile.html' %} + +{% block h1_title %}Update {{ user.username }}'s Profile{% endblock h1_title %} \ No newline at end of file diff --git a/django_warrant/templates/warrant/base.html b/django_warrant/templates/warrant/base.html index 10c00ff..9722a78 100644 --- a/django_warrant/templates/warrant/base.html +++ b/django_warrant/templates/warrant/base.html @@ -43,23 +43,37 @@ {% block nav_title %}Warrant{% endblock %} + {% if messages %} +{% endif %} {% block body %} {% block content %}
+ {% for message in messages %} +
+ × + {{message}} +
+{% endfor %} +
+
+

{% block h1_title %}{% endblock h1_title %}

+
+
diff --git a/django_warrant/templates/warrant/confirm-forgot-password.html b/django_warrant/templates/warrant/confirm-forgot-password.html new file mode 100644 index 0000000..3eebcc6 --- /dev/null +++ b/django_warrant/templates/warrant/confirm-forgot-password.html @@ -0,0 +1,3 @@ +{% extends 'warrant/form.html' %} + +{% block h1_title %}Confirm Verification Code{% endblock h1_title %} \ No newline at end of file diff --git a/django_warrant/templates/warrant/forgot-password.html b/django_warrant/templates/warrant/forgot-password.html new file mode 100644 index 0000000..3397fc5 --- /dev/null +++ b/django_warrant/templates/warrant/forgot-password.html @@ -0,0 +1,3 @@ +{% extends 'warrant/form.html' %} + +{% block h1_title %}Forgot Password{% endblock h1_title %} \ No newline at end of file diff --git a/django_warrant/templates/warrant/form.html b/django_warrant/templates/warrant/form.html new file mode 100644 index 0000000..f3e9180 --- /dev/null +++ b/django_warrant/templates/warrant/form.html @@ -0,0 +1,11 @@ +{% extends 'warrant/base.html' %} +{% load crispy_forms_tags %} +{% block h1_title %}Update Your Profile{% endblock h1_title %} +{% block main_content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+ {% block extra_form_info %}{% endblock extra_form_info %} +{% endblock %} \ No newline at end of file diff --git a/django_warrant/templates/warrant/login.html b/django_warrant/templates/warrant/login.html index b54997e..1577187 100644 --- a/django_warrant/templates/warrant/login.html +++ b/django_warrant/templates/warrant/login.html @@ -1,13 +1,6 @@ -{% extends 'warrant/base.html' %} +{% extends 'warrant/form.html' %} {% block title %}Login{% endblock title %} -{% block main_content %} -
-
- {% csrf_token %} - - {{ form.as_table }} -
- -
-
+{% block h1_title %}Login{% endblock %} +{% block extra_form_info %} + Forgot Password | Sign Up {% endblock %} diff --git a/django_warrant/templates/warrant/profile.html b/django_warrant/templates/warrant/profile.html index a33a3a5..c9a3445 100644 --- a/django_warrant/templates/warrant/profile.html +++ b/django_warrant/templates/warrant/profile.html @@ -1,8 +1,8 @@ {% extends 'warrant/base.html' %} -{% load cognito_tags %} +{% block h1_title %}{{ user.username }}{% endblock h1_title %} {% block main_content %} -

{{ user|username }}

+ Update your Profile
First Name:
diff --git a/django_warrant/templates/warrant/registration.html b/django_warrant/templates/warrant/registration.html new file mode 100644 index 0000000..b984030 --- /dev/null +++ b/django_warrant/templates/warrant/registration.html @@ -0,0 +1,3 @@ +{% extends 'warrant/form.html' %} + +{% block h1_title %}Registration{% endblock h1_title %} diff --git a/django_warrant/templates/warrant/subscriptions.html b/django_warrant/templates/warrant/subscriptions.html index 428d6ac..8a91c29 100644 --- a/django_warrant/templates/warrant/subscriptions.html +++ b/django_warrant/templates/warrant/subscriptions.html @@ -1,5 +1,7 @@ {% extends 'warrant/base.html' %} +{% block h1_title %}My Subscriptions{% endblock h1_title %} + {% block main_content %} diff --git a/django_warrant/templates/warrant/update-profile.html b/django_warrant/templates/warrant/update-profile.html index 3ec407f..431cc3e 100644 --- a/django_warrant/templates/warrant/update-profile.html +++ b/django_warrant/templates/warrant/update-profile.html @@ -1,10 +1,3 @@ -{% extends 'warrant/base.html' %} -{% load crispy_forms_tags %} +{% extends 'warrant/form.html' %} -{% block main_content %} - - {% csrf_token %} - {{ form|crispy }} - - -{% endblock %} \ No newline at end of file +{% block h1_title %}Update Your Profile{% endblock h1_title %} diff --git a/django_warrant/templatetags/__init__.py b/django_warrant/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_warrant/templatetags/cognito_tags.py b/django_warrant/templatetags/cognito_tags.py deleted file mode 100644 index 9f30226..0000000 --- a/django_warrant/templatetags/cognito_tags.py +++ /dev/null @@ -1,7 +0,0 @@ -from django import template - -register = template.Library() - -@register.filter('username') -def username(user): - return user._metadata.get('username') diff --git a/django_warrant/tests.py b/django_warrant/tests.py index 5a58b51..87e7f52 100644 --- a/django_warrant/tests.py +++ b/django_warrant/tests.py @@ -15,15 +15,51 @@ from django.test.client import RequestFactory from django.utils.six import iteritems -from django_warrant.backend import CognitoBackend, CognitoUser +from django_warrant.backend import CognitoBackend from django_warrant.middleware import APIKeyMiddleware -from warrant import Cognito + def set_tokens(cls, *args, **kwargs): cls.access_token = 'accesstoken' cls.id_token = 'idtoken' cls.refresh_token = 'refreshtoken' +def authenticate_user(cls,*args, **kwargs): + return { + 'ChallengeParameters': {}, + 'AuthenticationResult': { + 'AccessToken': 'fake.access.token', + 'ExpiresIn': 3600, + 'TokenType': 'Bearer', + 'RefreshToken': 'fake.refresh.token', + 'IdToken': 'fake.id.token'}, + 'ResponseMetadata': { + 'RequestId': 'abc123-1234-4567-789-9101112', + 'HTTPStatusCode': 200, + 'HTTPHeaders': { + 'date': 'Thu, 10 May 2018 15:23:12 GMT', + 'content-type': 'application/x-amz-json-1.1', + 'content-length': '4056', + 'connection': 'keep-alive', + 'x-amzn-requestid': 'abc123-f233-sfsdf-k342334'}, + 'RetryAttempts': 0} + } + +def verify_tokens(cls,*args, **kwargs): + return { + 'sub': 'asfadfadf-3323sd-sdt4-adf22-5dfgdfsaddf', + 'event_id': 'sdf44sdsd-3234-9540-8a21-234sdfdsff', + 'token_use': 'access', + 'scope': 'aws.cognito.signin.user.admin', + 'auth_time': 1525966559, + 'iss': 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Bgbf9cyLt', + 'exp': 1525970159, + 'iat': 1525966559, + 'jti': '23ssdfsdf-tt44-5678-9fgv-345dfgdfgfdfgg', + 'client_id': '7dsfsdfdsfdfgkdfkkd', + 'username': 'fakeusername' + } + def get_user(cls, *args, **kwargs): user = { 'user_status': kwargs.pop('user_status', 'CONFIRMED'), diff --git a/django_warrant/urls.py b/django_warrant/urls.py index 25d7fd8..88d1da7 100644 --- a/django_warrant/urls.py +++ b/django_warrant/urls.py @@ -2,17 +2,25 @@ from django.contrib.auth import views as auth_views from django.urls import re_path -from .views import ProfileView,UpdateProfileView,MySubsriptions,\ - AdminListUsers,AdminSubscriptions,LogoutView +from django_warrant.views import SubscriptionsView, AdminProfileView, ForgotPasswordView, ConfirmForgotPasswordView, \ + RegistrationView, ConfirmRegistrationView, UpdatePasswordView +from .views import ProfileView,UpdateProfileView,\ + AdminListUsers,LogoutView,AdminUpdateProfileView app_name = 'dw' urlpatterns = ( re_path(r'^login/$', auth_views.login, {'template_name': 'warrant/login.html'}, name='login'), re_path(r'^logout/$', LogoutView.as_view(), {'template_name': 'warrant/logout.html'}, name='logout'), - re_path(r'^profile/$', ProfileView.as_view(),name='profile'), + re_path(r'^forgot-password/$', ForgotPasswordView.as_view(), name='forgot-password'), + re_path(r'^register/$', RegistrationView.as_view(), name='register'), + re_path(r'^confirm-register/$', ConfirmRegistrationView.as_view(), name='confirm-register'), + re_path(r'^update-password/$', UpdatePasswordView.as_view(), name='update-password'), + re_path(r'^confirm-forgot-password/$', ConfirmForgotPasswordView.as_view(), name='confirm-forgot-password'), + re_path(r'^$', ProfileView.as_view(),name='profile'), + re_path(r'^subscriptions/$', SubscriptionsView.as_view(),name='subscriptions'), re_path(r'^profile/update/$', UpdateProfileView.as_view(),name='update-profile'), - re_path(r'^profile/subscriptions/$', MySubsriptions.as_view(),name='subscriptions'), re_path(r'^admin/cognito-users/$', AdminListUsers.as_view(),name='admin-cognito-users'), - re_path(r'^admin/cognito-users/(?P[-\w]+)$', AdminSubscriptions.as_view(),name='admin-cognito-user') + re_path(r'^admin/cognito-users/(?P[-\w]+)/$', AdminProfileView.as_view(),name='admin-cognito-user'), + re_path(r'^admin/cognito-users/(?P[-\w]+)/update/$', AdminUpdateProfileView.as_view(),name='admin-cognito-update-user') ) \ No newline at end of file diff --git a/django_warrant/utils.py b/django_warrant/utils.py index d4f5e29..0c1d072 100644 --- a/django_warrant/utils.py +++ b/django_warrant/utils.py @@ -1,5 +1,10 @@ +import boto3 from django.conf import settings -from warrant import Cognito +from warrant_lite import WarrantLite + + +apigw_client = boto3.client('apigateway') +cog_client = boto3.client('cognito-idp') def cognito_to_dict(attr_list,mapping): @@ -11,6 +16,23 @@ def cognito_to_dict(attr_list,mapping): user_attrs[name] = value return user_attrs + +def dict_to_cognito(attr_dict,mapping): + cognito_list = list() + inv_map = {v: k for k, v in mapping.items()} + for k,v in attr_dict.items(): + name = inv_map.get(k) + cognito_list.append({'Name':name,'Value':v}) + return cognito_list + + +def attr_map_inverse(): + attr_dict = settings.COGNITO_ATTR_MAPPING.copy() + attr_dict.pop('username') + attr_dict.pop('phone_number_verified') + return {v: k for k, v in attr_dict.items()} + + def user_obj_to_django(user_obj): c_attrs = settings.COGNITO_ATTR_MAPPING user_attrs = dict() @@ -20,17 +42,29 @@ def user_obj_to_django(user_obj): user_attrs[dk] = v return user_attrs -def get_cognito(request): - c = Cognito(settings.COGNITO_USER_POOL_ID,settings.COGNITO_APP_ID, - access_token=request.session.get('ACCESS_TOKEN'), - id_token=request.session.get('ID_TOKEN'), - refresh_token=request.session.get('REFRESH_TOKEN')) - changed = c.check_token() - if changed: - request.session['ACCESS_TOKEN'] = c.access_token - request.session['REFRESH_TOKEN'] = c.refresh_token - request.session['ID_TOKEN'] = c.id_token - request.session.save() - return c +def refresh_access_token(request): + """ + Sets a new access token on the User using the refresh token. + """ + refresh_token = request.session['REFRESH_TOKEN'] + auth_params = {'REFRESH_TOKEN': refresh_token} + if settings.COGNITO_CLIENT_SECRET: + username = request.user.username + auth_params['SECRET_HASH'] = WarrantLite.get_secret_hash(username, + settings.COGNITO_APP_ID, + settings.COGNITO_CLIENT_SECRET) + refresh_response = cog_client.initiate_auth( + ClientId=settings.COGNITO_APP_ID, + AuthFlow='REFRESH_TOKEN', + AuthParameters=auth_params, + ) + request.session['ACCESS_TOKEN'] = refresh_response['AuthenticationResult']['AccessToken'] + request.session['ID_TOKEN'] = refresh_response['AuthenticationResult']['IdToken'] + request.session.save() + return { + 'access_token': refresh_response['AuthenticationResult']['AccessToken'], + 'id_token': refresh_response['AuthenticationResult']['IdToken'], + 'token_type': refresh_response['AuthenticationResult']['TokenType'] + } \ No newline at end of file diff --git a/django_warrant/views/__init__.py b/django_warrant/views/__init__.py index f939f4c..60020ae 100644 --- a/django_warrant/views/__init__.py +++ b/django_warrant/views/__init__.py @@ -1,2 +1,3 @@ +from .admin import * from .profile import * from .subscriptions import * \ No newline at end of file diff --git a/django_warrant/views/admin.py b/django_warrant/views/admin.py new file mode 100644 index 0000000..92e08f1 --- /dev/null +++ b/django_warrant/views/admin.py @@ -0,0 +1,63 @@ +from django.contrib import messages +from django.views.generic import FormView, TemplateView + +from django_warrant.forms import AdminProfileForm +from django_warrant.utils import cog_client + +try: + from django.urls import reverse_lazy +except ImportError: + from django.core.urlresolvers import reverse_lazy +from django.views.generic.list import ListView + +from django.conf import settings +from ..models import UserObj +from .mixins import AdminMixin, GetUserMixin + + +class AdminListUsers(AdminMixin,ListView): + template_name = 'warrant/admin-list-users.html' + + def test_func(self): + return self.request.user.is_staff + + def get_queryset(self): + ul = cog_client.list_users(UserPoolId=settings.COGNITO_USER_POOL_ID).get('Users') + response = [UserObj(i) for i in ul] + return response + + +class AdminProfileView(AdminMixin,GetUserMixin,TemplateView): + template_name = 'warrant/admin-profile.html' + + def get_context_data(self, **kwargs): + context = super(AdminProfileView, self).get_context_data(**kwargs) + context['user'] = self.admin_get_user(self.kwargs.get('username')) + return context + + +class AdminUpdateProfileView(AdminMixin,GetUserMixin,FormView): + template_name = 'warrant/admin-update-profile.html' + form_class = AdminProfileForm + + def test_func(self): + return self.request.user.is_staff + + def get_success_url(self): + return reverse_lazy('dw:admin-cognito-users') + + def get_initial(self): + self.user = self.admin_get_user( + self.kwargs.get('username')) + return self.user._data + + def form_valid(self, form): + if not self.user: + self.user = self.admin_get_user( + self.kwargs.get('username')) + self.user._data = form.cleaned_data + self.user.save(admin=True) + messages.success(self.request, + "You have successfully updated {}'s profile.".format( + self.kwargs.get('username'))) + return super(AdminUpdateProfileView, self).form_valid(form) \ No newline at end of file diff --git a/django_warrant/views/mixins.py b/django_warrant/views/mixins.py new file mode 100644 index 0000000..f420219 --- /dev/null +++ b/django_warrant/views/mixins.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin, \ + UserPassesTestMixin, AccessMixin + +from django_warrant.models import UserObj, get_user +from django_warrant.utils import cog_client, apigw_client + + +class AdminMixin(LoginRequiredMixin,UserPassesTestMixin): + + def test_func(self): + return self.request.user.is_staff + + +class GetUserMixin(object): + + def get_user(self): + return get_user(self.request) + + def admin_get_user(self,username): + return UserObj(cog_client.admin_get_user( + UserPoolId=settings.COGNITO_USER_POOL_ID, + Username=username),is_authenticated=False) + + +class TokenMixin(AccessMixin): + + def dispatch(self, request, *args, **kwargs): + if not request.session.get('REFRESH_TOKEN'): + return self.handle_no_permission() + return super(TokenMixin, self).dispatch( + request, *args, **kwargs) + + +class GetSubscriptionsMixin(GetUserMixin): + + def get_subscriptions(self,api_key_id): + return apigw_client.get_usage_plans( + keyId=api_key_id).get('items', []) + + def get_user_subscriptions(self): + return self.get_subscriptions(self.get_user().api_key_id) + + def get_admin_subscriptions(self): + return self.get_subscriptions(self.admin_get_user( + self.kwargs.get('username')).api_key_id) \ No newline at end of file diff --git a/django_warrant/views/profile.py b/django_warrant/views/profile.py index 53ab205..f445249 100644 --- a/django_warrant/views/profile.py +++ b/django_warrant/views/profile.py @@ -1,7 +1,14 @@ -from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin +from botocore.exceptions import ClientError + +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ from django.views.decorators.cache import never_cache +from django_warrant.utils import cog_client, dict_to_cognito, apigw_client +from django_warrant.views.mixins import GetUserMixin, TokenMixin + try: from django.urls import reverse_lazy except ImportError: @@ -9,25 +16,10 @@ from django.views.generic import FormView, TemplateView from django.contrib import messages from django.contrib.auth.views import LogoutView as DJLogoutView -from django.conf import settings - -from django_warrant.utils import get_cognito -from django_warrant.forms import ProfileForm - - -class TokenMixin(AccessMixin): - - def dispatch(self, request, *args, **kwargs): - if not request.session.get('REFRESH_TOKEN'): - return self.handle_no_permission() - return super(TokenMixin, self).dispatch( - request, *args, **kwargs) -class GetUserMixin(object): - def get_user(self): - c = get_cognito(self.request) - return c.get_user(attr_map=settings.COGNITO_ATTR_MAPPING) +from django_warrant.forms import ProfileForm, ForgotPasswordForm, ConfirmForgotPasswordForm, RegistrationForm, \ + VerificationCodeForm, UpdatePasswordForm class ProfileView(LoginRequiredMixin,TokenMixin,GetUserMixin,TemplateView): @@ -47,13 +39,15 @@ def get_success_url(self): return reverse_lazy('dw:profile') def get_initial(self): - u = self.get_user() - return u.__dict__.get('_data') + self.user = self.get_user() + return self.user._data def form_valid(self, form): - c = get_cognito(self.request) - c.update_profile(form.cleaned_data,settings.COGNITO_ATTR_MAPPING) - messages.success(self.request,'You have successfully updated your profile.') + if not self.user: + self.user = self.get_user() + self.user._data = form.cleaned_data + self.user.save() + messages.success(self.request,_('You have successfully updated your profile.')) return super(UpdateProfileView, self).form_valid(form) @@ -64,3 +58,124 @@ def dispatch(self, request, *args, **kwargs): request.session.delete() return super(LogoutView, self).dispatch(request, *args, **kwargs) + +class CognitoFormView(FormView): + success_message = None + client_error_field = None + + def get_success_message(self,resp): + return self.success_message + + def cognito_command(self,form): + return {} + + def extra_command(self,form): + pass + + def form_valid(self, form): + try: + resp = self.cognito_command(form) + self.extra_command(form) + messages.success(self.request,_(self.get_success_message(resp))) + return super(CognitoFormView, self).form_valid(form) + except ClientError as e: + form.add_error(self.client_error_field, e.response['Error']['Message']) + return self.form_invalid(form) + + +class ForgotPasswordView(CognitoFormView): + template_name = 'warrant/forgot-password.html' + form_class = ForgotPasswordForm + success_url = reverse_lazy('dw:confirm-forgot-password') + success_message = 'Confirmation code delivered to {} by {}' + client_error_field = 'username' + + def cognito_command(self,form): + return cog_client.forgot_password( + ClientId=settings.COGNITO_APP_ID, + Username=form.cleaned_data['username'] + ) + + def get_success_message(self,resp): + return _(self.success_message.format( + resp['CodeDeliveryDetails']['Destination'], + resp['CodeDeliveryDetails']['DeliveryMedium'] + )) + + +class ConfirmForgotPasswordView(CognitoFormView): + template_name = 'warrant/confirm-forgot-password.html' + form_class = ConfirmForgotPasswordForm + success_url = reverse_lazy('dw:profile') + success_message = 'You have successfully changed your password.' + + def cognito_command(self,form): + return cog_client.confirm_forgot_password( + ClientId=settings.COGNITO_APP_ID, + Username=form.cleaned_data['username'], + ConfirmationCode=form.cleaned_data['verification_code'], + Password=form.cleaned_data['password'] + ) + + +class RegistrationView(CognitoFormView): + template_name = 'warrant/registration.html' + form_class = RegistrationForm + success_message = 'Confirmation code delivered to {} by {}' + success_url = reverse_lazy('dw:confirm-register') + def get_success_message(self,resp): + return _(self.success_message.format( + resp['CodeDeliveryDetails']['Destination'], + resp['CodeDeliveryDetails']['DeliveryMedium'] + )) + + def cognito_command(self,form): + cv = form.cleaned_data.copy() + cv.pop('confirm_password') + cv['name'] = '{} {}'.format(cv['first_name'],cv['last_name']) + return cog_client.sign_up( + ClientId=settings.COGNITO_APP_ID, + Username=cv.pop('username'), + Password=cv.pop('password'), + UserAttributes=dict_to_cognito(cv, + settings.COGNITO_ATTR_MAPPING) + ) + + +class ConfirmRegistrationView(GetUserMixin,CognitoFormView): + template_name = 'warrant/registration.html' + form_class = VerificationCodeForm + success_message = 'You have successfully registered.' + success_url = reverse_lazy('dw:profile') + + def cognito_command(self,form): + return cog_client.confirm_sign_up( + ClientId=settings.COGNITO_APP_ID, + Username=form.cleaned_data['username'], + ConfirmationCode=form.cleaned_data['verification_code'] + ) + + def extra_command(self,form): + username = form.cleaned_data['username'] + resp = apigw_client.create_api_key( + name=username, + description='Created by during registration by django-warrant' + ) + u = self.admin_get_user(username) + u.api_key = resp['value'] + u.api_key_id = resp['id'] + u.save(admin=True) + + +class UpdatePasswordView(LoginRequiredMixin,TokenMixin,CognitoFormView): + template_name = 'warrant/update-profile.html' + form_class = UpdatePasswordForm + success_message = 'You have successfully changed your password.' + success_url = reverse_lazy('dw:profile') + + def cognito_command(self,form): + return cog_client.change_password( + PreviousPassword=form.cleaned_data['previous_password'], + ProposedPassword=form.cleaned_data['proposed_password'], + AccessToken=self.request.session['ACCESS_TOKEN'] + ) \ No newline at end of file diff --git a/django_warrant/views/subscriptions.py b/django_warrant/views/subscriptions.py index 7c98a83..b0b165e 100644 --- a/django_warrant/views/subscriptions.py +++ b/django_warrant/views/subscriptions.py @@ -1,95 +1,11 @@ -import boto3 -from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import ListView -from django.contrib.auth.mixins import LoginRequiredMixin, \ - UserPassesTestMixin -from django.http import Http404 -try: - from django.urls import reverse_lazy -except ImportError: - from django.core.urlresolvers import reverse_lazy -from django.views.generic import FormView -from django.views.generic.list import MultipleObjectMixin, ListView +from django_warrant.views.mixins import GetSubscriptionsMixin, TokenMixin -from django.conf import settings -from warrant import UserObj, Cognito -from django_warrant.forms import APIKeySubscriptionForm - -class GetCognitoUserMixin(object): - client = boto3.client('apigateway') - - def get_user_object(self): - cog_client = boto3.client('cognito-idp') - user = cog_client.get_user( - AccessToken=self.request.session['ACCESS_TOKEN']) - u = UserObj(username=user.get('UserAttributes')[0].get('username'), - attribute_list=user.get('UserAttributes'), - attr_map=settings.COGNITO_ATTR_MAPPING) - return u - - def get_queryset(self): - try: - u = self.get_user_object() - except KeyError: - raise Http404 - my_plans = self.client.get_usage_plans(keyId=u.api_key_id) - return my_plans.get('items',[]) - - -class MySubsriptions(LoginRequiredMixin,GetCognitoUserMixin,ListView): +class SubscriptionsView(LoginRequiredMixin,TokenMixin,GetSubscriptionsMixin,ListView): template_name = 'warrant/subscriptions.html' - -class AdminListUsers(UserPassesTestMixin,ListView): - template_name = 'warrant/admin-list-users.html' - - def test_func(self): - return self.request.user.is_staff - def get_queryset(self): - response = Cognito(settings.COGNITO_USER_POOL_ID,settings.COGNITO_APP_ID)\ - .get_users(attr_map=settings.COGNITO_ATTR_MAPPING) - return response - - -class AdminSubscriptions(UserPassesTestMixin,GetCognitoUserMixin, - FormView): - template_name = 'warrant/admin-subscriptions.html' - form_class = APIKeySubscriptionForm - - def get_success_url(self): - return reverse_lazy('dw:admin-cognito-user', - args=[self.kwargs.get('username')]) - - def test_func(self): - return self.request.user.has_perm('can_edit') - - def get_user_object(self): - return Cognito(settings.COGNITO_USER_POOL_ID,settings.COGNITO_APP_ID, - username=self.kwargs.get('username')).admin_get_user( - attr_map=settings.COGNITO_ATTR_MAPPING) - - def get_context_data(self, **kwargs): - kwargs['object_list'] = self.object_list = self.get_queryset() - context = super(AdminSubscriptions, self).get_context_data(**kwargs) - return context - - def get_form_kwargs(self): - kwargs = super(AdminSubscriptions, self).get_form_kwargs() - kwargs.update({'plans':self.client.get_usage_plans().get('items',[]), - 'users_plans':[p.get('id') for p in self.get_queryset()]}) - return kwargs - - def form_invalid(self, form): - - return super(AdminSubscriptions, self).form_invalid(form) - - def form_valid(self, form): - self.client.create_usage_plan_key( - usagePlanId=form.cleaned_data['plan'], - keyId=self.get_user_object().api_key_id, - keyType='API_KEY' - ) - messages.success(self.request,'Addedd subscription successfully.') - return super(AdminSubscriptions, self).form_valid(form) \ No newline at end of file + return self.get_user_subscriptions() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d65c599..3f60b36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ Django>=2.0 django-braces>=1.11.0 django-crispy-forms>=1.6.1 django-extensions>=1.7.7 -warrant>=0.2.0 +python-jose>=3.0.0 +warrant-lite>=1.0.2 diff --git a/setup.py b/setup.py index e2dc3c2..47ef0e3 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def parse_requirements(filename): long_description=README, classifiers=[ 'Framework :: Django', - 'Framework :: Django :: 1.10', + 'Framework :: Django :: 2.0', "Programming Language :: Python", "Topic :: Software Development :: Libraries :: Python Modules", "Environment :: Web Environment",