~didrocks/ubuntuone-client/dont-suffer-zg-crash

« back to all changes in this revision

Viewing changes to ubuntuone/oauthdesktop/auth.py

  • Committer: Bazaar Package Importer
  • Author(s): Rodney Dawes
  • Date: 2009-06-30 12:00:00 UTC
  • Revision ID: james.westby@ubuntu.com-20090630120000-by806ovmw3193qe8
Tags: upstream-0.90.3
ImportĀ upstreamĀ versionĀ 0.90.3

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 pycurl
 
29
import StringIO
 
30
import dbus
 
31
 
 
32
import gnomekeyring
 
33
from ubuntuone.storageprotocol import oauth
 
34
from ubuntuone.oauthdesktop.key_acls import set_all_key_acls
 
35
 
 
36
from twisted.internet import reactor
 
37
from twisted.web import server, resource
 
38
 
 
39
from ubuntuone.oauthdesktop.logger import setupLogging
 
40
setupLogging()
 
41
import logging
 
42
logger = logging.getLogger("UbuntuOne.OAuthDesktop.auth")
 
43
 
 
44
 
 
45
class NoAccessToken(Exception):
 
46
    """No access token available."""
 
47
 
 
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."""
 
54
 
 
55
# NetworkManager State constants
 
56
NM_STATE_UNKNOWN = 0
 
57
NM_STATE_ASLEEP = 1
 
58
NM_STATE_CONNECTING = 2
 
59
NM_STATE_CONNECTED = 3
 
60
NM_STATE_DISCONNECTED = 4
 
61
 
 
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.
 
69
 
 
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
 
77
 
 
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:
 
80
 
 
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)
 
84
 
 
85
        """
 
86
        self.realm = realm
 
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)
 
102
 
 
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':
 
108
                                             self.consumer.key})
 
109
 
 
110
    def get_access_token(self):
 
111
        """Get the access token from the keyring.
 
112
 
 
113
        If no token is available in the keyring, `NoAccessToken` is raised.
 
114
        """
 
115
        logger.debug("Trying to fetch the token from the keyring")
 
116
        try:
 
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)
 
124
 
 
125
    def clear_token(self):
 
126
        """Clear any stored tokens from the keyring."""
 
127
        logger.debug("Searching keyring for existing tokens to delete.")
 
128
        try:
 
129
            items = self._get_keyring_items()
 
130
        except (gnomekeyring.NoMatchError,
 
131
                gnomekeyring.DeniedError):
 
132
            logger.debug("No preexisting tokens found")
 
133
        else:
 
134
            logger.debug("Deleting %s tokens from the keyring" % len(items))
 
135
            for item in items:
 
136
                try:
 
137
                    self.keyring.item_delete_sync(None, item.item_id)
 
138
                except gnomekeyring.DeniedError:
 
139
                    logger.debug("Permission denied deleting token")
 
140
 
 
141
    def store_token(self, access_token):
 
142
        """Store the given access token in the keyring.
 
143
 
 
144
        The keyring item is identified by the OAuth realm and consumer
 
145
        key to support multiple instances.
 
146
        """
 
147
        logger.debug("Trying to store the token in the keyring")
 
148
        item_id = self.keyring.item_create_sync(
 
149
            None,
 
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(),
 
155
            True)
 
156
 
 
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)
 
161
 
 
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.
 
165
        import time
 
166
        time.sleep(4)
 
167
 
 
168
    def have_access_token(self):
 
169
        """Returns true if an access token is available from the keyring."""
 
170
        try:
 
171
            self.get_access_token()
 
172
        except NoAccessToken:
 
173
            return False
 
174
        else:
 
175
            return True
 
176
 
 
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
 
180
 
 
181
        logger.debug("Making a token request")
 
182
        accum = StringIO.StringIO()
 
183
        c = pycurl.Curl()
 
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())
 
187
        try:
 
188
            c.perform()
 
189
        except pycurl.error, e:
 
190
            logger.debug("There was some unknown login error '%s'", e)
 
191
            raise UnknownLoginError(e.message)
 
192
        c.close()
 
193
        accum.seek(0)
 
194
        data = accum.read()
 
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
 
198
        try:
 
199
            out_token = oauth.OAuthToken.from_string(data)
 
200
            logger.debug("Token successfully requested")
 
201
            return out_token
 
202
        except:
 
203
            logger.debug("Token was not successfully retrieved: data was '%s'",
 
204
               data)
 
205
 
 
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])
 
210
        if ret != 0:
 
211
            raise Exception("Failed to launch browser")
 
212
 
 
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")
 
217
        try:
 
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)
 
223
        else:
 
224
            iface = dbus.Interface(nm, 'org.freedesktop.NetworkManager')
 
225
 
 
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,
 
240
                                                  store)
 
241
                else:
 
242
                    # NM is not connected: fail
 
243
                    logger.debug("We are not online")
 
244
                    raise NotOnlineError()
 
245
 
 
246
            def got_error():
 
247
                """Handler for D-Bus errors when calling state()."""
 
248
                logger.debug("Received D-Bus error")
 
249
                raise NotOnlineError()
 
250
 
 
251
            iface.state(reply_handler=got_state, error_handler=got_error)
 
252
 
 
253
    def connection_established(self, state):
 
254
        """NetworkManager's state has changed, and we're watching for
 
255
           a connection"""
 
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)
 
264
        else:
 
265
            # connection changed but not to "connected", so keep waiting
 
266
            logger.debug("Not yet connected: continuing to wait")
 
267
 
 
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()
 
271
 
 
272
        # Create a request token ...
 
273
        logger.debug("Creating a request token to begin access request")
 
274
        parameters = {}
 
275
        if description:
 
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)
 
284
 
 
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)
 
289
 
 
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())
 
298
 
 
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)
 
310
        return callback_url
 
311
 
 
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)
 
324
        if not access_token:
 
325
            logger.debug("Failed to get access token.")
 
326
            if self.callback_denied is not None:
 
327
                self.callback_denied()
 
328
        else:
 
329
            if store:
 
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)
 
334
 
 
335
    def ensure_access_token(self, description=None):
 
336
        """Returns an access token, either from the keyring or newly acquired.
 
337
 
 
338
        If a new token is acquired, it will be stored in the keyring
 
339
        for future use.
 
340
        """
 
341
        try:
 
342
            access_token = self.get_access_token()
 
343
            self.callback_parent(access_token)
 
344
        except NoAccessToken:
 
345
            if self.do_login:
 
346
                access_token = self.acquire_access_token_if_online(
 
347
                    description,
 
348
                    store=True)
 
349
            else:
 
350
                if self.callback_denied is not None:
 
351
                    self.callback_denied()
 
352
 
 
353
 
 
354
class TemporaryTwistedWebServer(resource.Resource):
 
355
    """A temporary httpd for the oauth process to call back to"""
 
356
    isLeaf = True
 
357
    def __init__(self, nonce, store_yes_no, retrieve_function):
 
358
        """Initialize the temporary web server."""
 
359
        resource.Resource.__init__(self)
 
360
        self.nonce = nonce
 
361
        self.store_yes_no = store_yes_no
 
362
        self.retrieve_function = retrieve_function
 
363
        reactor.callLater(600, self.stop) # ten minutes
 
364
        self.port = None
 
365
    def set_port(self, port):
 
366
        """Save the Twisted port object so we can stop it later"""
 
367
        self.port = port
 
368
    def stop(self):
 
369
        """Stop the httpd"""
 
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">
 
384
        </head>
 
385
        <body>
 
386
        <p>You should now automatically <a
 
387
        href="%(url)s">return to %(url)s</a>.</p>
 
388
        </body>
 
389
        </html>
 
390
        """ % { 'url' : url }
 
391
        else:
 
392
            request.setResponseCode(400)
 
393
            return """<!doctype html>
 
394
        <html><head><title>Error</title></head>
 
395
        <body>
 
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>
 
399
        </body>
 
400
        </html>
 
401
        """
 
402
 
 
403
 
 
404