From 92b42e365bf1b279b3142c14084374bccecc5f47 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 7 Jul 2015 17:40:07 +1000 Subject: [PATCH 1/2] Update hash method in dddp.accounts to bind hash tokens to specified purposes. --- dddp/accounts/ddp.py | 62 +++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index ab7720a..582f310 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -7,9 +7,11 @@ See http://docs.meteor.com/#/full/accounts_api for details of each method. """ from binascii import Error import collections +import hashlib from ejson import loads, dumps +from django.conf import settings from django.contrib import auth from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth.signals import user_login_failed @@ -28,6 +30,16 @@ forgot_password = Signal(providing_args=['request', 'user', 'token', 'expiry']) password_reset = Signal(providing_args=['request', 'user']) +class HashPurpose(object): + + """HashPurpose enumeration.""" + + PASSWORD_RESET = 'password_reset' + RESUME_LOGIN = 'resume_login' + CHANGE_EMAIL = 'change_email' + CREATE_USER = 'create_user' + + class Users(Collection): """Mimic `users` collection of Meteor's `accounts-password` package.""" @@ -201,36 +213,48 @@ class Auth(APIMixin): ) raise MeteorError(403, 'Authentication failed.') - def validated_user_and_session(self, token): + @staticmethod + def get_auth_hash(user, purpose): + """Generate a user hash for a particular purpose.""" + return hashlib.sha1( + ':'.join([ + settings.SECRET_KEY, + user.get_session_auth_hash(), + purpose, + ]) + ).hexdigest() + + @classmethod + def validated_user_and_session(cls, token, purpose): """Resolve and validate auth token, returns user and session objects.""" try: username, session_key, auth_hash = loads(token.decode('base64')) except (ValueError, Error): - self.auth_failed(token=token) + cls.auth_failed(token=token) try: - user = self.user_model.objects.get(**{ - self.user_model.USERNAME_FIELD: username, + user = cls.user_model.objects.get(**{ + cls.user_model.USERNAME_FIELD: username, }) user.backend = 'django.contrib.auth.backends.ModelBackend' - except self.user_model.DoesNotExist: - self.auth_failed(username=username, token=token) - if user.get_session_auth_hash() != auth_hash: - self.auth_failed(username=username, token=token) + except cls.user_model.DoesNotExist: + cls.auth_failed(username=username, token=token) + if cls.get_auth_hash(user, purpose) != auth_hash: + cls.auth_failed(username=username, token=token) session = SessionStore( session_key=session_key, ) if session.get_expiry_date() <= timezone.now(): - self.auth_failed(username=username, token=token) + cls.auth_failed(username=username, token=token) return (user, session) - @staticmethod - def get_user_token(user, session_key, expiry_date): + @classmethod + def get_user_token(cls, user, session_key, expiry_date, purpose): """Return login token info for given user.""" token = ''.join( dumps([ user.get_username(), session_key, - user.get_session_auth_hash(), + cls.get_auth_hash(user, purpose), ]).encode('base64').split('\n') ) return { @@ -330,6 +354,7 @@ class Auth(APIMixin): user=user, session_key=this.request.session.session_key, expiry_date=this.request.session.get_expiry_date(), + purpose=HashPurpose.CREATE_USER, ) @api_endpoint @@ -367,6 +392,7 @@ class Auth(APIMixin): user=user, session_key=this.request.session.session_key, expiry_date=this.request.session.get_expiry_date(), + purpose=HashPurpose.RESUME_LOGIN, ) # Call to `authenticate` was unable to verify the username and password. @@ -386,8 +412,10 @@ class Auth(APIMixin): # never allow insecure login self.check_secure() - # pull the username, session_key and session_auth_hash from the token - user, session = self.validated_user_and_session(params['resume']) + # pull the username, session_key and auth_hash from the token + user, session = self.validated_user_and_session( + params['resume'], purpose=HashPurpose.RESUME_LOGIN, + ) auth.login(this.request, user) self.update_subs(user.pk) @@ -396,6 +424,7 @@ class Auth(APIMixin): user=user, session_key=session.session_key, expiry_date=session.get_expiry_date(), + purpose=HashPurpose.RESUME_LOGIN, ) @api_endpoint('changePassword') @@ -431,6 +460,7 @@ class Auth(APIMixin): token = self.get_user_token( user=user, session_key=this.request.session.session_key, expiry_date=expiry_date, + purpose=HashPurpose.PASSWORD_RESET, ) forgot_password.send( @@ -444,7 +474,9 @@ class Auth(APIMixin): @api_endpoint('resetPassword') def reset_password(self, token, new_password): """Reset password using a token received in email then logs user in.""" - user, _ = self.validated_user_and_session(token) + user, _ = self.validated_user_and_session( + token, purpose=HashPurpose.PASSWORD_RESET, + ) user.set_password(new_password) user.save() auth.login(this.request, user) From 246acfbdffdad879f316d155c86f489923deeeab Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Wed, 8 Jul 2015 10:44:25 +1000 Subject: [PATCH 2/2] Updated CHANGES.rst, bump version number. --- CHANGES.rst | 4 ++++ setup.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 74fe061..a75764b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Change Log ========== +0.9.7 +----- +* Updated Accounts hashing to prevent cross-purposing auth tokens. + 0.9.6 ----- * Correct method signature to match Meteor Accounts.resetPassword in diff --git a/setup.py b/setup.py index febb465..a7a82e9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.9.6', + version='0.9.7', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg',