django-ddp/dddp/accounts/ddp.py

571 lines
20 KiB
Python

"""
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
import collections
import datetime
import hashlib
from ejson import loads, dumps
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.signals import (
user_login_failed, user_logged_in, user_logged_out,
)
from django.dispatch import Signal
from django.utils import timezone
from dddp import (
THREAD_LOCAL_FACTORIES, this, MeteorError,
ADDED, REMOVED,
meteor_random_id,
)
from dddp.models import get_meteor_id, get_object, Subscription
from dddp.api import API, APIMixin, api_endpoint, Collection, Publication
# pylint dones't like lower case attribute names on modules, but it's the normal
# thing to do for Django signal names. --> pylint: disable=C0103
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 HashPurpose(object):
"""HashPurpose enumeration."""
PASSWORD_RESET = 'password_reset'
RESUME_LOGIN = 'resume_login'
HASH_MINUTES_VALID = {
HashPurpose.PASSWORD_RESET: int(
getattr(
# keep possible attack window short to reduce chance of account
# takeover through later discovery of password reset email message.
settings, 'DDP_PASSWORD_RESET_MINUTES_VALID', '1440', # 24 hours
)
),
HashPurpose.RESUME_LOGIN: int(
getattr(
# balance security and useability by allowing users to resume their
# logins within a reasonable time, but not forever.
settings, 'DDP_LOGIN_RESUME_MINUTES_VALID', '240', # 4 hours
)
),
}
def iter_auth_hashes(user, purpose, minutes_valid):
"""
Generate auth tokens tied to user and specified purpose.
The hash expires at midnight on the minute of now + minutes_valid, such that
when minutes_valid=1 you get *at least* 1 minute to use the token.
"""
now = timezone.now().replace(microsecond=0, second=0)
for minute in range(minutes_valid + 1):
yield hashlib.sha1(
'%s:%s:%s:%s:%s' % (
now - datetime.timedelta(minutes=minute),
user.password,
purpose,
user.pk,
settings.SECRET_KEY,
),
).hexdigest()
def get_auth_hash(user, purpose):
"""Generate a user hash for a particular purpose."""
return iter_auth_hashes(user, purpose, minutes_valid=1).next()
def calc_expiry_time(minutes_valid):
"""Return specific time an auth_hash will expire."""
return (
timezone.now() + datetime.timedelta(minutes=minutes_valid + 1)
).replace(second=0, microsecond=0)
def get_user_token(user, purpose, minutes_valid):
"""Return login token info for given user."""
token = ''.join(
dumps([
user.get_username(),
get_auth_hash(user, purpose),
]).encode('base64').split('\n')
)
return {
'id': get_meteor_id(user),
'token': token,
'tokenExpires': calc_expiry_time(minutes_valid),
}
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, *args, **kwargs):
"""Serialize user as per Meteor accounts serialization."""
# use default serialization, then modify to suit our needs.
data = super(Users, self).serialize(obj, *args, **kwargs)
# 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,
'permissions': sorted(self.model.get_all_permissions(obj)),
}
# 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(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 MeteorError(400, 'Bad profile key: %r' % key)
return result
@api_endpoint
def update(self, selector, update, options=None):
"""Update user data."""
# we're ignoring the `options` argument at this time
del options
user = get_object(
self.model, selector['_id'],
pk=this.user_id,
)
profile_update = self.deserialize_profile(
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 LoginServiceConfiguration(Publication):
"""Published list of authenitcation providers and their configuration."""
name = 'meteor.loginServiceConfiguration'
queries = []
class LoggedInUser(Publication):
"""Meteor auto publication for showing logged in user."""
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()
user_id = None
user_ddp_id = None
def user_factory(self):
"""Retrieve the current user (or None) from the database."""
if this.user_id is None:
return None
return self.user_model.objects.get(pk=this.user_id)
user_factory.update_thread_local = False
def ready(self):
"""Called after AppConfig.ready()."""
THREAD_LOCAL_FACTORIES['user'] = self.user_factory
@staticmethod
def update_subs(new_user_id):
"""Update subs to send added/removed for collections with user_rel."""
for sub in Subscription.objects.filter(connection=this.ws.connection):
params = loads(sub.params_ejson)
pub = API.get_pub_by_name(sub.publication)
# calculate the querysets prior to update
pre = collections.OrderedDict([
(col, query) for col, query
in API.sub_unique_objects(sub, params, pub)
])
# save the subscription with the updated user_id
sub.user_id = new_user_id
sub.save()
# calculate the querysets after the update
post = collections.OrderedDict([
(col, query) for col, query
in API.sub_unique_objects(sub, params, pub)
])
# first pass, send `added` for objs unique to `post`
for col_post, query in post.items():
try:
qs_pre = pre[col_post]
query = query.exclude(pk__in=qs_pre.order_by().values('pk'))
except KeyError:
# collection not included pre-auth, everything is added.
pass
for obj in query:
this.ws.send(col_post.obj_change_as_msg(obj, ADDED))
# second pass, send `removed` for objs unique to `pre`
for col_pre, query in pre.items():
try:
qs_post = post[col_pre]
query = query.exclude(
pk__in=qs_post.order_by().values('pk'),
)
except KeyError:
# collection not included post-auth, everything is removed.
pass
for obj in query:
this.ws.send(col_pre.obj_change_as_msg(obj, REMOVED))
@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.')
@classmethod
def validated_user(cls, token, purpose, minutes_valid):
"""Resolve and validate auth token, returns user object."""
try:
username, auth_hash = loads(token.decode('base64'))
except (ValueError, Error):
cls.auth_failed(token=token)
try:
user = cls.user_model.objects.get(**{
cls.user_model.USERNAME_FIELD: username,
'is_active': True,
})
user.backend = 'django.contrib.auth.backends.ModelBackend'
except cls.user_model.DoesNotExist:
cls.auth_failed(username=username, token=token)
if auth_hash not in iter_auth_hashes(user, purpose, minutes_valid):
cls.auth_failed(username=username, token=token)
return user
@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 in ('email', '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. For
# 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(
426,
"Outmoded password hashing: "
"https://github.com/meteor/meteor/issues/4363",
upgrade='meteor add tysonclugg:accounts-secure',
)
@api_endpoint('createUser')
def create_user(self, params):
"""Register a new user account."""
receivers = create_user.send(
sender=__name__,
request=this.request,
params=params,
)
if len(receivers) == 0:
raise NotImplementedError(
'Handler for `create_user` not registered.'
)
user = receivers[0][1]
user = auth.authenticate(
username=user.get_username(), password=params['password'],
)
self.do_login(user)
return get_user_token(
user=user, purpose=HashPurpose.RESUME_LOGIN,
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
)
def do_login(self, user):
"""Login a user."""
this.user_id = user.pk
this.user_ddp_id = get_meteor_id(user)
# silent subscription (sans sub/nosub msg) to LoggedInUser pub
this.user_sub_id = meteor_random_id()
API.do_sub(this.user_sub_id, 'LoggedInUser', silent=True)
self.update_subs(user.pk)
user_logged_in.send(
sender=user.__class__, request=this.request, user=user,
)
def do_logout(self):
"""Logout a user."""
# silent unsubscription (sans sub/nosub msg) from LoggedInUser pub
API.do_unsub(this.user_sub_id, silent=True)
del this.user_sub_id
self.update_subs(None)
user_logged_out.send(
sender=self.user_model, request=this.request, user=this.user,
)
this.user_id = None
this.user_ddp_id = None
@api_endpoint
def logout(self):
"""Logout current user."""
self.do_logout()
@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:
self.do_login(user)
return get_user_token(
user=user, purpose=HashPurpose.RESUME_LOGIN,
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
)
# 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 and auth_hash from the token
user = self.validated_user(
params['resume'], purpose=HashPurpose.RESUME_LOGIN,
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
)
self.do_login(user)
return get_user_token(
user=user, purpose=HashPurpose.RESUME_LOGIN,
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
)
@api_endpoint('changePassword')
def change_password(self, old_password, new_password):
"""Change password."""
try:
user = this.user
except self.user_model.DoesNotExist:
self.auth_failed()
user = auth.authenticate(
username=user.get_username(),
password=self.get_password(old_password),
)
if user is None:
self.auth_failed()
else:
user.set_password(self.get_password(new_password))
user.save()
password_changed.send(
sender=__name__,
request=this.request,
user=user,
)
return {"passwordChanged": True}
@api_endpoint('forgotPassword')
def forgot_password(self, params):
"""Request password reset email."""
username = self.get_username(params)
try:
user = self.user_model.objects.get(**{
self.user_model.USERNAME_FIELD: username,
})
except self.user_model.DoesNotExist:
self.auth_failed()
minutes_valid = HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET]
token = get_user_token(
user=user, purpose=HashPurpose.PASSWORD_RESET,
minutes_valid=minutes_valid,
)
forgot_password.send(
sender=__name__,
user=user,
token=token,
request=this.request,
expiry_date=calc_expiry_time(minutes_valid),
)
@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(
token, purpose=HashPurpose.PASSWORD_RESET,
minutes_valid=HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET],
)
user.set_password(new_password)
user.save()
self.do_login(user)
return {"userId": this.user_ddp_id}
API.register([Users, LoginServiceConfiguration, LoggedInUser, Auth])