mirror of
https://github.com/jazzband/django-ddp.git
synced 2026-05-19 04:51:11 +00:00
Add dddp.accounts module (Django application).
This commit is contained in:
parent
28a187a574
commit
53ec18cd33
3 changed files with 450 additions and 13 deletions
68
README.rst
68
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 <https://github.com/tysonclugg>`_
|
||||
|
|
|
|||
0
dddp/accounts/__init__.py
Normal file
0
dddp/accounts/__init__.py
Normal file
395
dddp/accounts/ddp.py
Normal file
395
dddp/accounts/ddp.py
Normal file
|
|
@ -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])
|
||||
Loading…
Reference in a new issue