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.
33
from ubuntuone.storageprotocol import oauth
34
from ubuntuone.oauthdesktop.key_acls import set_all_key_acls
36
from twisted.internet import reactor
37
from twisted.web import server, resource
39
from ubuntuone.oauthdesktop.logger import setupLogging
42
logger = logging.getLogger("UbuntuOne.OAuthDesktop.auth")
45
class NoAccessToken(Exception):
46
"""No access token available."""
48
class ConsumerKeyInvalid(Exception):
49
"""OAuth said the consumer key was invalid."""
50
class UnknownLoginError(Exception):
51
"""An OAuth request failed for some reason."""
52
class NotOnlineError(Exception):
53
"""There is no network connection."""
55
# NetworkManager State constants
58
NM_STATE_CONNECTING = 2
59
NM_STATE_CONNECTED = 3
60
NM_STATE_DISCONNECTED = 4
62
class AuthorisationClient(object):
63
"""OAuth authorisation client."""
64
def __init__(self, realm, request_token_url, user_authorisation_url,
65
access_token_url, consumer_key, consumer_secret,
66
callback_parent, callback_denied=None, do_login=True,
67
keyring=gnomekeyring):
68
"""Create an `AuthorisationClient` instance.
70
@param realm: the OAuth realm.
71
@param request_token_url: the OAuth request token URL.
72
@param user_authorisation_url: the OAuth user authorisation URL.
73
@param access_token_url: the OAuth access token URL.
74
@param consumer_key: the OAuth consumer key.
75
@param consumer_secret: the OAuth consumer secret.
76
@param callback_parent: a function in the includer to call with a token
78
The preceding parameters are defined in sections 3 and 4.1 of the
79
OAuth Core 1.0 specification. The following parameters are not:
81
@param callback_denied: a function to call if no token is available
82
@param do_login: whether to create a token if one is not cached
83
@param keychain: the keyring object to use (defaults to gnomekeyring)
87
self.request_token_url = request_token_url
88
self.user_authorisation_url = user_authorisation_url
89
self.access_token_url = access_token_url
90
self.consumer = oauth.OAuthConsumer(consumer_key, consumer_secret)
91
self.callback_parent = callback_parent
92
self.callback_denied = callback_denied
93
self.do_login = do_login
94
self.request_token = None
95
self.saved_acquire_details = (None, None, None)
96
self.keyring = keyring
97
logger.debug("auth.AuthorisationClient created with parameters "+ \
98
"realm='%s', request_token_url='%s', user_authorisation_url='%s',"+\
99
"access_token_url='%s', consumer_key='%s', callback_parent='%s'",
100
realm, request_token_url, user_authorisation_url, access_token_url,
101
consumer_key, callback_parent)
103
def _get_keyring_items(self):
104
"""Raw interface to obtain keyring items."""
105
return self.keyring.find_items_sync(gnomekeyring.ITEM_GENERIC_SECRET,
106
{'ubuntuone-realm': self.realm,
107
'oauth-consumer-key':
110
def get_access_token(self):
111
"""Get the access token from the keyring.
113
If no token is available in the keyring, `NoAccessToken` is raised.
115
logger.debug("Trying to fetch the token from the keyring")
117
items = self._get_keyring_items()
118
except (gnomekeyring.NoMatchError,
119
gnomekeyring.DeniedError):
120
logger.debug("Access token was not in the keyring")
121
raise NoAccessToken("No access token found.")
122
logger.debug("Access token successfully found in the keyring")
123
return oauth.OAuthToken.from_string(items[0].secret)
125
def clear_token(self):
126
"""Clear any stored tokens from the keyring."""
127
logger.debug("Searching keyring for existing tokens to delete.")
129
items = self._get_keyring_items()
130
except (gnomekeyring.NoMatchError,
131
gnomekeyring.DeniedError):
132
logger.debug("No preexisting tokens found")
134
logger.debug("Deleting %s tokens from the keyring" % len(items))
137
self.keyring.item_delete_sync(None, item.item_id)
138
except gnomekeyring.DeniedError:
139
logger.debug("Permission denied deleting token")
141
def store_token(self, access_token):
142
"""Store the given access token in the keyring.
144
The keyring item is identified by the OAuth realm and consumer
145
key to support multiple instances.
147
logger.debug("Trying to store the token in the keyring")
148
item_id = self.keyring.item_create_sync(
150
gnomekeyring.ITEM_GENERIC_SECRET,
151
'UbuntuOne token for %s' % self.realm,
152
{'ubuntuone-realm': self.realm,
153
'oauth-consumer-key': self.consumer.key},
154
access_token.to_string(),
157
# set ACLs on the key for all apps listed in xdg BaseDir, but only
158
# the root level one, not the user-level one
159
logger.debug("Setting ACLs on the token in the keyring")
160
set_all_key_acls(item_id=item_id)
162
# keyring seems to take a while to actually apply the change
163
# for when other people retrieve it, so sleep a bit.
164
# this ought to get fixed.
168
def have_access_token(self):
169
"""Returns true if an access token is available from the keyring."""
171
self.get_access_token()
172
except NoAccessToken:
177
def make_token_request(self, oauth_request):
178
"""Perform the given `OAuthRequest` and return the associated token."""
179
# uses pycurl because that will fail on self-signed certs
181
logger.debug("Making a token request")
182
accum = StringIO.StringIO()
184
c.setopt(c.URL, str(oauth_request.http_url)) # no unicode
185
c.setopt(c.WRITEFUNCTION, accum.write)
186
c.setopt(c.POSTFIELDS, oauth_request.to_postdata())
189
except pycurl.error, e:
190
logger.debug("There was some unknown login error '%s'", e)
191
raise UnknownLoginError(e.message)
195
# we deliberately trap anything that might go wrong when parsing the
196
# token, because we do not want this to explicitly fail
197
# pylint: disable-msg=W0702
199
out_token = oauth.OAuthToken.from_string(data)
200
logger.debug("Token successfully requested")
203
logger.debug("Token was not successfully retrieved: data was '%s'",
206
def open_in_browser(self, url):
207
"""Open the given URL in the user's web browser."""
208
logger.debug("Opening '%s' in the browser", url)
209
ret = subprocess.call(["xdg-open", url])
211
raise Exception("Failed to launch browser")
213
def acquire_access_token_if_online(self, description=None, store=False):
214
"""Check to see if we are online before trying to acquire"""
215
# Get NetworkManager state
216
logger.debug("Checking whether we are online")
218
nm = dbus.SystemBus().get_object('org.freedesktop.NetworkManager',
219
'/org/freedesktop/NetworkManager')
220
except dbus.exceptions.DBusException:
221
logger.warn("Unable to connect to NetworkManager. Trying anyway.")
222
self.acquire_access_token(description, store)
224
iface = dbus.Interface(nm, 'org.freedesktop.NetworkManager')
226
def got_state(state):
227
"""Handler for when state() call succeeds."""
228
if state == NM_STATE_CONNECTED:
229
logger.debug("We are online")
230
self.acquire_access_token(description, store)
231
elif state == NM_STATE_CONNECTING:
232
logger.debug("We are currently going online")
233
# attach to NM's StateChanged signal
234
signal_match = nm.connect_to_signal(
235
signal_name="StateChanged",
236
handler_function=self.connection_established,
237
dbus_interface="org.freedesktop.NetworkManager")
238
# stash the details so the handler_function can get at them
239
self.saved_acquire_details = (signal_match, description,
242
# NM is not connected: fail
243
logger.debug("We are not online")
244
raise NotOnlineError()
247
"""Handler for D-Bus errors when calling state()."""
248
logger.debug("Received D-Bus error")
249
raise NotOnlineError()
251
iface.state(reply_handler=got_state, error_handler=got_error)
253
def connection_established(self, state):
254
"""NetworkManager's state has changed, and we're watching for
256
logger.debug("Online status has changed to %s" % state)
257
if int(state) == NM_STATE_CONNECTED:
258
signal_match, description, store = self.saved_acquire_details
259
# disconnect the signal so we don't get called again
260
signal_match.remove()
261
# call the real acquire_access_token now it has a connection
262
logger.debug("Correctly connected: now starting auth process")
263
self.acquire_access_token(description, store)
265
# connection changed but not to "connected", so keep waiting
266
logger.debug("Not yet connected: continuing to wait")
268
def acquire_access_token(self, description=None, store=False):
269
"""Create an OAuth access token authorised against the user."""
270
signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()
272
# Create a request token ...
273
logger.debug("Creating a request token to begin access request")
276
parameters['description'] = description
277
oauth_request = oauth.OAuthRequest.from_consumer_and_token(
278
http_url=self.request_token_url,
279
oauth_consumer=self.consumer,
280
parameters=parameters)
281
oauth_request.sign_request(signature_method, self.consumer, None)
282
logger.debug("Making token request")
283
self.request_token = self.make_token_request(oauth_request)
285
# Request authorisation from the user
286
# Add a nonce to the query so we know the callback (to our temp
287
# webserver) came from us
288
nonce = random.randint(1000000, 10000000)
290
# start temporary webserver to receive browser response
291
callback_url = self.get_temporary_httpd(nonce,
292
self.retrieve_access_token, store)
293
oauth_request = oauth.OAuthRequest.from_token_and_callback(
294
http_url=self.user_authorisation_url,
295
token=self.request_token,
296
callback=callback_url)
297
self.open_in_browser(oauth_request.to_url())
299
def get_temporary_httpd(self, nonce, retrieve_function, store):
300
"A separate class so it can be mocked in testing"
301
logger.debug("Creating a listening temp web server")
302
site = TemporaryTwistedWebServer(nonce=nonce,
303
retrieve_function=retrieve_function, store_yes_no=store)
304
temphttpd = server.Site(site)
305
temphttpdport = reactor.listenTCP(0, temphttpd)
306
callback_url = "http://127.0.0.1:%s/?nonce=%s" % (
307
temphttpdport.getHost().port, nonce)
308
site.set_port(temphttpdport)
309
logger.debug("Webserver listening on port '%s'", temphttpdport)
312
def retrieve_access_token(self, store=False):
313
"""Retrieve the access token, once OAuth is done. This is a callback."""
314
logger.debug("Access token callback from temp webserver")
315
signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()
316
oauth_request = oauth.OAuthRequest.from_consumer_and_token(
317
http_url=self.access_token_url,
318
oauth_consumer=self.consumer,
319
token=self.request_token)
320
oauth_request.sign_request(
321
signature_method, self.consumer, self.request_token)
322
logger.debug("Retrieving access token from OAuth")
323
access_token = self.make_token_request(oauth_request)
325
logger.debug("Failed to get access token.")
326
if self.callback_denied is not None:
327
self.callback_denied()
330
logger.debug("Storing access token in keyring")
331
self.store_token(access_token)
332
logger.debug("Calling the callback_parent")
333
self.callback_parent(access_token)
335
def ensure_access_token(self, description=None):
336
"""Returns an access token, either from the keyring or newly acquired.
338
If a new token is acquired, it will be stored in the keyring
342
access_token = self.get_access_token()
343
self.callback_parent(access_token)
344
except NoAccessToken:
346
access_token = self.acquire_access_token_if_online(
350
if self.callback_denied is not None:
351
self.callback_denied()
354
class TemporaryTwistedWebServer(resource.Resource):
355
"""A temporary httpd for the oauth process to call back to"""
357
def __init__(self, nonce, store_yes_no, retrieve_function):
358
"""Initialize the temporary web server."""
359
resource.Resource.__init__(self)
361
self.store_yes_no = store_yes_no
362
self.retrieve_function = retrieve_function
363
reactor.callLater(600, self.stop) # ten minutes
365
def set_port(self, port):
366
"""Save the Twisted port object so we can stop it later"""
370
logger.debug("Stopping temp webserver")
371
self.port.stopListening()
372
def render_GET(self, request):
373
"""Handle incoming web requests"""
374
logger.debug("Incoming temp webserver hit received")
375
nonce = request.args.get("nonce", [None])[0]
376
token = request.args.get("oauth_token", [None])[0]
377
url = request.args.get("return", ["https://ubuntuone.com/"])[0]
378
if nonce and (str(nonce) == str(self.nonce)):
379
self.retrieve_function(self.store_yes_no)
380
reactor.callLater(3, self.stop)
381
return """<!doctype html>
382
<html><head><meta http-equiv="refresh"
383
content="0;url=%(url)s">
386
<p>You should now automatically <a
387
href="%(url)s">return to %(url)s</a>.</p>
390
""" % { 'url' : url }
392
request.setResponseCode(400)
393
return """<!doctype html>
394
<html><head><title>Error</title></head>
396
<h1>There was an error</h1>
397
<p>The authentication process has not succeeded. This may be a
398
temporary problem; please try again in a few minutes.</p>