From c99c171dbc573bdcd0dde2fc07ca5a954b3c2afd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Tue, 25 Mar 2014 13:43:11 -0700 Subject: [PATCH] Initial snapshot. --- LICENSE | 202 ++++++++++++++++++++++++++++++++++ __init__.py | 0 api.py | 125 +++++++++++++++++++++ auth.py | 41 +++++++ channel.py | 44 ++++++++ config.py | 9 ++ include.yaml | 19 ++++ lib/__init__.py | 0 lib/auth.py | 69 ++++++++++++ lib/models.py | 111 +++++++++++++++++++ lib/security.py | 70 ++++++++++++ lib/session.py | 88 +++++++++++++++ lib/utils.py | 56 ++++++++++ static/cosmopolite.js | 211 ++++++++++++++++++++++++++++++++++++ static/debug.html | 79 ++++++++++++++ static/login_complete.html | 8 ++ static/logout_complete.html | 8 ++ 17 files changed, 1140 insertions(+) create mode 100644 LICENSE create mode 100644 __init__.py create mode 100644 api.py create mode 100644 auth.py create mode 100644 channel.py create mode 100644 config.py create mode 100644 include.yaml create mode 100644 lib/__init__.py create mode 100644 lib/auth.py create mode 100644 lib/models.py create mode 100644 lib/security.py create mode 100644 lib/session.py create mode 100644 lib/utils.py create mode 100644 static/cosmopolite.js create mode 100644 static/debug.html create mode 100644 static/login_complete.html create mode 100644 static/logout_complete.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api.py b/api.py new file mode 100644 index 0000000..b7673c7 --- /dev/null +++ b/api.py @@ -0,0 +1,125 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import webapp2 + +from google.appengine.api import channel +from google.appengine.ext import db + +from cosmopolite.lib import auth +from cosmopolite.lib import models +from cosmopolite.lib import security +from cosmopolite.lib import session +from cosmopolite.lib import utils + +import config + + +class GetUser(webapp2.RequestHandler): + @utils.chaos_monkey + @utils.returns_json + @utils.local_namespace + @security.google_user_xsrf_protection + @security.weak_security_checks + @session.session_required + def post(self): + ret = {} + if self.verified_google_user: + ret['google_user'] = self.verified_google_user.email() + return ret + + +class SetValue(webapp2.RequestHandler): + @utils.chaos_monkey + @utils.returns_json + @utils.local_namespace + @security.google_user_xsrf_protection + @security.weak_security_checks + @session.session_required + @db.transactional() + def post(self): + entry_key = self.request.get('key') + entry_value = self.request.get('value') + + entries = (models.StateEntry.all() + .ancestor(self.client.parent_key()) + .filter('entry_key =', entry_key) + .fetch(1)) + if entries: + entry = entries[0] + entry.entry_value = entry_value + else: + entry = models.StateEntry( + parent=self.client.parent_key(), + entry_key=entry_key, + entry_value=entry_value) + + entry.put() + clients = (models.Client.all(keys_only=True) + .ancestor(self.client.parent_key())) + for client in clients: + entry.SendToClient(client) + + return {} + + +class GetValue(webapp2.RequestHandler): + @utils.chaos_monkey + @utils.returns_json + @utils.local_namespace + @security.google_user_xsrf_protection + @security.weak_security_checks + @session.session_required + @db.transactional() + def post(self): + entry_key = self.request.get('key') + + entries = (models.StateEntry.all() + .ancestor(self.client.parent_key()) + .filter('entry_key =', entry_key) + .fetch(1)) + if entries: + return { + 'value': entries[0].entry_value + } + + return {} + + +class CreateChannel(webapp2.RequestHandler): + @utils.chaos_monkey + @utils.returns_json + @utils.local_namespace + @security.google_user_xsrf_protection + @security.weak_security_checks + @session.session_required + def post(self): + token = channel.create_channel( + client_id=str(self.client.key()), + duration_minutes=config.CHANNEL_DURATION_SECONDS / 60) + entries = (models.StateEntry.all() + .ancestor(self.client.parent_key())) + for entry in entries: + entry.SendToClient(self.client.key()) + return { + 'token': token, + } + + +app = webapp2.WSGIApplication([ + (config.URL_PREFIX + '/api/createChannel', CreateChannel), + (config.URL_PREFIX + '/api/getUser', GetUser), + (config.URL_PREFIX + '/api/getValue', GetValue), + (config.URL_PREFIX + '/api/setValue', SetValue), +]) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..412c6f6 --- /dev/null +++ b/auth.py @@ -0,0 +1,41 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import webapp2 + +from google.appengine.api import users + +from cosmopolite.lib import security + +import config + + +class Login(webapp2.RequestHandler): + @security.weak_security_checks + def get(self): + self.redirect(users.create_login_url( + dest_url=config.URL_PREFIX + '/static/login_complete.html')) + + +class Logout(webapp2.RequestHandler): + @security.weak_security_checks + def get(self): + self.redirect(users.create_logout_url( + dest_url=config.URL_PREFIX + '/static/logout_complete.html')) + + +app = webapp2.WSGIApplication([ + (config.URL_PREFIX + '/auth/login', Login), + (config.URL_PREFIX + '/auth/logout', Logout), +]) diff --git a/channel.py b/channel.py new file mode 100644 index 0000000..17f9807 --- /dev/null +++ b/channel.py @@ -0,0 +1,44 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import webapp2 + +from google.appengine.ext import db + +from cosmopolite.lib import auth +from cosmopolite.lib import models +from cosmopolite.lib import utils + + +class OnChannelConnect(webapp2.RequestHandler): + @utils.local_namespace + @db.transactional() + def post(self): + client = models.Client.get(self.request.get('from')) + client.channel_active = True + client.put() + +class OnChannelDisconnect(webapp2.RequestHandler): + @utils.local_namespace + @db.transactional() + def post(self): + client = models.Client.get(self.request.get('from')) + client.channel_active = False + client.put() + + +app = webapp2.WSGIApplication([ + ('/_ah/channel/connected/', OnChannelConnect), + ('/_ah/channel/disconnected/', OnChannelDisconnect), +]) diff --git a/config.py b/config.py new file mode 100644 index 0000000..92e43f6 --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +# Co-existence +URL_PREFIX = '/cosmopolite' +NAMESPACE = 'cosmopolite' + +# Timings +CHANNEL_DURATION_SECONDS = 60 * 60 * 2 # 2 hours + +# Probabilities +CHAOS_PROBABILITY = 0.50 diff --git a/include.yaml b/include.yaml new file mode 100644 index 0000000..b06714e --- /dev/null +++ b/include.yaml @@ -0,0 +1,19 @@ +handlers: +- url: /cosmopolite/api/.* + script: cosmopolite.api.app + secure: always + +- url: /cosmopolite/auth/.* + script: cosmopolite.auth.app + secure: always + +- url: /_ah/channel/.* + script: cosmopolite.channel.app + +- url: /cosmopolite/static + static_dir: cosmopolite/static + secure: always + http_headers: + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Strict-Transport-Security: max-age=31536000; includeSubDomains diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/auth.py b/lib/auth.py new file mode 100644 index 0000000..4d21c44 --- /dev/null +++ b/lib/auth.py @@ -0,0 +1,69 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import hmac +import random +import string + +from google.appengine.api import memcache +from google.appengine.ext import db + + +class BadSignature(Exception): + pass + + +class AuthKey(db.Model): + auth_key = db.ByteStringProperty(required=True) + live = db.BooleanProperty(required=True, default=True) + + +_KEY_CHARS = string.ascii_letters + string.digits +_KEY_LENGTH = 64 + +def GetAuthKey(): + auth_key = memcache.get('auth_key') + if auth_key: + return auth_key + + for key in AuthKey.all().filter('live =', True): + auth_key = key.auth_key + + if not auth_key: + auth_key = ''.join(random.choice(_KEY_CHARS) for _ in xrange(_KEY_LENGTH)) + AuthKey(auth_key=auth_key).put() + + memcache.set('auth_key', auth_key) + return auth_key + + +def Sign(value): + sig = hmac.new(GetAuthKey(), str(value), hashlib.sha512) + return '%s:%s' % (value, sig.hexdigest()) + + +def Parse(token): + if not token: + return None + value, sig_digest = token.split(':', 1) + if token != Sign(value): + raise BadSignature + return value + + +def ParseKey(token): + if not token: + return None + return db.Key(encoded=Parse(token)) diff --git a/lib/models.py b/lib/models.py new file mode 100644 index 0000000..1f6fc83 --- /dev/null +++ b/lib/models.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging + +from google.appengine.api import channel +from google.appengine.ext import db + + +# Profile +# ↳ Client +# ↳ StateEntry +# +# Subject +# ↳ Subscription (⤴︎ Client) + + +class Profile(db.Model): + google_user = db.UserProperty() + + @classmethod + def FromGoogleUser(cls, google_user): + if not google_user: + profile = Profile() + profile.put() + return profile + + profiles = Profile.all().filter('google_user =', google_user).fetch(1) + if profiles: + return profiles[0] + else: + # TODO(flamingcow): Fetch-then-store uniqueness is a race. + profile = Profile(google_user=google_user) + profile.put() + return profile + + @db.transactional(xg=True) + def MergeFrom(self, source_profile): + # Merge from another profile into this one, using last_set time as the + # arbiter. + my_states = {} + for state_entry in (StateEntry.all() + .ancestor(self) + .run()): + my_states[state_entry.entry_key] = state_entry + + for state_entry in (StateEntry.all() + .ancestor(source_profile) + .run()): + my_state_entry = my_states.get(state_entry.entry_key, None) + if my_state_entry: + if state_entry.last_set > my_state_entry.last_set: + # newer, merge in + my_state_entry.entry_value = state_entry.entry_value + my_state_entry.put() + else: + # entirely new, add + StateEntry(parent=self, + entry_key=state_entry.entry_key, + entry_value=state_entry.entry_value + ).put() + + +class Client(db.Model): + first_seen = db.DateTimeProperty(required=True, auto_now_add=True) + channel_active = db.BooleanProperty(required=True, default=False) + + @classmethod + def FromProfile(cls, profile): + client = cls(parent=profile) + client.put() + return client + + @classmethod + def FromGoogleUser(cls, google_user): + profile = Profile.FromGoogleUser(google_user) + return cls.FromProfile(profile) + + +class StateEntry(db.Model): + last_set = db.DateTimeProperty(required=True, auto_now=True) + entry_key = db.StringProperty(required=True) + entry_value = db.StringProperty(required=True) + + def SendToClient(self, client_id): + channel.send_message(str(client_id), json.dumps({ + 'message_type': 'state', + 'key': self.entry_key, + 'value': self.entry_value, + })) + + +class Subject(db.Model): + name = db.StringProperty(required=True) + + +class Subscription(db.Model): + client = db.ReferenceProperty(reference_class=Client) diff --git a/lib/security.py b/lib/security.py new file mode 100644 index 0000000..7e2da53 --- /dev/null +++ b/lib/security.py @@ -0,0 +1,70 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools + +from google.appengine.api import users + +from cosmopolite.lib import auth + + +def google_user_xsrf_protection(handler): + """Validate google user cookie. + + We can't trust that the action being requested is being made by this user due + to XSRF concerns (since google user is stored in a cookie). We have to make + sure that this user can actually receive responses, so we ask them to pass a + second token about their user that we can validate. + """ + + @functools.wraps(handler) + def ValidateGoogleUser(self): + self.verified_google_user = None + + google_user = users.get_current_user() + if not google_user: + return handler(self) + + google_user_id = auth.Parse(self.request.get('google_user_id', None)) + if (not google_user_id or + google_user_id != google_user.user_id()): + return { + 'status': 'retry', + 'google_user_id': auth.Sign(google_user.user_id()), + } + + self.verified_google_user = google_user + return handler(self) + + return ValidateGoogleUser + + +def weak_security_checks(handler): + + @functools.wraps(handler) + def CheckOriginHeader(self): + origin = self.request.headers.get('Origin') + if origin: + host = self.request.headers.get('Host') + possible_origins = { + 'http://%s' % host, + 'https://%s' % host, + } + if origin not in possible_origins: + self.error(403) + self.response.out.write('Origin/Host header mismatch') + return + return handler(self) + + return CheckOriginHeader diff --git a/lib/session.py b/lib/session.py new file mode 100644 index 0000000..31e5eb4 --- /dev/null +++ b/lib/session.py @@ -0,0 +1,88 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools + +from google.appengine.api import users + +from cosmopolite.lib import auth +from cosmopolite.lib import models + + +def _CheckClientAndGoogleUser(client, google_user): + if not google_user: + # Nothing to check. If there's a user on the profile, it can stay there. + return client + + client_profile_google_user = client.parent().google_user + if client_profile_google_user: + if client_profile_google_user == google_user: + return client + else: + # Shared computer? Google account wins. + return models.Client.FromGoogleUser(google_user) + + # User just signed in. Their anonymous profile gets permanently + # associated with this Google account. + profiles = (models.Profile.all() + .filter('google_user =', google_user) + .fetch(1)) + if profiles: + # We can't convert the anonymous profile because there's already + # a profile for this Google user. Create a new client_id pointing to that + # profile. + # TODO(flamingcow): Fetch-then-store uniqueness is a race. + google_profile = profiles[0] + google_profile.MergeFrom(client.parent_key()) + return models.Client.FromProfile(google_profile) + + # First time signin. + client_profile = client.parent() + client_profile.google_user = google_user + client_profile.put() + return client + + +def session_required(handler): + """Find or create a session for this user. + + Find or create a Client and Profile for this user. Muck with the return value + to wrap it in an object that contains session info for the client. + + Make sure to wrap this in google_user_xsrf_protection. + """ + + @functools.wraps(handler) + def FindOrCreateSession(self): + client_key = auth.ParseKey(self.request.get('client_id', None)) + + # The hunt for a Profile begins. + if client_key: + self.client = _CheckClientAndGoogleUser( + models.Client.get(client_key), + self.verified_google_user) + else: + self.client = models.Client.FromGoogleUser(self.verified_google_user) + + ret = { + 'status': 'ok', + 'response': handler(self), + } + if client_key != self.client.key(): + # Tell the client that this changed + ret['client_id'] = auth.Sign(self.client.key()) + + return ret + + return FindOrCreateSession diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 0000000..54363ad --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,56 @@ +# Copyright 2014, Ian Gulliver +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import json +import random + +from google.appengine.api import namespace_manager + +from cosmopolite import config + +from cosmopolite.lib import auth + + +def returns_json(handler): + + @functools.wraps(handler) + def SerializeResult(self): + json.dump(handler(self), self.response.out) + + return SerializeResult + + +def chaos_monkey(handler): + + @functools.wraps(handler) + def IntroduceFailures(self): + if random.random() < config.CHAOS_PROBABILITY: + self.response.headers['Retry-After'] = '0' + self.error(503) + return + return handler(self) + + return IntroduceFailures + + +def local_namespace(handler): + + @functools.wraps(handler) + def SetNamespace(self): + import logging + namespace_manager.set_namespace(config.NAMESPACE) + return handler(self) + + return SetNamespace diff --git a/static/cosmopolite.js b/static/cosmopolite.js new file mode 100644 index 0000000..92dd124 --- /dev/null +++ b/static/cosmopolite.js @@ -0,0 +1,211 @@ +/* +Copyright 2014, Ian Gulliver + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var cosmopolite = {}; + +cosmopolite.Client = function(opt_callbacks, opt_urlPrefix, opt_namespace) { + this.callbacks_ = opt_callbacks || {}; + this.urlPrefix_ = opt_urlPrefix || '/cosmopolite'; + this.namespace_ = opt_namespace || 'cosmopolite'; + + this.stateCache_ = {}; + + var scripts = [ + 'https://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js', + '/_ah/channel/jsapi', + ]; + this.numScriptsToLoad_ = scripts.length; + for (var i = 0; i < scripts.length; i++) { + var script = document.createElement('script'); + script.src = scripts[i]; + script.onload = this.onLoad_.bind(this); + document.body.appendChild(script); + } +}; + +cosmopolite.Client.prototype.onLoad_ = function() { + if (--this.numScriptsToLoad_ > 0) { + return; + } + this.$ = jQuery.noConflict(true); + this.registerMessageHandlers_(); + this.getUser_(); + this.createChannel_(); +}; + +// Message from another browser window +cosmopolite.Client.prototype.onReceiveMessage_ = function(data) { + switch (data) { + case 'login_complete': + this.getUser_(); + break; + case 'logout_complete': + localStorage.removeItem(this.namespace_ + ':client_id'); + localStorage.removeItem(this.namespace_ + ':google_user_id'); + this.$('#google_user').empty(); + this.getUser_(); + break; + default: + console.log('Unknown message type'); + break; + } +}; + +cosmopolite.Client.prototype.registerMessageHandlers_ = function() { + this.$(window).on('message', this.$.proxy(function(e) { + if (e.originalEvent.origin != window.location.origin) { + console.log( + 'Received message from bad origin: ' + e.originalEvent.origin); + return; + } + console.log('Received message: ' + e.originalEvent.data); + this.onReceiveMessage_(e.originalEvent.data); + }, this)); +}; + +cosmopolite.Client.prototype.sendRPC_ = function(command, data, onSuccess, delay) { + if (this.namespace_ + ':client_id' in localStorage) { + data['client_id'] = localStorage[this.namespace_ + ':client_id']; + } + if (this.namespace_ + ':google_user_id' in localStorage) { + data['google_user_id'] = localStorage[this.namespace_ + ':google_user_id']; + } + this.$.ajax({ + url: this.urlPrefix_ + '/api/' + command, + type: 'post', + data: data, + dataType: 'json', + context: this, + }) + .done(function(data, stat, xhr) { + if ('google_user_id' in data) { + localStorage[this.namespace_ + ':google_user_id'] = + data['google_user_id']; + } + if ('client_id' in data) { + localStorage[this.namespace_ + ':client_id'] = data['client_id']; + } + if (data['status'] == 'retry') { + // Discard delay + this.sendRPC_(command, data, onSuccess); + return; + } + if (data['status'] != 'ok') { + console.log( + 'Server returned unknown status (' + data['status'] + ') for RPC ' + + command); + // TODO(flamingcow): Refresh the page? Show an alert? + return; + } + if (onSuccess) { + this.$.proxy(onSuccess, this)(data.response); + } + }) + .fail(function(xhr) { + var intDelay = + xhr.getResponseHeader('Retry-After') || + Math.min(32, Math.max(2, delay || 2)); + console.log( + 'RPC ' + command + ' failed. Will retry in ' + intDelay + ' seconds'); + function retry() { + this.sendRPC_(command, data, onSuccess, Math.pow(intDelay, 2)); + } + window.setTimeout(this.$.proxy(retry, this), intDelay * 1000); + }); +}; + +cosmopolite.Client.prototype.getUser_ = function() { + this.sendRPC_('getUser', {}, function(data) { + if ('google_user' in data) { + if ('onLogin' in this.callbacks_) { + this.callbacks_['onLogin']( + data['google_user'], + this.urlPrefix_ + '/auth/logout'); + } + } else { + if ('onLogout' in this.callbacks_) { + this.callbacks_['onLogout']( + this.urlPrefix_ + '/auth/login'); + } + } + }); +}; + +cosmopolite.Client.prototype.setValue = function(key, value) { + this.sendRPC_('setValue', { + 'key': key, + 'value': value, + }) +}; + +cosmopolite.Client.prototype.getValue = function(key) { + return this.stateCache_[key]; +}; + +cosmopolite.Client.prototype.createChannel_ = function() { + this.sendRPC_('createChannel', {}, this.onCreateChannel_); +}; + +cosmopolite.Client.prototype.onCreateChannel_ = function(data) { + var channel = new goog.appengine.Channel(data['token']); + console.log('Opening channel...'); + this.socket = channel.open({ + onopen: this.$.proxy(this.onSocketOpen_, this), + onclose: this.$.proxy(this.onSocketClose_, this), + onmessage: this.$.proxy(this.onSocketMessage_, this), + onerror: this.$.proxy(this.onSocketError_, this), + }); +}; + +cosmopolite.Client.prototype.onSocketOpen_ = function() { + console.log('Channel opened'); +}; + +cosmopolite.Client.prototype.onSocketClose_ = function() { + if (!this.socket) { + return; + } + console.log('Channel closed'); + this.socket = null; + this.createChannel_(); +}; + +cosmopolite.Client.prototype.onSocketMessage_ = function(msg) { + console.log('message: ' + msg.data); + var parsed = JSON.parse(msg.data); + switch (parsed.message_type) { + case 'state': + var key = parsed['key']; + var value = parsed['value']; + if (this.stateCache_[key] == value) { + // Duplicate message. + break; + } + this.stateCache_[key] = value; + if ('onStateChange' in this.callbacks_) { + this.callbacks_['onStateChange'](key, value); + } + break; + default: + console.log('Unknown message type: ' + parsed.message_type); + break; + } +} + +cosmopolite.Client.prototype.onSocketError_ = function(msg) { + console.log('Socket error: ' + msg); + this.socket.close(); +}; diff --git a/static/debug.html b/static/debug.html new file mode 100644 index 0000000..77d87bd --- /dev/null +++ b/static/debug.html @@ -0,0 +1,79 @@ + + + + + + +
Google user:
+ +
+ Key: + + +
+ +
+ + + diff --git a/static/login_complete.html b/static/login_complete.html new file mode 100644 index 0000000..97c3674 --- /dev/null +++ b/static/login_complete.html @@ -0,0 +1,8 @@ + + + + + diff --git a/static/logout_complete.html b/static/logout_complete.html new file mode 100644 index 0000000..186a292 --- /dev/null +++ b/static/logout_complete.html @@ -0,0 +1,8 @@ + + + + +