1
# ubuntuone.oauthdesktop.auth - Client authorization module
3
# Author: Stuart Langridge <stuart.langridge@canonical.com>
5
# Copyright 2009 Canonical Ltd.
7
# This program is free software: you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License version 3, as published
9
# by the Free Software Foundation.
11
# This program is distributed in the hope that it will be useful, but
12
# WITHOUT ANY WARRANTY; without even the implied warranties of
13
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
14
# PURPOSE. See the GNU General Public License for more details.
16
# You should have received a copy of the GNU General Public License along
17
# with this program. If not, see <http://www.gnu.org/licenses/>.
18
"""OAuth client authorisation code.
20
This code handles acquisition of an OAuth access token for a service,
21
managed through the GNOME keyring for future use, and asynchronously.
30
import socket, httplib, urllib
33
from oauth import oauth
35
from ubuntuone.clientdefs import VERSION
36
ubuntuone_client_version = VERSION
38
ubuntuone_client_version = "Unknown"
39
from ubuntuone.oauthdesktop.key_acls import set_all_key_acls
41
from threading import Thread
42
from twisted.internet import reactor
43
from twisted.web import server, resource
45
from ubuntuone.oauthdesktop.logger import setupLogging
46
logger = setupLogging("UbuntuOne.OAuthDesktop.auth")
49
class NoAccessToken(Exception):
50
"""No access token available."""
52
# NetworkManager State constants
55
NM_STATE_CONNECTING = 2
56
NM_STATE_CONNECTED = 3
57
NM_STATE_DISCONNECTED = 4
59
# Monkeypatch httplib so that urllib will fail on invalid certificate
60
# Only patch if we can import ssl to work around fail in 2.5
66
def _connect_wrapper(self):
67
"""Override HTTPSConnection.connect to require certificate checks"""
68
sock = socket.create_connection((self.host, self.port), self.timeout)
73
except AttributeError:
75
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
76
cert_reqs=ssl.CERT_REQUIRED,
77
ca_certs="/etc/ssl/certs/ca-certificates.crt")
78
httplib.HTTPSConnection.connect = _connect_wrapper
81
class FancyURLOpenerWithRedirectedPOST(urllib.FancyURLopener):
82
"""FancyURLopener does not redirect postdata when redirecting POSTs"""
83
version = "Ubuntu One/Login (%s)" % ubuntuone_client_version
84
def redirect_internal(self, url, fp, errcode, errmsg, headers, data):
85
"""Actually perform a redirect"""
86
# All the same as the original, except passing data, below
87
if 'location' in headers:
88
newurl = headers['location']
89
elif 'uri' in headers:
90
newurl = headers['uri']
95
# In case the server sent a relative URL, join with original:
96
newurl = urllib.basejoin(self.type + ":" + url, newurl)
98
# pass data if present when we redirect
100
return self.open(newurl, data)
102
return self.open(newurl)
104
class AuthorisationClient(object):
105
"""OAuth authorisation client."""
106
def __init__(self, realm, request_token_url, user_authorisation_url,
107
access_token_url, consumer_key, consumer_secret,
108
callback_parent, callback_denied=None,
109
callback_notoken=None, callback_error=None, do_login=True,
110
keyring=gnomekeyring):
111
"""Create an `AuthorisationClient` instance.
113
@param realm: the OAuth realm.
114
@param request_token_url: the OAuth request token URL.
115
@param user_authorisation_url: the OAuth user authorisation URL.
116
@param access_token_url: the OAuth access token URL.
117
@param consumer_key: the OAuth consumer key.
118
@param consumer_secret: the OAuth consumer secret.
119
@param callback_parent: a function in the includer to call with a token
121
The preceding parameters are defined in sections 3 and 4.1 of the
122
OAuth Core 1.0 specification. The following parameters are not:
124
@param callback_denied: a function to call if no token is available
125
@param do_login: whether to create a token if one is not cached
126
@param keychain: the keyring object to use (defaults to gnomekeyring)
130
self.request_token_url = request_token_url
131
self.user_authorisation_url = user_authorisation_url
132
self.access_token_url = access_token_url
133
self.consumer = oauth.OAuthConsumer(consumer_key, consumer_secret)
134
self.callback_parent = callback_parent
135
self.callback_denied = callback_denied
136
self.callback_notoken = callback_notoken
137
self.callback_error = callback_error
138
self.do_login = do_login
139
self.request_token = None
140
self.saved_acquire_details = (None, None, None)
141
self.keyring = keyring
142
logger.debug("auth.AuthorisationClient created with parameters "+ \
143
"realm='%s', request_token_url='%s', user_authorisation_url='%s',"+\
144
"access_token_url='%s', consumer_key='%s', callback_parent='%s'",
145
realm, request_token_url, user_authorisation_url, access_token_url,
146
consumer_key, callback_parent)
148
def _get_keyring_items(self):
149
"""Raw interface to obtain keyring items."""
150
return self.keyring.find_items_sync(gnomekeyring.ITEM_GENERIC_SECRET,
151
{'ubuntuone-realm': self.realm,
152
'oauth-consumer-key':
155
def _forward_error_callback(self, error):
156
"""Forward an error through callback_error()"""
157
if self.callback_error:
158
self.callback_error(str(error))
162
def get_access_token(self):
163
"""Get the access token from the keyring.
165
If no token is available in the keyring, `NoAccessToken` is raised.
167
logger.debug("Trying to fetch the token from the keyring")
169
items = self._get_keyring_items()
170
except (gnomekeyring.NoMatchError,
171
gnomekeyring.DeniedError):
172
logger.debug("Access token was not in the keyring")
173
raise NoAccessToken("No access token found.")
174
logger.debug("Access token successfully found in the keyring")
175
return oauth.OAuthToken.from_string(items[0].secret)
177
def clear_token(self):
178
"""Clear any stored tokens from the keyring."""
179
logger.debug("Searching keyring for existing tokens to delete.")
181
items = self._get_keyring_items()
182
except (gnomekeyring.NoMatchError,
183
gnomekeyring.DeniedError):
184
logger.debug("No preexisting tokens found")
186
logger.debug("Deleting %s tokens from the keyring" % len(items))
189
self.keyring.item_delete_sync(None, item.item_id)
190
except gnomekeyring.DeniedError:
191
logger.debug("Permission denied deleting token")
193
def store_token(self, access_token):
194
"""Store the given access token in the keyring.
196
The keyring item is identified by the OAuth realm and consumer
197
key to support multiple instances.
199
logger.debug("Trying to store the token in the keyring")
201
item_id = self.keyring.item_create_sync(
203
gnomekeyring.ITEM_GENERIC_SECRET,
204
'UbuntuOne token for %s' % self.realm,
205
{'ubuntuone-realm': self.realm,
206
'oauth-consumer-key': self.consumer.key},
207
access_token.to_string(),
209
except gnomekeyring.DeniedError:
210
logger.debug("Permission denied storing token")
212
# set ACLs on the key for all apps listed in xdg BaseDir, but only
213
# the root level one, not the user-level one
214
logger.debug("Setting ACLs on the token in the keyring")
215
set_all_key_acls(item_id=item_id)
217
# keyring seems to take a while to actually apply the change
218
# for when other people retrieve it, so sleep a bit.
219
# this ought to get fixed.
223
def have_access_token(self):
224
"""Returns true if an access token is available from the keyring."""
226
self.get_access_token()
227
except NoAccessToken:
232
def make_token_request(self, oauth_request):
233
"""Perform the given `OAuthRequest` and return the associated token."""
235
logger.debug("Making a token request")
236
# Note that we monkeypatched httplib above to handle invalid certs
237
# Ways this urlopen can fail:
239
# raises IOError, e.args[1] == SSLError, e.args[1].errno == 1
241
# raises IOError, e.args[1] == SSLError, e.args[1].errno == -2
243
opener = FancyURLOpenerWithRedirectedPOST()
244
fp = opener.open(oauth_request.http_url, oauth_request.to_postdata())
247
self._forward_error_callback(e)
250
# we deliberately trap anything that might go wrong when parsing the
251
# token, because we do not want this to explicitly fail
252
# pylint: disable-msg=W0702
254
out_token = oauth.OAuthToken.from_string(data)
255
logger.debug("Token successfully requested")
258
error = Exception(data)
259
logger.error("Token was not successfully retrieved: data was '%s'",
261
self._forward_error_callback(error)
263
def open_in_browser(self, url):
264
"""Open the given URL in the user's web browser."""
265
logger.debug("Opening '%s' in the browser", url)
266
p = subprocess.Popen(["xdg-open", url], bufsize=4096,
267
stderr=subprocess.PIPE)
269
if p.returncode != 0:
270
errors = "".join(p.stderr.readlines())
272
self._forward_error_callback(IOError(errors))
274
def acquire_access_token_if_online(self, description=None, store=False):
275
"""Check to see if we are online before trying to acquire"""
276
# Get NetworkManager state
277
logger.debug("Checking whether we are online")
279
nm = dbus.SystemBus().get_object('org.freedesktop.NetworkManager',
280
'/org/freedesktop/NetworkManager',
281
follow_name_owner_changes=True)
282
except dbus.exceptions.DBusException:
283
logger.warn("Unable to connect to NetworkManager. Trying anyway.")
284
self.acquire_access_token(description, store)
286
iface = dbus.Interface(nm, 'org.freedesktop.NetworkManager')
288
def got_state(state):
289
"""Handler for when state() call succeeds."""
290
if state == NM_STATE_CONNECTED:
291
logger.debug("We are online")
292
self.acquire_access_token(description, store)
293
elif state == NM_STATE_CONNECTING:
294
logger.debug("We are currently going online")
295
# attach to NM's StateChanged signal
296
signal_match = nm.connect_to_signal(
297
signal_name="StateChanged",
298
handler_function=self.connection_established,
299
dbus_interface="org.freedesktop.NetworkManager")
300
# stash the details so the handler_function can get at them
301
self.saved_acquire_details = (signal_match, description,
304
# NM is not connected: fail
305
logger.debug("We are not online")
307
def got_error(error):
308
"""Handler for D-Bus errors when calling state()."""
309
if error.get_dbus_name() == \
310
'org.freedesktop.DBus.Error.ServiceUnknown':
311
logger.debug("NetworkManager not available.")
312
self.acquire_access_token(description, store)
314
logger.error("Error contacting NetworkManager: %s" % \
318
iface.state(reply_handler=got_state, error_handler=got_error)
320
def connection_established(self, state):
321
"""NetworkManager's state has changed, and we're watching for
323
logger.debug("Online status has changed to %s" % state)
324
if int(state) == NM_STATE_CONNECTED:
325
signal_match, description, store = self.saved_acquire_details
326
# disconnect the signal so we don't get called again
327
signal_match.remove()
328
# call the real acquire_access_token now it has a connection
329
logger.debug("Correctly connected: now starting auth process")
330
self.acquire_access_token(description, store)
332
# connection changed but not to "connected", so keep waiting
333
logger.debug("Not yet connected: continuing to wait")
335
def acquire_access_token(self, description=None, store=False):
336
"""Create an OAuth access token authorised against the user."""
337
signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()
339
# Create a request token ...
340
logger.debug("Creating a request token to begin access request")
343
parameters['description'] = description
344
# Add a nonce to the query so we know the callback (to our temp
345
# webserver) came from us
346
nonce = random.randint(1000000, 10000000)
348
# start temporary webserver to receive browser response
349
callback_url = self.get_temporary_httpd(nonce,
350
self.retrieve_access_token, store)
352
oauth_request = oauth.OAuthRequest.from_consumer_and_token(
353
callback=callback_url,
354
http_url=self.request_token_url,
355
oauth_consumer=self.consumer,
356
parameters=parameters)
357
oauth_request.sign_request(signature_method, self.consumer, None)
358
logger.debug("Making token request")
359
self.request_token = self.make_token_request(oauth_request)
361
# Request authorisation from the user
362
oauth_request = oauth.OAuthRequest.from_token_and_callback(
363
http_url=self.user_authorisation_url,
364
token=self.request_token)
365
nodename = os.uname()[1]
367
oauth_request.set_parameter("description", nodename)
368
Thread(target=self.open_in_browser, name="authorization",
369
args=(oauth_request.to_url(),)).start()
371
def get_temporary_httpd(self, nonce, retrieve_function, store):
372
"A separate class so it can be mocked in testing"
373
logger.debug("Creating a listening temp web server")
374
site = TemporaryTwistedWebServer(nonce=nonce,
375
retrieve_function=retrieve_function, store_yes_no=store)
376
temphttpd = server.Site(site)
377
temphttpdport = reactor.listenTCP(0, temphttpd)
378
callback_url = "http://localhost:%s/?nonce=%s" % (
379
temphttpdport.getHost().port, nonce)
380
site.set_port(temphttpdport)
381
logger.debug("Webserver listening on port '%s'", temphttpdport)
384
def retrieve_access_token(self, store=False, verifier=None):
385
"""Retrieve the access token, once OAuth is done. This is a callback."""
386
logger.debug("Access token callback from temp webserver")
387
signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()
388
oauth_request = oauth.OAuthRequest.from_consumer_and_token(
389
http_url=self.access_token_url,
390
oauth_consumer=self.consumer,
391
token=self.request_token)
392
oauth_request.set_parameter("oauth_verifier", verifier)
393
oauth_request.sign_request(
394
signature_method, self.consumer, self.request_token)
395
logger.debug("Retrieving access token from OAuth")
396
access_token = self.make_token_request(oauth_request)
398
logger.error("Failed to get access token.")
399
if self.callback_denied is not None:
400
self.callback_denied()
403
logger.debug("Storing access token in keyring")
404
self.store_token(access_token)
405
logger.debug("Calling the callback_parent")
406
self.callback_parent(access_token)
408
def ensure_access_token(self, description=None):
409
"""Returns an access token, either from the keyring or newly acquired.
411
If a new token is acquired, it will be stored in the keyring
415
access_token = self.get_access_token()
416
self.callback_parent(access_token)
417
except NoAccessToken:
419
access_token = self.acquire_access_token_if_online(
423
if self.callback_notoken is not None:
424
self.callback_notoken()
427
class TemporaryTwistedWebServer(resource.Resource):
428
"""A temporary httpd for the oauth process to call back to"""
430
def __init__(self, nonce, store_yes_no, retrieve_function):
431
"""Initialize the temporary web server."""
432
resource.Resource.__init__(self)
434
self.store_yes_no = store_yes_no
435
self.retrieve_function = retrieve_function
436
reactor.callLater(600, self.stop) # ten minutes
438
def set_port(self, port):
439
"""Save the Twisted port object so we can stop it later"""
443
logger.debug("Stopping temp webserver")
444
self.port.stopListening()
445
def render_GET(self, request):
446
"""Handle incoming web requests"""
447
logger.debug("Incoming temp webserver hit received")
448
nonce = request.args.get("nonce", [None])[0]
449
url = request.args.get("return", ["https://one.ubuntu.com/"])[0]
450
verifier = request.args.get("oauth_verifier", [None])[0]
451
logger.debug("Got verifier %s" % verifier)
452
if nonce and (str(nonce) == str(self.nonce) and verifier):
453
self.retrieve_function(store=self.store_yes_no, verifier=verifier)
454
reactor.callLater(3, self.stop)
455
return """<!doctype html>
456
<html><head><meta http-equiv="refresh"
457
content="0;url=%(url)s">
460
<p>You should now automatically <a
461
href="%(url)s">return to %(url)s</a>.</p>
464
""" % { 'url' : url }
466
self.retrieve_function(store=self.store_yes_no, verifier=verifier)
467
reactor.callLater(3, self.stop)
468
request.setResponseCode(400)
469
return """<!doctype html>
470
<html><head><title>Error</title></head>
472
<h1>There was an error</h1>
473
<p>The authentication process has not succeeded. This may be a
474
temporary problem; please try again in a few minutes.</p>