diff --git a/configure-aspen.py b/configure-aspen.py index 95857ae78b..5b0f1d4112 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -14,6 +14,10 @@ website.github_client_secret = os.environ['GITHUB_CLIENT_SECRET'].decode('ASCII') website.github_callback = os.environ['GITHUB_CALLBACK'].decode('ASCII') +website.twitter_consumer_key = os.environ['TWITTER_CONSUMER_KEY'].decode('ASCII') +website.twitter_consumer_secret = os.environ['TWITTER_CONSUMER_SECRET'].decode('ASCII') +website.twitter_callback = os.environ['TWITTER_CALLBACK'].decode('ASCII') + website.hooks.inbound_early.register(gittip.canonize) website.hooks.inbound_early.register(gittip.configure_payments) website.hooks.inbound_early.register(gittip.csrf.inbound) @@ -23,9 +27,10 @@ def add_stuff(request): - from gittip.networks import github + from gittip.networks import github, twitter request.context['__version__'] = gittip.__version__ request.context['username'] = None request.context['github'] = github + request.context['twitter'] = twitter website.hooks.inbound_early.register(add_stuff) diff --git a/gittip/networks/twitter.py b/gittip/networks/twitter.py index ef2c03e128..9ae5e0b731 100644 --- a/gittip/networks/twitter.py +++ b/gittip/networks/twitter.py @@ -1,7 +1,5 @@ import requests from aspen import json, log, Response -from aspen.website import Website -from aspen.utils import typecheck from gittip import db, networks @@ -13,34 +11,10 @@ def upsert(user_info): ) -def oauth_url(website, action, then=u""): - """Given a website object and a string, return a URL string. - - `action' is one of 'opt-in', 'lock' and 'unlock' - - `then' is either a twitter username or an URL starting with '/'. It's - where we'll send the user after we get the redirect back from - GitHub. - - """ - typecheck(website, Website, action, unicode, then, unicode) - assert action in [u'opt-in', u'lock', u'unlock'] - url = u"https://twitter.com/login/oauth/authorize?consumer_key=%s&redirect_uri=%s" - url %= (website.twitter_consumer_key, website.twitter_callback) - - # Pack action,then into data and base64-encode. Querystring isn't - # available because it's consumed by the initial GitHub request. - - data = u'%s,%s' % (action, then) - data = data.encode('UTF-8').encode('base64').decode('US-ASCII') - url += u'?data=%s' % data - return url - - def oauth_dance(website, qs): """Given a querystring, return a dict of user_info. - The querystring should be the querystring that we get from GitHub when + The querystring should be the querystring that we get from Twitter when we send the user to the return value of oauth_url above. See also: @@ -49,16 +23,16 @@ def oauth_dance(website, qs): """ - log("Doing an OAuth dance with Github.") + log("Doing an OAuth dance with Twitter.") - if 'error' in qs: - raise Response(500, str(qs['error'])) + if 'denied' in qs: + raise Response(500, str(qs['denied'])) data = { 'code': qs['code'].encode('US-ASCII') - , 'client_id': website.twitter_client_id - , 'client_secret': website.twitter_client_secret + , 'client_id': website.twitter_customer_key + , 'client_secret': website.twitter_customer_secret } - r = requests.post("https://twitter.com/login/oauth/access_token", data=data) + r = requests.post("https://api.twitter.com/oauth/access_token", data=data) assert r.status_code == 200, (r.status_code, r.text) back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX @@ -72,13 +46,13 @@ def oauth_dance(website, qs): ) assert r.status_code == 200, (r.status_code, r.text) user_info = json.loads(r.text) - log("Done with OAuth dance with Github for %s (%s)." + log("Done with OAuth dance with Twitter for %s (%s)." % (user_info['login'], user_info['id'])) return user_info -def resolve(screen_name): +def resolve(user_id): """Given str, return a participant_id. """ FETCH = """\ @@ -86,10 +60,10 @@ def resolve(screen_name): SELECT participant_id FROM social_network_users WHERE network='twitter' - AND user_info -> 'screen_namec' = %s + AND user_info -> 'user_id' = %s """ # XXX Uniqueness constraint on screen_name? - rec = db.fetchone(FETCH, (screen_name,)) + rec = db.fetchone(FETCH, (user_id,)) if rec is None: - raise Exception("Twitter user %s has no participant." % (screen_name)) + raise Exception("Twitter user %s has no participant." % (user_id)) return rec['participant_id'] diff --git a/requirements.txt b/requirements.txt index e56761b08f..67e87b6537 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,6 @@ ./vendor/mock-0.8.0.tar.gz ./vendor/balanced-0.8.18.tar.gz +./vendor/requests-oauth-0.4.1.tar.gz + ./ diff --git a/vendor/requests-oauth-0.4.1.tar.gz b/vendor/requests-oauth-0.4.1.tar.gz new file mode 100644 index 0000000000..4c9c0fb315 Binary files /dev/null and b/vendor/requests-oauth-0.4.1.tar.gz differ diff --git a/www/on/twitter/%screen_name/lock-fail.html b/www/on/twitter/%screen_name/lock-fail.html new file mode 100644 index 0000000000..00e132fdb3 --- /dev/null +++ b/www/on/twitter/%screen_name/lock-fail.html @@ -0,0 +1,16 @@ +username = path['login'] +^L +{% extends templates/base.html %} + +{% block body %} + +
+

