diff --git a/CHANGES.rst b/CHANGES.rst index 81c1d9e..97e9a89 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,20 @@ Change Log ========== +0.14.0 +------ +* Correctly handle serving app content from the root path of a domain. +* Account security tokens are now calculated for each minute allowing for finer grained token expiry. +* Fix bug in error handling where invalid arguments were being passed to `logging.error()`. +* Change setting names (and implied meanings): + - DDP_PASSWORD_RESET_DAYS_VALID becomes + DDP_PASSWORD_RESET_MINUTES_VALID. + - DDP_LOGIN_RESUME_DAYS_VALID becomes DDP_LOGIN_RESUME_MINUTES_VALID. +* Include `created` field in logs collection. +* Stop depending on `Referrer` HTTP header which is optional. +* Honour `--verbosity` in `dddp` command, now showing API endpoints in more verbose modes. +* Updated `dddp.test` to Meteor 1.2 and also showing example of URL config to serve Meteor files from Python. + 0.13.0 ------ * Abstract DDPLauncher out from dddp.main.serve to permit use from other contexts. diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index d49b0e8..8896f1c 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -45,36 +45,36 @@ class HashPurpose(object): RESUME_LOGIN = 'resume_login' -HASH_DAYS_VALID = { +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_DAYS_VALID', '1', + 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_DAYS_VALID', '10', + settings, 'DDP_LOGIN_RESUME_MINUTES_VALID', '240', # 4 hours ) ), } -def iter_auth_hashes(user, purpose, days_valid): +def iter_auth_hashes(user, purpose, minutes_valid): """ Generate auth tokens tied to user and specified purpose. - The hash expires at midnight on the day of today + days_valid, such that - when days_valid=1 you get *at least* 24 hours to use the token. + 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. """ - today = timezone.now().date() - for day in range(days_valid + 1): + now = timezone.now().replace(microsecond=0, second=0) + for minute in range(minutes_valid + 1): yield hashlib.sha1( '%s:%s:%s:%s:%s' % ( - today - datetime.timedelta(days=day), + now - datetime.timedelta(minutes=minute), user.password, purpose, user.pk, @@ -85,17 +85,17 @@ def iter_auth_hashes(user, purpose, days_valid): def get_auth_hash(user, purpose): """Generate a user hash for a particular purpose.""" - return iter_auth_hashes(user, purpose, days_valid=1).next() + return iter_auth_hashes(user, purpose, minutes_valid=1).next() -def calc_expiry_time(days_valid): +def calc_expiry_time(minutes_valid): """Return specific time an auth_hash will expire.""" return ( - timezone.now() + datetime.timedelta(days=days_valid + 1) - ).replace(hour=0, minute=0, second=0, microsecond=0) + timezone.now() + datetime.timedelta(minutes=minutes_valid + 1) + ).replace(second=0, microsecond=0) -def get_user_token(user, purpose, days_valid): +def get_user_token(user, purpose, minutes_valid): """Return login token info for given user.""" token = ''.join( dumps([ @@ -106,7 +106,7 @@ def get_user_token(user, purpose, days_valid): return { 'id': get_meteor_id(user), 'token': token, - 'tokenExpires': calc_expiry_time(days_valid), + 'tokenExpires': calc_expiry_time(minutes_valid), } @@ -309,7 +309,7 @@ class Auth(APIMixin): raise MeteorError(403, 'Authentication failed.') @classmethod - def validated_user(cls, token, purpose, days_valid): + def validated_user(cls, token, purpose, minutes_valid): """Resolve and validate auth token, returns user object.""" try: username, auth_hash = loads(token.decode('base64')) @@ -323,7 +323,7 @@ class Auth(APIMixin): 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, days_valid): + if auth_hash not in iter_auth_hashes(user, purpose, minutes_valid): cls.auth_failed(username=username, token=token) return user @@ -415,7 +415,7 @@ class Auth(APIMixin): self.do_login(user) return get_user_token( user=user, purpose=HashPurpose.RESUME_LOGIN, - days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], + minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN], ) def do_login(self, user): @@ -472,7 +472,7 @@ class Auth(APIMixin): self.do_login(user) return get_user_token( user=user, purpose=HashPurpose.RESUME_LOGIN, - days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], + minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN], ) # Call to `authenticate` was unable to verify the username and password. @@ -495,13 +495,13 @@ class Auth(APIMixin): # pull the username and auth_hash from the token user = self.validated_user( params['resume'], purpose=HashPurpose.RESUME_LOGIN, - days_valid=HASH_DAYS_VALID[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, - days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], + minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN], ) @api_endpoint('changePassword') @@ -538,10 +538,10 @@ class Auth(APIMixin): except self.user_model.DoesNotExist: self.auth_failed() - days_valid = HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET] + minutes_valid = HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET] token = get_user_token( user=user, purpose=HashPurpose.PASSWORD_RESET, - days_valid=days_valid, + minutes_valid=minutes_valid, ) forgot_password.send( @@ -549,7 +549,7 @@ class Auth(APIMixin): user=user, token=token, request=this.request, - expiry_date=calc_expiry_time(days_valid), + expiry_date=calc_expiry_time(minutes_valid), ) @api_endpoint('resetPassword') @@ -557,7 +557,7 @@ class Auth(APIMixin): """Reset password using a token received in email then logs user in.""" user = self.validated_user( token, purpose=HashPurpose.PASSWORD_RESET, - days_valid=HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET], + minutes_valid=HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET], ) user.set_password(new_password) user.save() diff --git a/dddp/logging.py b/dddp/logging.py index c4de8c9..2560558 100644 --- a/dddp/logging.py +++ b/dddp/logging.py @@ -1,6 +1,7 @@ """Django DDP logging helpers.""" from __future__ import absolute_import, print_function +import datetime import logging from dddp import THREAD_LOCAL as this, meteor_random_id, ADDED @@ -18,6 +19,7 @@ class DDPHandler(logging.Handler): 'collection': 'logs', 'id': meteor_random_id('/collection/logs'), 'fields': { + 'created': datetime.datetime.fromtimestamp(record.created), 'name': record.name, 'levelno': record.levelno, 'levelname': record.levelname, diff --git a/dddp/main.py b/dddp/main.py index 7fa9416..66c6f22 100644 --- a/dddp/main.py +++ b/dddp/main.py @@ -18,22 +18,29 @@ import geventwebsocket.handler Addr = collections.namedtuple('Addr', ['host', 'port']) +def common_headers(environ, **kwargs): + """Return list of common headers for SockJS HTTP responses.""" + return [ + # DDP doesn't use cookies or HTTP level auth, so CSRF attacks are + # ineffective. We can safely allow cross-domain DDP connections and + # developers may choose to allow anonymous access to publications and + # RPC methods as they see fit. More to the point, developers should + # restrict access to publications and RPC endpoints as appropriate. + ('Access-Control-Allow-Origin', '*'), + ('Access-Control-Allow-Credentials', 'false'), + ('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'), + ('Connection', 'keep-alive'), + ('Vary', 'Origin'), + ] + + def ddpp_sockjs_xhr(environ, start_response): """Dummy method that doesn't handle XHR requests.""" start_response( '404 Not found', [ ('Content-Type', 'text/plain; charset=UTF-8'), - ( - 'Access-Control-Allow-Origin', - '/'.join(environ['HTTP_REFERER'].split('/')[:3]), - ), - ('Access-Control-Allow-Credentials', 'true'), - # ('access-control-allow-credentials', 'true'), - ('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'), - ('Connection', 'keep-alive'), - ('Vary', 'Origin'), - ], + ] + common_headers(environ), ) yield 'No.' @@ -47,16 +54,7 @@ def ddpp_sockjs_info(environ, start_response): '200 OK', [ ('Content-Type', 'application/json; charset=UTF-8'), - ( - 'Access-Control-Allow-Origin', - '/'.join(environ['HTTP_REFERER'].split('/')[:3]), - ), - ('Access-Control-Allow-Credentials', 'true'), - # ('access-control-allow-credentials', 'true'), - ('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'), - ('Connection', 'keep-alive'), - ('Vary', 'Origin'), - ], + ] + common_headers(environ), ) yield ejson.dumps(collections.OrderedDict([ ('websocket', True), @@ -193,8 +191,9 @@ class DDPLauncher(object): self.logger.debug('PostgresGreenlet start') self._stop_event.clear() self.print('=> Discovering DDP endpoints...') - for api_path in sorted(self.api.api_path_map()): - self.logger.debug(' %s', api_path) + if self.verbosity > 1: + for api_path in sorted(self.api.api_path_map()): + print(' %s' % api_path) # start greenlets self.pgworker.start() diff --git a/dddp/test/meteor_todos/.meteor/.finished-upgraders b/dddp/test/meteor_todos/.meteor/.finished-upgraders index 8a76103..61ee313 100644 --- a/dddp/test/meteor_todos/.meteor/.finished-upgraders +++ b/dddp/test/meteor_todos/.meteor/.finished-upgraders @@ -6,3 +6,7 @@ notices-for-0.9.0 notices-for-0.9.1 0.9.4-platform-file notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes diff --git a/dddp/test/meteor_todos/.meteor/packages b/dddp/test/meteor_todos/.meteor/packages index 99704e0..ccb3468 100644 --- a/dddp/test/meteor_todos/.meteor/packages +++ b/dddp/test/meteor_todos/.meteor/packages @@ -4,6 +4,17 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -meteor-platform -autopublish -insecure +standard-minifiers +meteor-base +mobile-experience +blaze-html-templates +session +jquery +tracker +logging +reload +random +ejson +spacebars +check +mongo diff --git a/dddp/test/meteor_todos/.meteor/release b/dddp/test/meteor_todos/.meteor/release index dab6b55..712ef79 100644 --- a/dddp/test/meteor_todos/.meteor/release +++ b/dddp/test/meteor_todos/.meteor/release @@ -1 +1 @@ -METEOR@1.1.0.2 +METEOR@1.2 diff --git a/dddp/test/meteor_todos/.meteor/versions b/dddp/test/meteor_todos/.meteor/versions index 410e1d9..40a8d86 100644 --- a/dddp/test/meteor_todos/.meteor/versions +++ b/dddp/test/meteor_todos/.meteor/versions @@ -1,48 +1,63 @@ -autopublish@1.0.3 -autoupdate@1.2.1 -base64@1.0.3 -binary-heap@1.0.3 -blaze@2.1.2 -blaze-tools@1.0.3 -boilerplate-generator@1.0.3 -callback-hook@1.0.3 -check@1.0.5 -ddp@1.1.0 -deps@1.0.7 -ejson@1.0.6 -fastclick@1.0.3 -geojson-utils@1.0.3 -html-tools@1.0.4 -htmljs@1.0.4 -http@1.1.0 -id-map@1.0.3 -insecure@1.0.3 -jquery@1.11.3_2 -json@1.0.3 -launch-screen@1.0.2 -livedata@1.0.13 -logging@1.0.7 -meteor@1.1.6 -meteor-platform@1.2.2 -minifiers@1.1.5 -minimongo@1.0.8 -mobile-status-bar@1.0.3 -mongo@1.1.0 -observe-sequence@1.0.6 -ordered-dict@1.0.3 -random@1.0.3 -reactive-dict@1.1.0 -reactive-var@1.0.5 -reload@1.1.3 -retry@1.0.3 -routepolicy@1.0.5 -session@1.1.0 -spacebars@1.0.6 -spacebars-compiler@1.0.6 -templating@1.1.1 -tracker@1.0.7 -ui@1.0.6 -underscore@1.0.3 -url@1.0.4 -webapp@1.2.0 -webapp-hashing@1.0.3 +autoupdate@1.2.3 +babel-compiler@5.8.24 +babel-runtime@0.1.4 +base64@1.0.4 +binary-heap@1.0.4 +blaze@2.1.3 +blaze-html-templates@1.0.1 +blaze-tools@1.0.4 +boilerplate-generator@1.0.4 +caching-compiler@1.0.0 +caching-html-compiler@1.0.1 +callback-hook@1.0.4 +check@1.0.6 +ddp@1.2.1 +ddp-client@1.2.1 +ddp-common@1.2.1 +ddp-server@1.2.1 +deps@1.0.8 +diff-sequence@1.0.1 +ecmascript@0.1.3 +ecmascript-collections@0.1.6 +ejson@1.0.7 +fastclick@1.0.7 +geojson-utils@1.0.4 +hot-code-push@1.0.0 +html-tools@1.0.5 +htmljs@1.0.5 +http@1.1.1 +id-map@1.0.4 +jquery@1.11.4 +launch-screen@1.0.3 +livedata@1.0.14 +logging@1.0.8 +meteor@1.1.7 +meteor-base@1.0.1 +minifiers@1.1.6 +minimongo@1.0.9 +mobile-experience@1.0.1 +mobile-status-bar@1.0.6 +mongo@1.1.1 +mongo-id@1.0.1 +npm-mongo@1.4.39_1 +observe-sequence@1.0.7 +ordered-dict@1.0.4 +promise@0.4.8 +random@1.0.4 +reactive-dict@1.1.1 +reactive-var@1.0.6 +reload@1.1.4 +retry@1.0.4 +routepolicy@1.0.6 +session@1.1.1 +spacebars@1.0.7 +spacebars-compiler@1.0.7 +standard-minifiers@1.0.0 +templating@1.1.2 +templating-tools@1.0.0 +tracker@1.0.8 +ui@1.0.7 +underscore@1.0.4 +url@1.0.5 +webapp@1.2.2 +webapp-hashing@1.0.4 diff --git a/dddp/test/meteor_todos/meteor_todos.js b/dddp/test/meteor_todos/meteor_todos.js index 7e44016..10d00ca 100644 --- a/dddp/test/meteor_todos/meteor_todos.js +++ b/dddp/test/meteor_todos/meteor_todos.js @@ -1,8 +1,14 @@ if (Meteor.isClient) { // This code only runs on the client - Django = DDP.connect('http://'+window.location.hostname+':8000/'); - Tasks = new Mongo.Collection("django_todos.task", {"connection": Django}); - Django.subscribe('Tasks'); + options = {}; + if (__meteor_runtime_config__.hasOwnProperty('DDP_DEFAULT_CONNECTION_URL')) { + Django = Meteor; + } else { + Django = DDP.connect(window.location.protocol + '//'+window.location.hostname+':8000/'); + options.connection = Django; + } + Tasks = new Mongo.Collection("django_todos.task", options); + TaskSub = Django.subscribe('Tasks'); Template.body.helpers({ tasks: function () { return Tasks.find({}); diff --git a/dddp/test/test_project/urls.py b/dddp/test/test_project/urls.py index fc92be3..6547d18 100644 --- a/dddp/test/test_project/urls.py +++ b/dddp/test/test_project/urls.py @@ -1,6 +1,18 @@ +"""Django DDP test project - URL configuraiton.""" +import os.path + from django.conf import settings from django.conf.urls import patterns, include, url from django.contrib import admin +from dddp.views import MeteorView +import dddp.test + +app = MeteorView.as_view( + json_path=os.path.join( + os.path.dirname(dddp.test.__file__), + 'build', 'bundle', 'star.json' + ), +) urlpatterns = patterns('', # Examples: @@ -16,4 +28,6 @@ urlpatterns = patterns('', 'show_indexes': False, }, ), + # all remaining URLs routed to Meteor app. + url(r'^(?P.*)$', app), ) diff --git a/dddp/views.py b/dddp/views.py index 76ebfac..eeb5dbf 100644 --- a/dddp/views.py +++ b/dddp/views.py @@ -1,7 +1,8 @@ """Django DDP Server views.""" -from __future__ import print_function, absolute_import, unicode_literals +from __future__ import absolute_import, unicode_literals import io +import logging import mimetypes import os.path @@ -36,6 +37,7 @@ class MeteorView(View): """Django DDP Meteor server view.""" + logger = logging.getLogger(__name__) http_method_names = ['get', 'head'] json_path = None @@ -44,7 +46,6 @@ class MeteorView(View): manifest = None program_json = None program_json_path = None - runtime_config = None star_json = None # top level layout @@ -60,6 +61,7 @@ class MeteorView(View): def __init__(self, **kwargs): """Initialisation for Django DDP server view.""" + self.runtime_config = {} # super(...).__init__ assigns kwargs to instance. super(MeteorView, self).__init__(**kwargs) @@ -146,6 +148,8 @@ class MeteorView(View): if item['where'] == 'client': if '?' in item['url']: item['url'] = item['url'].split('?', 1)[0] + if item['url'].startswith('/'): + item['url'] = item['url'][1:] self.client_map[item['url']] = item self.url_map[item['url']] = ( item['path_full'], @@ -190,7 +194,13 @@ class MeteorView(View): def get(self, request, path): """Return HTML (or other related content) for Meteor.""" - if path == '/meteor_runtime_config.js': + self.logger.debug( + '[%s:%s] %s %s %s', + request.META['REMOTE_ADDR'], request.META['REMOTE_PORT'], + request.method, request.path, + request.META['SERVER_PROTOCOL'], + ) + if path == 'meteor_runtime_config.js': config = { 'DDP_DEFAULT_CONNECTION_URL': request.build_absolute_uri('/'), 'ROOT_URL': request.build_absolute_uri( @@ -217,6 +227,4 @@ class MeteorView(View): content_type=content_type, ) except KeyError: - print(path) return HttpResponse(self.html) - # raise Http404 diff --git a/dddp/websocket.py b/dddp/websocket.py index cf4bdfd..508ec15 100644 --- a/dddp/websocket.py +++ b/dddp/websocket.py @@ -308,7 +308,7 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): } if record['exc_info'] == (None, None, None): del record['exc_info'] - self.logger.error('! %s %r', self, data, exc_info=exc_info, **record) + self.logger.error('! %s %r', self, data, **record) self.reply(msg, **data) def recv_connect(self, version=None, support=None, session=None): diff --git a/setup.py b/setup.py index 82bba90..9f4282b 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ CLASSIFIERS = [ setup( name='django-ddp', - version='0.13.0', + version='0.14.0', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg',