~ubuntu-branches/ubuntu/oneiric/ubuntuone-client/oneiric

« back to all changes in this revision

Viewing changes to ubuntuone/oauthdesktop/auth.py

  • Committer: Bazaar Package Importer
  • Author(s): Rodrigo Moya
  • Date: 2010-06-23 23:08:15 UTC
  • mto: This revision was merged to the branch mainline in revision 34.
  • Revision ID: james.westby@ubuntu.com-20100623230815-4m3ugh10u9x9xzw5
Tags: upstream-1.3.2
ImportĀ upstreamĀ versionĀ 1.3.2

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# ubuntuone.oauthdesktop.auth - Client authorization module
2
 
#
3
 
# Author: Stuart Langridge <stuart.langridge@canonical.com>
4
 
#
5
 
# Copyright 2009 Canonical Ltd.
6
 
#
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.
10
 
#
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.
15
 
#
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.
19
 
 
20
 
This code handles acquisition of an OAuth access token for a service,
21
 
managed through the GNOME keyring for future use, and asynchronously.
22
 
"""
23
 
 
24
 
__metaclass__ = type
25
 
 
26
 
import subprocess
27
 
import random
28
 
import dbus
29
 
import os
30
 
import socket, httplib, urllib
31
 
 
32
 
import gnomekeyring
33
 
from oauth import oauth
34
 
try:
35
 
    from ubuntuone.clientdefs import VERSION
36
 
    ubuntuone_client_version = VERSION
37
 
except ImportError:
38
 
    ubuntuone_client_version = "Unknown"
39
 
from ubuntuone.oauthdesktop.key_acls import set_all_key_acls
40
 
 
41
 
from threading import Thread
42
 
from twisted.internet import reactor
43
 
from twisted.web import server, resource
44
 
 
45
 
from ubuntuone.oauthdesktop.logger import setupLogging
46
 
logger = setupLogging("UbuntuOne.OAuthDesktop.auth")
47
 
 
48
 
 
49
 
class NoAccessToken(Exception):
50
 
    """No access token available."""
51
 
 
52
 
# NetworkManager State constants
53
 
NM_STATE_UNKNOWN = 0
54
 
NM_STATE_ASLEEP = 1
55
 
NM_STATE_CONNECTING = 2
56
 
NM_STATE_CONNECTED = 3
57
 
NM_STATE_DISCONNECTED = 4
58
 
 
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
61
 
try:
62
 
    import ssl
63
 
except ImportError:
64
 
    pass
65
 
else:
66
 
    def _connect_wrapper(self):
67
 
        """Override HTTPSConnection.connect to require certificate checks"""
68
 
        sock = socket.create_connection((self.host, self.port), self.timeout)
69
 
        try:
70
 
            if self._tunnel_host:
71
 
                self.sock = sock
72
 
                self._tunnel()
73
 
        except AttributeError:
74
 
            pass
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
79
 
 
80
 
 
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']
91
 
        else:
92
 
            return
93
 
        fp.read()
94
 
        fp.close()
95
 
        # In case the server sent a relative URL, join with original:
96
 
        newurl = urllib.basejoin(self.type + ":" + url, newurl)
97
 
        
98
 
        # pass data if present when we redirect
99
 
        if data:
100
 
            return self.open(newurl, data)
101
 
        else:
102
 
            return self.open(newurl)
103
 
 
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.
112
 
 
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
120
 
 
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:
123
 
 
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)
127
 
 
128
 
        """
129
 
        self.realm = realm
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)
147
 
 
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':
153
 
                                             self.consumer.key})
154
 
 
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))
159
 
        else:
160
 
            raise error
161
 
 
162
 
    def get_access_token(self):
163
 
        """Get the access token from the keyring.
164
 
 
165
 
        If no token is available in the keyring, `NoAccessToken` is raised.
166
 
        """
167
 
        logger.debug("Trying to fetch the token from the keyring")
168
 
        try:
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)
176
 
 
177
 
    def clear_token(self):
178
 
        """Clear any stored tokens from the keyring."""
179
 
        logger.debug("Searching keyring for existing tokens to delete.")
180
 
        try:
181
 
            items = self._get_keyring_items()
182
 
        except (gnomekeyring.NoMatchError,
183
 
                gnomekeyring.DeniedError):
184
 
            logger.debug("No preexisting tokens found")
185
 
        else:
186
 
            logger.debug("Deleting %s tokens from the keyring" % len(items))
187
 
            for item in items:
188
 
                try:
189
 
                    self.keyring.item_delete_sync(None, item.item_id)
190
 
                except gnomekeyring.DeniedError:
191
 
                    logger.debug("Permission denied deleting token")
192
 
 
193
 
    def store_token(self, access_token):
194
 
        """Store the given access token in the keyring.
195
 
 
196
 
        The keyring item is identified by the OAuth realm and consumer
197
 
        key to support multiple instances.
198
 
        """
199
 
        logger.debug("Trying to store the token in the keyring")
200
 
        try:
201
 
            item_id = self.keyring.item_create_sync(
202
 
                None,
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(),
208
 
                True)
209
 
        except gnomekeyring.DeniedError:
210
 
            logger.debug("Permission denied storing token")
211
 
        else:
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)
216
 
 
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.
220
 
            import time
221
 
            time.sleep(4)
222
 
 
223
 
    def have_access_token(self):