Are you really {{ username }}?

+ +

Your attempt to lock or unlock this account failed because you’re +logged into GitHub as someone else. Please log out of GitHub and try again.

+
+ +{% end %} diff --git a/www/on/twitter/associate b/www/on/twitter/associate new file mode 100644 index 0000000000..1da9098f76 --- /dev/null +++ b/www/on/twitter/associate @@ -0,0 +1,97 @@ +"""Associate a Twitter account with a Gittip account. + +First we do the OAuth dance with Twitter. Once we've authenticated the user +against Twitter, we record them in our social_network_users table. This table +contains information for Twitter users whether or not they are explicit +participants in the Gittip community. + +""" +import requests +from oauth_hook import OAuthHook +from aspen import log, Response, json +from gittip import db +from gittip.networks import twitter, set_as_claimed +from gittip.authentication import User +from urlparse import parse_qs + +# ========================== ^L + +if 'denied' in qs: + request.redirect('/') + + +token = qs['oauth_token'] +secret, action, then = website.oauth_cache.pop(token) + + +oauth_hook = OAuthHook(token, secret, header_auth=True) +response = requests.post( "https://api.twitter.com/oauth/access_token" + , data={"oauth_verifier": qs['oauth_verifier']} + , hooks={'pre_request': oauth_hook} + ) +assert response.status_code == 200 + +qs = parse_qs(response.text) +token = qs['oauth_token'][0] +secret = qs['oauth_token_secret'][0] +user_id = qs['user_id'][0] + + +oauth_hook = OAuthHook(token, secret, header_auth=True) +response = requests.get( "https://api.twitter.com/1/users/show.json?user_id=%s" % user_id + , hooks={'pre_request': oauth_hook} + ) +user_info = json.loads(response.text) +assert response.status_code == 200 + + +# Load Twitter user info. + +if action not in [u'opt-in', u'lock', u'unlock']: + raise Response(400) + +# Make sure we have a Twitter screen_name. +screen_name = user_info.get('screen_name') +if screen_name is None: + log(u"We got a user_info from Twitter with no screen_name [%s, %s]" + % (action, then)) + raise Response(400) + +# Do something. +log(u"%s wants to %s" % (screen_name, action)) +if action == 'opt-in': # opt in + participant_id, is_claimed, is_locked, balance = twitter.upsert(user_info) + set_as_claimed(participant_id) + user = User.from_id(participant_id) # give them a session +else: # lock or unlock + if then != screen_name: + + # The user could spoof `then' to match their screen_name, but the most + # they can do is lock/unlock their own Twitter account in a convoluted + # way. + + then = u'/on/twitter/%s/lock-fail.html' % then + + else: + + # Associate the Twitter screen_name with a randomly-named, unclaimed + # Gittip participant. + + participant_id, is_claimed, is_locked, balance \ + = twitter.upsert(user_info) + assert participant_id != screen_name, screen_name # sanity check + + db.execute( "UPDATE social_network_users " + "SET is_locked=%s " + "WHERE participant_id=%s" + , (action == 'lock', participant_id) + ) + +if then == u'': + then = u'/%s/' % participant_id +if not then.startswith(u'/'): + # Interpret it as a Twitter screen_name. + then = u'/on/twitter/%s/' % then +request.redirect(then) + +# ========================== ^L text/plain diff --git a/www/on/twitter/redirect b/www/on/twitter/redirect new file mode 100644 index 0000000000..4696d25bfe --- /dev/null +++ b/www/on/twitter/redirect @@ -0,0 +1,32 @@ +from urlparse import parse_qs + +import requests +from oauth_hook import OAuthHook + +OAuthHook.consumer_key = website.twitter_consumer_key +OAuthHook.consumer_secret = website.twitter_consumer_secret + +website.oauth_cache = {} + +# ========================== ^L + +oauth_hook = OAuthHook(header_auth=True) +response = requests.post( "https://api.twitter.com/oauth/request_token" + , hooks={'pre_request': oauth_hook} + ) + +assert response.status_code == 200, response.status_code # safety check + +qs = parse_qs(response.text) + +token = qs['oauth_token'][0] +secret = qs['oauth_token_secret'][0] +assert qs['oauth_callback_confirmed'][0] == "true" # sanity check + +action = qs.get('action', 'opt-in') +then = qs.get('then', '') +website.oauth_cache[token] = (secret, action, then) + +url = "https://api.twitter.com/oauth/authenticate?oauth_token=%s" +request.redirect(url % token) +# ========================== ^L text/plain