From 53ec18cd33813d0fa67845ac105a00d93c77ba51 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 12 May 2015 14:35:08 +1000 Subject: [PATCH] Add dddp.accounts module (Django application). --- README.rst | 68 +++++-- dddp/accounts/__init__.py | 0 dddp/accounts/ddp.py | 395 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 13 deletions(-) create mode 100644 dddp/accounts/__init__.py create mode 100644 dddp/accounts/ddp.py diff --git a/README.rst b/README.rst index 61b03e8..094037a 100644 --- a/README.rst +++ b/README.rst @@ -52,30 +52,38 @@ Add 'dddp' to your settings.INSTALLED_APPS: ... INSTALLED_APPS = list(INSTALLED_APPS) + ['dddp'] -Add ddp.py to your Django app: +If you'd like support for the Meteor Accounts package (ie: login/logout +with django.contrib.auth) consult the section on authentication below +and use the following line instead: + + # settings.py + ... + INSTALLED_APPS = list(INSTALLED_APPS) + ['dddp', 'dddp.accounts'] + +Add ddp.py to your Django application: .. code:: python # bookstore/ddp.py - + from dddp.api import API, Collection, Publication from bookstore import models - + class Book(Collection): model = models.Book - - + + class Author(Collection): model = models.Author - - + + class AllBooks(Publication): queries = [ models.Author.objects.all(), models.Book.objects.all(), ] - - + + class BooksByAuthorEmail(Publication): def get_queries(self, author_email): return [ @@ -86,13 +94,13 @@ Add ddp.py to your Django app: author__email=author_email, ), ] - - + + API.register( [Book, Author, AllBooks, BooksByAuthorEmail] ) -Connect your Meteor app to the Django DDP service: +Connect your Meteor application to the Django DDP service: .. code:: javascript @@ -113,13 +121,47 @@ Start the Django DDP service: DJANGO_SETTINGS_MODULE=myproject.settings dddp -In a separate terminal, start Meteor (from within your meteor app directory): +In a separate terminal, start Meteor (from within your meteor +application directory): .. code:: sh meteor +Adding API endpoints (server method definitions) +------------------------------------------------ +API endpoints can be added by calling `register` method of the +dddp.api.API object from the ddp.py module of your Django app, on a +subclass of dddp.api.APIMixin - both dddp.api.Collection and +dddp.api.Publication are suitable, or you may define your own subclass +of dddp.api.APIMixin. A good example of this can be seen in +dddp/accounts/ddp.py in the source of django-ddp. + + +Authentication +-------------- +Authentication is provided using the standard meteor accounts system, +along with the `accounts-secure` package which turns off Meteor's +password hashing in favour of using TLS (HTTPS + WebSockets). This +ensures strong protection for all data over the wire. Correctly using +TLS/SSL also protects your site against man-in-the-middle and replay +attacks - Meteor is vulnerable to both of these without using +encryption. + +Add `dddp.accounts` to your `settings.INSTALLED_APPS` as described in +the example usage section above, then add `tysonclugg:accounts-secure` +to your Meteor application (from within your meteor application +directory): + +.. code:: sh + + meteor add tysonclugg:accounts-secure + +Then follow the normal procedure to add login/logout views to your +Meteor application. + + Contributors ------------ `Tyson Clugg `_ diff --git a/dddp/accounts/__init__.py b/dddp/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py new file mode 100644 index 0000000..bb402f0 --- /dev/null +++ b/dddp/accounts/ddp.py @@ -0,0 +1,395 @@ +""" +Django DDP authentication. + +Matches Meteor 1.1 Accounts package: https://www.meteor.com/accounts + +See http://docs.meteor.com/#/full/accounts_api for details of each method. +""" +from binascii import Error + +from ejson import loads, dumps + +from django.contrib import auth +from django.contrib.sessions.backends.db import SessionStore +from django.contrib.auth.signals import user_login_failed +from django.dispatch import Signal +from django.utils import timezone + +from dddp import THREAD_LOCAL as this +from dddp.models import get_meteor_id, get_object +from dddp.api import API, APIMixin, api_endpoint, Collection, Publication +from dddp.websocket import MeteorError + + +create_user = Signal(providing_args=['request', 'params']) +password_changed = Signal(providing_args=['request', 'user']) +forgot_password = Signal(providing_args=['request', 'user', 'token', 'expiry']) +password_reset = Signal(providing_args=['request', 'user']) + + +class Users(Collection): + + """Mimic `users` collection of Meteor's `accounts-password` package.""" + + name = 'users' + api_path_prefix = '/users/' + model = auth.get_user_model() + + user_rel = [ + 'pk', + ] + + def serialize(self, obj): + """Serialize user as per Meteor accounts serialization.""" + # use default serialization, then modify to suit our needs. + data = super(Users, self).serialize(obj) + + # everything that isn't handled explicitly ends up in `profile` + profile = data.pop('fields') + profile.setdefault('name', obj.get_full_name()) + fields = data['fields'] = { + 'username': obj.get_username(), + 'emails': [], + 'profile': profile, + } + + # clear out sensitive data + for sensitive in [ + 'password', + 'user_permissions_ids', + 'is_active', + 'is_staff', + 'is_superuser', + 'groups_ids', + ]: + profile.pop(sensitive, None) + + # createdAt (default is django.contrib.auth.models.User.date_joined) + try: + fields['createdAt'] = profile.pop('date_joined') + except KeyError: + date_joined = getattr( + obj, 'get_date_joined', + lambda: getattr(obj, 'date_joined', None) + )() + if date_joined: + fields['createdAt'] = date_joined + + # email (default is django.contrib.auth.models.User.email) + try: + email = profile.pop('email') + except KeyError: + email = getattr( + obj, 'get_email', + lambda: getattr(obj, 'email', None) + )() + if email: + fields['emails'].append({'address': email, 'verified': True}) + + return data + + @staticmethod + def deserialize_profile(user, profile, key_prefix='', pop=False): + """De-serialize user profile fields into concrete model fields.""" + result = {} + if pop: + getter = profile.pop + else: + getter = profile.get + + def prefixed(name): + """Return name prefixed by `key_prefix`.""" + return '%s%s' % (key_prefix, name) + + for key in profile.keys(): + val = getter(key) + if key == prefixed('name'): + result['full_name'] = val + else: + raise ValueError('Bad profile key: %r' % key) + return result + + @api_endpoint + def update(self, selector, update, options=None): + """Update user data.""" + user = get_object( + self.model, selector['_id'], + pk=this.request.user.pk, + ) + profile_update = self.deserialize_profile( + user, update['$set'], key_prefix='profile.', pop=True, + ) + if len(update['$set']) != 0: + raise MeteorError(400, 'Invalid update fields: %r') + + for key, val in profile_update.items(): + setattr(user, key, val) + user.save() + + +class LoginPublication(Publication): + + """Meteor Accounts emulation.""" + + name = 'meteor.loginServiceConfiguration' + + queries = [ + (Users.model.objects.all(), 'users'), + ] + + +class Auth(APIMixin): + + """Meteor Passwords emulation.""" + + api_path_prefix = '' # auth endpoints don't have a common prefix + user_model = auth.get_user_model() + + @staticmethod + def auth_failed(**credentials): + """Consistent fail so we don't provide attackers with valuable info.""" + if credentials: + user_login_failed.send_robust( + sender=__name__, + credentials=auth._clean_credentials(credentials), + ) + raise MeteorError(403, 'Authentication failed.') + + def validated_user_and_session(self, token): + """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) + try: + user = self.user_model.objects.get(**{ + self.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) + session = SessionStore( + session_key=session_key, + ) + if session.get_expiry_date() <= timezone.now(): + self.auth_failed(username=username, token=token) + return (user, session) + + @staticmethod + def get_user_token(user, session_key, expiry_date): + """Return login token info for given user.""" + token = ''.join( + dumps([ + user.get_username(), + session_key, + user.get_session_auth_hash(), + ]).encode('base64').split('\n') + ) + return { + 'id': get_meteor_id(user), + 'token': token, + 'tokenExpires': expiry_date, + } + + @staticmethod + def check_secure(): + """Check request, return False if using SSL or local connection.""" + if this.request.is_secure(): + return True # using SSL + elif this.request.META['REMOTE_ADDR'] in [ + 'localhost', + '127.0.0.1', + ]: + return True # localhost + raise MeteorError(403, 'Authentication refused without SSL.') + + def get_username(self, user): + """Retrieve username from user selector.""" + if isinstance(user, basestring): + return user + elif isinstance(user, dict) and len(user) == 1: + [(key, val)] = user.items() + if key == 'username' or (key == self.user_model.USERNAME_FIELD): + # username provided directly + return val + elif key == 'emails.address': + email_field = getattr(self.user_model, 'EMAIL_FIELD', 'email') + if self.user_model.USERNAME_FIELD == email_field: + return val # email is username + # find username by email + return self.user_model.objects.values_list( + self.user_model.USERNAME_FIELD, flat=True, + ).get(**{email_field: val}) + elif key in ('id', 'pk'): + # find username by primary key (ID) + return self.user_model.objects.values_list( + self.user_model.USERNAME_FIELD, flat=True, + ).get( + pk=val, + ) + else: + raise MeteorError(400, 'Invalid user lookup: %r' % key) + else: + raise MeteorError(400, 'Invalid user expression: %r' % user) + + @staticmethod + def get_password(password): + """Return password in plain-text from string/dict.""" + if isinstance(password, basestring): + # regular Django authentication - plaintext password... but you're + # using HTTPS (SSL) anyway so it's protected anyway, right? + return password + else: + # Meteor is trying to be smart by doing client side hashing of the + # password so that passwords are "...not sent in plain text over the + # wire". This behaviour doesn't make HTTP any more secure - it just + # gives a false sense of security as replay attacks and + # code-injection are both still viable attack vectors for the + # malicious MITM. Also as no salt is used with hashing, the + # passwords are vulnerable to rainbow-table lookups anyway. + # + # If you're doing security, do it right from the very outset. Fors + # web services that means using SSL and not relying on half-baked + # security concepts put together by people with no security + # background. + # + # We protest loudly to anyone who cares to listen in the server logs + # until upstream developers see the light and drop the password + # hashing mis-feature. + raise MeteorError( + 400, + "Outmoded password hashing, run " + "`meteor add tysonclugg:accounts-secure` to fix.", + ) + + @staticmethod + @api_endpoint('createUser') + def create_user(params): + """Register a new user account.""" + receivers = create_user.send( + sender=__name__, + request=this.request, + params=params, + ) + if len(receivers) == 0: + raise MeteorError(501, 'Handler for `create_user` not registered.') + + @staticmethod + @api_endpoint + def logout(): + """Logout current user.""" + auth.logout(this.request) + + @api_endpoint + def login(self, params): + """Login either with resume token or password.""" + if 'password' in params: + return self.login_with_password(params) + elif 'resume' in params: + return self.login_with_resume_token(params) + else: + self.auth_failed(**params) + + def login_with_password(self, params): + """Authenticate using credentials supplied in params.""" + # never allow insecure login + self.check_secure() + + username = self.get_username(params['user']) + password = self.get_password(params['password']) + + user = auth.authenticate(username=username, password=password) + if user is not None: + # the password verified for the user + if user.is_active: + auth.login(this.request, user) + this.request.session.save() + return self.get_user_token( + user=user, + session_key=this.request.session.session_key, + expiry_date=this.request.session.get_expiry_date(), + ) + + # Call to `authenticate` was unable to verify the username and password. + # It will have sent the `user_login_failed` signal, no need to pass the + # `username` argument to auth_failed(). + self.auth_failed() + + def login_with_resume_token(self, params): + """ + Login with existing resume token. + + Either the token is valid and the user is logged in, or the token is + invalid and a non-specific ValueError("Login failed.") exception is + raised - don't be tempted to give clues to attackers as to why their + logins are invalid! + """ + # 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']) + + auth.login(this.request, user) + this.request.session.save() + return self.get_user_token( + user=user, + session_key=session.session_key, + expiry_date=session.get_expiry_date(), + ) + + @api_endpoint('changePassword') + def change_password(self, params): + """Change password.""" + user = auth.authenticate( + username=this.request.user.get_username(), + password=self.get_password(params['oldPassword']), + ) + if user is None: + self.auth_failed() + else: + user.set_password(self.get_password(params['newPassword'])) + user.save() + password_changed.send( + sender=__name__, + request=this.request, + user=user, + ) + + @api_endpoint('forgotPassword') + def forgot_password(self, params): + """Request password reset email.""" + username = self.get_username(params['user']) + try: + user = self.user_model.objects.get(**{ + self.user_model.USERNAME_FIELD: username, + }) + except self.user_model.DoesNotExist: + self.auth_failed() + + expiry_date = this.request.session.get_expiry_date() + token = self.get_user_token( + user=user, session_key=this.request.session.session_key, + expiry_date=expiry_date, + ) + + forgot_password.send( + sender=__name__, + user=user, + token=token, + request=this.request, + expiry_date=expiry_date, + ) + + @api_endpoint('resetPassword') + def reset_password(self, params): + """Reset password using a token received in email then logs user in.""" + user, _ = self.validated_user_and_session(params['token']) + user.set_password(params['newPassword']) + user.save() + auth.login(this.request, user) + + +API.register([Users, LoginPublication, Auth])