224
 
        """Returns true if an access token is available from the keyring."""
225
 
        try:
226
 
            self.get_access_token()
227
 
        except NoAccessToken:
228
 
            return False
229
 
        else:
230
 
            return True
231
 
 
232
 
    def make_token_request(self, oauth_request):
233
 
        """Perform the given `OAuthRequest` and return the associated token."""
234
 
        
235
 
        logger.debug("Making a token request")
236
 
        # Note that we monkeypatched httplib above to handle invalid certs
237
 
        # Ways this urlopen can fail:
238
 
        # bad certificate
239
 
        #    raises IOError, e.args[1] == SSLError, e.args[1].errno == 1
240
 
        # No such server 
241
 
        #    raises IOError, e.args[1] == SSLError, e.args[1].errno == -2
242
 
        try:
243
 
            opener = FancyURLOpenerWithRedirectedPOST()
244
 
            fp = opener.open(oauth_request.http_url, oauth_request.to_postdata())
245
 
            data = fp.read()
246
 
        except IOError, e:
247
 
            self._forward_error_callback(e)
248
 
            return
249
 
        
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
253
 
        try:
254
 
            out_token = oauth.OAuthToken.from_string(data)
255
 
            logger.debug("Token successfully requested")
256
 
            return out_token
257
 
        except:
258
 
            error = Exception(data)
259
 
            logger.error("Token was not successfully retrieved: data was '%s'",
260
 
               str(error))
261
 
            self._forward_error_callback(error)
262
 
 
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)
268
 
        p.wait()
269
 
        if p.returncode != 0:
270
 
            errors = "".join(p.stderr.readlines())
271
 
            if errors != "":
272
 
                self._forward_error_callback(IOError(errors))
273
 
 
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")
278
 
        try:
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)
285
 
        else:
286
 
            iface = dbus.Interface(nm, 'org.freedesktop.NetworkManager')
287
 
 
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,
302
 
                                                  store)
303
 
                else:
304
 
                    # NM is not connected: fail
305
 
                    logger.debug("We are not online")
306
 
 
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)
313
 
                else:
314
 
                    logger.error("Error contacting NetworkManager: %s" % \
315
 
                                     str(error))
316
 
 
317
 
 
318
 
            iface.state(reply_handler=got_state, error_handler=got_error)
319
 
 
320
 
    def connection_established(self, state):
321
 
        """NetworkManager's state has changed, and we're watching for
322
 
           a connection"""
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)
331
 
        else:
332
 
            # connection changed but not to "connected", so keep waiting
333
 
            logger.debug("Not yet connected: continuing to wait")
334
 
 
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()
338
 
 
339
 
        # Create a request token ...
340
 
        logger.debug("Creating a request token to begin access request")
341
 
        parameters = {}
342
 
        if description:
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)
347
 
 
348
 
        # start temporary webserver to receive browser response
349
 
        callback_url = self.get_temporary_httpd(nonce,
350
 
           self.retrieve_access_token, store)
351
 
 
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)
360
 
 
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]
366
 
        if nodename:
367
 
            oauth_request.set_parameter("description", nodename)
368
 
        Thread(target=self.open_in_browser, name="authorization",
369
 
               args=(oauth_request.to_url(),)).start()
370
 
 
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)
382
 
        return callback_url
383
 
 
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)
397
 
        if not access_token:
398
 
            logger.error("Failed to get access token.")
399
 
            if self.callback_denied is not None:
400
 
                self.callback_denied()
401
 
        else:
402
 
            if store:
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)
407
 
 
408
 
    def ensure_access_token(self, description=None):
409
 
        """Returns an access token, either from the keyring or newly acquired.
410
 
 
411
 
        If a new token is acquired, it will be stored in the keyring
412
 
        for future use.
413
 
        """
414
 
        try:
415
 
            access_token = self.get_access_token()
416
 
            self.callback_parent(access_token)
417
 
        except NoAccessToken:
418
 
            if self.do_login:
419
 
                access_token = self.acquire_access_token_if_online(
420
 
                    description,
421
 
                    store=True)
422
 
            else:
423
 
                if self.callback_notoken is not None:
424
 
                    self.callback_notoken()
425
 
 
426
 
 
427
 
class TemporaryTwistedWebServer(resource.Resource):
428
 
    """A temporary httpd for the oauth process to call back to"""
429
 
    isLeaf = True
430
 
    def __init__(self, nonce, store_yes_no, retrieve_function):
431
 
        """Initialize the temporary web server."""
432
 
        resource.Resource.__init__(self)
433
 
        self.nonce = nonce
434
 
        self.store_yes_no = store_yes_no
435
 
        self.retrieve_function = retrieve_function
436
 
        reactor.callLater(600, self.stop) # ten minutes
437
 
        self.port = None
438
 
    def set_port(self, port):
439
 
        """Save the Twisted port object so we can stop it later"""
440
 
        self.port = port
441
 
    def stop(self):
442
 
        """Stop the httpd"""
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">
458
 
        </head>
459
 
        <body>
460
 
        <p>You should now automatically <a
461
 
        href="%(url)s">return to %(url)s</a>.</p>
462
 
        </body>
463
 
        </html>
464
 
        """ % { 'url' : url }
465
 
        else:
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>
471
 
        <body>
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>
475
 
        </body>
476
 
        </html>
477
 
        """
478
 
 
479
 
 
480