diff --git a/development.ini b/development.ini index f8a50e2..13a2912 100644 --- a/development.ini +++ b/development.ini @@ -18,6 +18,9 @@ oauth.twitter.com.consumer_secret = prh6A961516mJ3XEjd7eERsGxuVZqycrBB6lV7LQ oauth.facebook.com.app_id = 158102624846 oauth.facebook.com.app_secret = 4203f7f23803f405e06509ec4d4b9729 +oauth.google.com.consumer_key = anonymous +oauth.google.com.consumer_secret = anonymous + [composite:main] use = egg:Paste#urlmap diff --git a/linkdrop/controllers/account.py b/linkdrop/controllers/account.py index bcdd626..7e27a7d 100644 --- a/linkdrop/controllers/account.py +++ b/linkdrop/controllers/account.py @@ -74,17 +74,16 @@ the contacts API that uses @me/@self. service = get_provider(provider) auth = service.responder() - access_key = auth.verify() - data = auth.get_credentials(access_key) - #import sys; print >> sys.stderr, data + user = auth.verify() + import sys; print >> sys.stderr, user - account = data['profile']['accounts'][0] + account = user['profile']['accounts'][0] acct = self._get_or_create_account(provider, account['userid'], account['username']) - acct.profile = data['profile'] - acct.oauth_token = data['oauth_token'] - if 'oauth_token_secret' in data: - acct.oauth_token_secret = data['oauth_token_secret'] + acct.profile = user['profile'] + acct.oauth_token = user.get('oauth_token', None) + if 'oauth_token_secret' in user: + acct.oauth_token_secret = user['oauth_token_secret'] Session.commit() fragment = "oauth_success_" + provider diff --git a/linkdrop/lib/oauth/__init__.py b/linkdrop/lib/oauth/__init__.py index 1a7fbd2..18f4fd0 100644 --- a/linkdrop/lib/oauth/__init__.py +++ b/linkdrop/lib/oauth/__init__.py @@ -1,5 +1,5 @@ from linkdrop.lib.oauth import facebook_ -#from linkdrop.lib.oauth.google_ import GoogleResponder +from linkdrop.lib.oauth import google_ #from linkdrop.lib.oauth.live_ import LiveResponder #from linkdrop.lib.oauth.openidconsumer import OpenIDResponder from linkdrop.lib.oauth import twitter_ @@ -10,7 +10,8 @@ __all__ = ['get_provider'] # XXX need a better way to do this _providers = { twitter_.domain: twitter_, - facebook_.domain: facebook_ + facebook_.domain: facebook_, + google_.domain: google_ } def get_provider(provider): diff --git a/linkdrop/lib/oauth/base.py b/linkdrop/lib/oauth/base.py index e479d08..e36dc51 100644 --- a/linkdrop/lib/oauth/base.py +++ b/linkdrop/lib/oauth/base.py @@ -78,9 +78,9 @@ class OAuth1(): redirect(session['end_point_auth_failure']) access_token = dict(urlparse.parse_qsl(content)) - return access_token + return self._get_credentials(access_token) - def get_credentials(self, access_token): + def _get_credentials(self, access_token): return access_token class OAuth2(): @@ -109,7 +109,7 @@ class OAuth2(): def verify(self): code = request.GET.get('code') if not code: - redirect(session['end_point_auth_failure']) + raise Exception("No oauth code received") return_to = url(controller='account', action="verify", qualified=True) @@ -121,10 +121,10 @@ class OAuth2(): client = httplib2.Http() resp, content = client.request(access_url) if resp['status'] != '200': - raise Exception("Error status: %r", resp['status']) + raise Exception("Error status: %r", resp['status']) access_token = parse_qs(content)['access_token'][0] - return access_token + return self._get_credentials(access_token) - def get_credentials(self, access_token): + def _get_credentials(self, access_token): return access_token diff --git a/linkdrop/lib/oauth/facebook_.py b/linkdrop/lib/oauth/facebook_.py index a0eeab8..7225f7b 100644 --- a/linkdrop/lib/oauth/facebook_.py +++ b/linkdrop/lib/oauth/facebook_.py @@ -97,7 +97,7 @@ class responder(OAuth2): self.authorization_url = 'https://graph.facebook.com/oauth/authorize' self.access_token_url = 'https://graph.facebook.com/oauth/access_token' - def get_credentials(self, access_token): + def _get_credentials(self, access_token): fields = 'id,first_name,last_name,name,link,birthday,email,website,verified,picture,gender,timezone' client = httplib2.Http() resp, content = client.request(url(self.profile_url, access_token=access_token, fields=fields)) diff --git a/linkdrop/lib/oauth/google_.py b/linkdrop/lib/oauth/google_.py new file mode 100644 index 0000000..4006486 --- /dev/null +++ b/linkdrop/lib/oauth/google_.py @@ -0,0 +1,96 @@ +"""Google Responder + +A Google responder that authenticates against Google using OpenID, or optionally +can use OpenId+OAuth hybrid protocol to request access to Google Apps using OAuth2. + +""" +import urlparse + +from openid.extensions import ax +import oauth2 as oauth + +from pylons import config, request, response, session, tmpl_context as c, url +from pylons.controllers.util import abort, redirect + +from linkdrop.lib.oauth.oid_extensions import OAuthRequest +from linkdrop.lib.oauth.oid_extensions import UIRequest +from linkdrop.lib.oauth.openidconsumer import ax_attributes, alternate_ax_attributes, attributes +from linkdrop.lib.oauth.openidconsumer import OpenIDResponder + +GOOGLE_OAUTH = 'https://www.google.com/accounts/OAuthGetAccessToken' + +domain = 'google.com' + +class responder(OpenIDResponder): + def __init__(self, consumer=None, oauth_key=None, oauth_secret=None, request_attributes=None, *args, + **kwargs): + """Handle Google Auth + + This also handles making an OAuth request during the OpenID + authentication. + + """ + OpenIDResponder.__init__(self, domain) + self.consumer_key = self.config.get('consumer_key') + self.consumer_secret = self.config.get('consumer_secret') + + def _lookup_identifier(self, identifier): + """Return the Google OpenID directed endpoint""" + return "https://www.google.com/accounts/o8/id" + + def _update_authrequest(self, authrequest): + """Update the authrequest with Attribute Exchange and optionally OAuth + + To optionally request OAuth, the request POST must include an ``oauth_scope`` + parameter that indicates what Google Apps should have access requested. + + """ + request_attributes = request.POST.get('ax_attributes', + ['country', 'email', 'first_name', 'last_name', 'language']) + ax_request = ax.FetchRequest() + for attr in request_attributes: + ax_request.add(ax.AttrInfo(attributes[attr], required=True)) + authrequest.addExtension(ax_request) + + oauth_request = OAuthRequest(consumer=self.consumer_key, scope=request.POST.get('scope', 'http://www.google.com/m8/feeds/')) + authrequest.addExtension(oauth_request) + + if 'popup_mode' in request.POST: + kw_args = {'mode': request.POST['popup_mode']} + if 'popup_icon' in request.POST: + kw_args['icon'] = request.POST['popup_icon'] + ui_request = UIRequest(**kw_args) + authrequest.addExtension(ui_request) + return None + + def _get_access_token(self, request_token): + """Retrieve the access token if OAuth hybrid was used""" + consumer = oauth.Consumer(self.consumer_key, self.consumer_secret) + token = oauth.Token(key=request_token, secret='') + client = oauth.Client(consumer, token) + resp, content = client.request(GOOGLE_OAUTH, "POST") + if resp['status'] != '200': + return None + + return dict(urlparse.parse_qsl(content)) + + def _get_credentials(self, result_data): + + #{'profile': {'preferredUsername': u'mixedpuppy', + # 'displayName': u'Shane Caraveo', + # 'name': + # {'givenName': u'Shane', + # 'formatted': u'Shane Caraveo', + # 'familyName': u'Caraveo'}, + # 'providerName': 'Google', + # 'verifiedEmail': u'mixedpuppy@gmail.com', + # 'identifier': 'https://www.google.com/accounts/o8/id?id=AItOawnEHbJcEY5EtwX7vf81_x2P4KUjha35VyQ'}} + profile = result_data['profile'] + userid = profile['verifiedEmail'] + username = profile['preferredUsername'] + profile['emails'] = [{ 'value': userid, 'primary': True }] + account = {'domain': domain, + 'userid': userid, + 'username': username } + profile['accounts'] = [account] + return result_data diff --git a/linkdrop/lib/oauth/oid_extensions.py b/linkdrop/lib/oauth/oid_extensions.py new file mode 100644 index 0000000..b9a5242 --- /dev/null +++ b/linkdrop/lib/oauth/oid_extensions.py @@ -0,0 +1,38 @@ +"""OpenID Extensions + +Additional OpenID extensions for OAuth and UIRequest extensions. + +original code from velruse +""" +from openid import extension + +class UIRequest(extension.Extension): + """OpenID UI extension""" + ns_uri = 'http://specs.openid.net/extensions/ui/1.0' + ns_alias = 'ui' + + def __init__(self, mode=None, icon=False): + super(UIRequest, self).__init__() + self._args = {} + if mode: + self._args['mode'] = mode + if icon: + self._args['icon'] = str(icon).lower() + + def getExtensionArgs(self): + return self._args + + +class OAuthRequest(extension.Extension): + """OAuth extension""" + ns_uri = 'http://specs.openid.net/extensions/oauth/1.0' + ns_alias = 'oauth' + + def __init__(self, consumer, scope=None): + super(OAuthRequest, self).__init__() + self._args = {'consumer': consumer} + if scope: + self._args['scope'] = scope + + def getExtensionArgs(self): + return self._args diff --git a/linkdrop/lib/oauth/openidconsumer.py b/linkdrop/lib/oauth/openidconsumer.py new file mode 100644 index 0000000..843c384 --- /dev/null +++ b/linkdrop/lib/oauth/openidconsumer.py @@ -0,0 +1,319 @@ +import logging +import re + +from openid.consumer import consumer +from openid.extensions import ax, sreg, pape +from openid.store import memstore, filestore, sqlstore + +from pylons import config, request, response, session, tmpl_context as c, url +from pylons.controllers.util import abort, redirect + +from linkdrop.lib.oauth.base import get_oauth_config + +log = logging.getLogger(__name__) + +__all__ = ['OpenIDResponder'] + + +# Setup our attribute objects that we'll be requesting +ax_attributes = dict( + nickname = 'http://axschema.org/namePerson/friendly', + email = 'http://axschema.org/contact/email', + full_name = 'http://axschema.org/namePerson', + birthday = 'http://axschema.org/birthDate', + gender = 'http://axschema.org/person/gender', + postal_code = 'http://axschema.org/contact/postalCode/home', + country = 'http://axschema.org/contact/country/home', + timezone = 'http://axschema.org/pref/timezone', + language = 'http://axschema.org/pref/language', + name_prefix = 'http://axschema.org/namePerson/prefix', + first_name = 'http://axschema.org/namePerson/first', + last_name = 'http://axschema.org/namePerson/last', + middle_name = 'http://axschema.org/namePerson/middle', + name_suffix = 'http://axschema.org/namePerson/suffix', + web = 'http://axschema.org/contact/web/default', +) + +#Change names later to make things a little bit clearer +alternate_ax_attributes = dict( + nickname = 'http://schema.openid.net/namePerson/friendly', + email = 'http://schema.openid.net/contact/email', + full_name = 'http://schema.openid.net/namePerson', + birthday = 'http://schema.openid.net/birthDate', + gender = 'http://schema.openid.net/person/gender', + postal_code = 'http://schema.openid.net/contact/postalCode/home', + country = 'http://schema.openid.net/contact/country/home', + timezone = 'http://schema.openid.net/pref/timezone', + language = 'http://schema.openid.net/pref/language', + name_prefix = 'http://schema.openid.net/namePerson/prefix', + first_name = 'http://schema.openid.net/namePerson/first', + last_name = 'http://schema.openid.net/namePerson/last', + middle_name = 'http://schema.openid.net/namePerson/middle', + name_suffix = 'http://schema.openid.net/namePerson/suffix', + web = 'http://schema.openid.net/contact/web/default', +) + +# Translation dict for AX attrib names to sreg equiv +trans_dict = dict( + full_name = 'fullname', + birthday = 'dob', + postal_code = 'postcode', +) + +attributes = ax_attributes + + +class AttribAccess(object): + """Uniform attribute accessor for Simple Reg and Attribute Exchange values""" + def __init__(self, sreg_resp, ax_resp): + self.sreg_resp = sreg_resp or {} + self.ax_resp = ax_resp or ax.AXKeyValueMessage() + + def get(self, key, ax_only=False): + """Get a value from either Simple Reg or AX""" + # First attempt to fetch it from AX + v = self.ax_resp.getSingle(attributes[key]) + if v: + return v + if ax_only: + return None + + # Translate the key if needed + if key in trans_dict: + key = trans_dict[key] + + # Don't attempt to fetch keys that aren't valid sreg fields + if key not in sreg.data_fields: + return None + + return self.sreg_resp.get(key) + + +def extract_openid_data(identifier, sreg_resp, ax_resp): + """Extract the OpenID Data from Simple Reg and AX data + + This normalizes the data to the appropriate format. + + """ + attribs = AttribAccess(sreg_resp, ax_resp) + + ud = {'identifier': identifier} + if 'google.com' in identifier: + ud['providerName'] = 'Google' + elif 'yahoo.com' in identifier: + ud['providerName'] = 'Yahoo' + else: + ud['providerName'] = 'OpenID' + + # Sort out the display name and preferred username + if ud['providerName'] == 'Google': + # Extract the first bit as the username since Google doesn't return + # any usable nickname info + email = attribs.get('email') + if email: + ud['preferredUsername'] = re.match('(^.*?)@', email).groups()[0] + else: + ud['preferredUsername'] = attribs.get('nickname') + + # We trust that Google and Yahoo both verify their email addresses + if ud['providerName'] in ['Google', 'Yahoo']: + ud['verifiedEmail'] = attribs.get('email', ax_only=True) + else: + ud['emails'] = [attribs.get('email')] + + # Parse through the name parts, assign the properly if present + name = {} + name_keys = ['name_prefix', 'first_name', 'middle_name', 'last_name', 'name_suffix'] + pcard_map = {'first_name': 'givenName', 'middle_name': 'middleName', 'last_name': 'familyName', + 'name_prefix': 'honorificPrefix', 'name_suffix': 'honorificSuffix'} + full_name_vals = [] + for part in name_keys: + val = attribs.get(part) + if val: + full_name_vals.append(val) + name[pcard_map[part]] = val + full_name = ' '.join(full_name_vals).strip() + if not full_name: + full_name = attribs.get('full_name') + + name['formatted'] = full_name + ud['name'] = name + + ud['displayName'] = full_name or ud.get('preferredUsername') + + urls = attribs.get('web') + if urls: + ud['urls'] = [urls] + + for k in ['gender', 'birthday']: + ud[k] = attribs.get(k) + + # Now strip out empty values + for k, v in ud.items(): + if not v or (isinstance(v, list) and not v[0]): + del ud[k] + + return ud + + +class OpenIDResponder(): + """OpenID Consumer for handling OpenID authentication + """ + + def __init__(self, provider): + self.log_debug = logging.DEBUG >= log.getEffectiveLevel() + + self.config = get_oauth_config(provider) + + self.endpoint_regex = self.config.get('endpoint_regex') + + # application config items, dont use self.config + store = config.get('openid_store', 'mem') + if store==u"file": + store_file_path = config.get('openid_store_path', None) + self.openid_store = filestore.FileOpenIDStore(store_file_path) + elif store==u"mem": + self.openid_store = memstore.MemoryStore() + elif store==u"sql": + # TODO: This does not work as we need a connection, not a string + self.openid_store = sqlstore.SQLStore(sql_connstring, sql_associations_table, sql_connstring) + + + def _lookup_identifier(self, identifier): + """Extension point for inherited classes that want to change or set + a default identifier""" + return identifier + + def _update_authrequest(self, authrequest): + """Update the authrequest with the default extensions and attributes + we ask for + + This method doesn't need to return anything, since the extensions + should be added to the authrequest object itself. + + """ + # Add on the Attribute Exchange for those that support that + ax_request = ax.FetchRequest() + for attrib in attributes.values(): + ax_request.add(ax.AttrInfo(attrib)) + authrequest.addExtension(ax_request) + + # Form the Simple Reg request + sreg_request = sreg.SRegRequest( + optional=['nickname', 'email', 'fullname', 'dob', 'gender', 'postcode', + 'country', 'language', 'timezone'], + ) + authrequest.addExtension(sreg_request) + return None + + def _get_access_token(self, request_token): + """Called to exchange a request token for the access token + + This method doesn't by default return anything, other OpenID+Oauth + consumers should override it to do the appropriate lookup for the + access token, and return the access token. + + """ + return None + + def request_access(self): + log_debug = self.log_debug + if log_debug: + log.debug('Handling OpenID login') + + # Load default parameters that all Auth Responders take + session['end_point_success'] = request.POST['end_point_success'] + session['end_point_auth_failure'] = request.POST['end_point_auth_failure'] + openid_url = request.POST.get('openid_identifier') + + # Let inherited consumers alter the openid identifier if desired + openid_url = self._lookup_identifier(openid_url) + + if not openid_url or (self.endpoint_regex and not re.match(self.endpoint_regex, end_point)): + return redirect(session['end_point_auth_failure']) + + openid_session = {} + oidconsumer = consumer.Consumer(openid_session, self.openid_store) + + try: + authrequest = oidconsumer.begin(openid_url) + except consumer.DiscoveryFailure: + return redirect(session['end_point_auth_failure']) + + if authrequest is None: + return redirect(session['end_point_auth_failure']) + + # Update the authrequest + self._update_authrequest(authrequest) + + return_to = url(controller='account', action="verify", + qualified=True) + + # Ensure our session is saved for the id to persist + session['openid_session'] = openid_session + session.save() + + redirect_url = authrequest.redirectURL(realm=request.application_url, + return_to=return_to, + immediate=False) + return redirect(redirect_url) + + # OpenID 2.0 lets Providers request POST instead of redirect, this + # checks for such a request. + if authrequest.shouldSendRedirect(): + redirect_url = authrequest.redirectURL(realm=request.application_url, + return_to=return_to, + immediate=False) + return redirect(redirect_url) + else: + # XXX this will likely fail for now + html = authrequest.htmlMarkup(realm=request.application_url, return_to=return_to, + immediate=False) + return response(body=html) + + def verify(self): + """Handle incoming redirect from OpenID Provider""" + log_debug = self.log_debug + if log_debug: + log.debug('Handling processing of response from server') + + openid_session = session['openid_session'] + if not openid_session: + raise Exception("openid session missing") + + # Setup the consumer and parse the information coming back + oidconsumer = consumer.Consumer(openid_session, self.openid_store) + return_to = url(controller='account', action="verify", + qualified=True) + + info = oidconsumer.complete(request.params, return_to) + + if info.status == consumer.FAILURE: + raise Exception("consumer failure") + elif info.status == consumer.CANCEL: + raise Exception("consumer canceled") + elif info.status == consumer.SUCCESS: + openid_identity = info.identity_url + if info.endpoint.canonicalID: + # If it's an i-name, use the canonicalID as its secure even if + # the old one is compromised + openid_identity = info.endpoint.canonicalID + + user_data = extract_openid_data(identifier=openid_identity, + sreg_resp=sreg.SRegResponse.fromSuccessResponse(info), + ax_resp=ax.FetchResponse.fromSuccessResponse(info)) + result_data = {'profile': user_data} + # Did we get any OAuth info? + access_token = None + oauth = info.extensionResponse('http://specs.openid.net/extensions/oauth/1.0', False) + if oauth and 'request_token' in oauth: + access_token = self._get_access_token(oauth['request_token']) + if access_token: + result_data['oauth_token'] = access_token + + return self._get_credentials(result_data) + else: + raise Exception("unknown openid failure") + + def _get_credentials(self, access_token): + return access_token diff --git a/linkdrop/lib/oauth/twitter_.py b/linkdrop/lib/oauth/twitter_.py index 42d2c23..f73adcb 100644 --- a/linkdrop/lib/oauth/twitter_.py +++ b/linkdrop/lib/oauth/twitter_.py @@ -21,7 +21,7 @@ class responder(OAuth1): self.access_token_url = 'https://twitter.com/oauth/access_token' self.authorization_url = 'https://twitter.com/oauth/authenticate' - def get_credentials(self, access_token): + def _get_credentials(self, access_token): # XXX should call twitter.api.VerifyCredentials to get the user object # Setup the normalized poco contact object username = access_token['screen_name'] diff --git a/web/scratch/oauth/index.html b/web/scratch/oauth/index.html index 36de1ba..72b8931 100644 --- a/web/scratch/oauth/index.html +++ b/web/scratch/oauth/index.html @@ -156,6 +156,31 @@ + + + +