Merge branch 'release/0.14.0'

This commit is contained in:
Tyson Clugg 2015-09-22 18:21:20 +10:00
commit dba30fd3ab
13 changed files with 182 additions and 109 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
METEOR@1.1.0.2
METEOR@1.2

View file

@ -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

View file

@ -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({});

View file

@ -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<path>.*)$', app),
)

View file

@ -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

View file

@ -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):

View file

@ -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',