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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# ubuntuone.oauthdesktop.auth - Client authorization module
#
# Author: Stuart Langridge <stuart.langridge@canonical.com>
#
# Copyright 2009 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranties of
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
# PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program.  If not, see <http://www.gnu.org/licenses/>.
"""OAuth client authorisation code.

This code handles acquisition of an OAuth access token for a service,
managed through the GNOME keyring for future use, and asynchronously.
"""

__metaclass__ = type

import subprocess
import random
import pycurl
import StringIO
import dbus

import gnomekeyring
from ubuntuone.storageprotocol import oauth
from ubuntuone.oauthdesktop.key_acls import set_all_key_acls

from twisted.internet import reactor
from twisted.web import server, resource

from ubuntuone.oauthdesktop.logger import setupLogging
setupLogging()
import logging
logger = logging.getLogger("UbuntuOne.OAuthDesktop.auth")


class NoAccessToken(Exception):
    """No access token available."""

class ConsumerKeyInvalid(Exception):
    """OAuth said the consumer key was invalid."""
class UnknownLoginError(Exception):
    """An OAuth request failed for some reason."""
class NotOnlineError(Exception):
    """There is no network connection."""

# NetworkManager State constants
NM_STATE_UNKNOWN = 0
NM_STATE_ASLEEP = 1
NM_STATE_CONNECTING = 2
NM_STATE_CONNECTED = 3
NM_STATE_DISCONNECTED = 4

class AuthorisationClient(object):
    """OAuth authorisation client."""
    def __init__(self, realm, request_token_url, user_authorisation_url,
                 access_token_url, consumer_key, consumer_secret,
                 callback_parent, callback_denied=None, do_login=True,
                 keyring=gnomekeyring):
        """Create an `AuthorisationClient` instance.

        @param realm: the OAuth realm.
        @param request_token_url: the OAuth request token URL.
        @param user_authorisation_url: the OAuth user authorisation URL.
        @param access_token_url: the OAuth access token URL.
        @param consumer_key: the OAuth consumer key.
        @param consumer_secret: the OAuth consumer secret.
        @param callback_parent: a function in the includer to call with a token

        The preceding parameters are defined in sections 3 and 4.1 of the
        OAuth Core 1.0 specification.  The following parameters are not:

        @param callback_denied: a function to call if no token is available
        @param do_login: whether to create a token if one is not cached
        @param keychain: the keyring object to use (defaults to gnomekeyring)

        """
        self.realm = realm
        self.request_token_url = request_token_url
        self.user_authorisation_url = user_authorisation_url
        self.access_token_url = access_token_url
        self.consumer = oauth.OAuthConsumer(consumer_key, consumer_secret)
        self.callback_parent = callback_parent
        self.callback_denied = callback_denied
        self.do_login = do_login
        self.request_token = None
        self.saved_acquire_details = (None, None, None)
        self.keyring = keyring
        logger.debug("auth.AuthorisationClient created with parameters "+ \
           "realm='%s', request_token_url='%s', user_authorisation_url='%s',"+\
           "access_token_url='%s', consumer_key='%s', callback_parent='%s'",
           realm, request_token_url, user_authorisation_url, access_token_url,
           consumer_key, callback_parent)

    def _get_keyring_items(self):
        """Raw interface to obtain keyring items."""
        return self.keyring.find_items_sync(gnomekeyring.ITEM_GENERIC_SECRET,
                                            {'ubuntuone-realm': self.realm,
                                             'oauth-consumer-key':
                                             self.consumer.key})

    def get_access_token(self):
        """Get the access token from the keyring.

        If no token is available in the keyring, `NoAccessToken` is raised.
        """
        logger.debug("Trying to fetch the token from the keyring")
        try:
            items = self._get_keyring_items()
        except (gnomekeyring.NoMatchError,
                gnomekeyring.DeniedError):
            logger.debug("Access token was not in the keyring")
            raise NoAccessToken("No access token found.")
        logger.debug("Access token successfully found in the keyring")
        return oauth.OAuthToken.from_string(items[0].secret)

    def clear_token(self):
        """Clear any stored tokens from the keyring."""
        logger.debug("Searching keyring for existing tokens to delete.")
        try:
            items = self._get_keyring_items()
        except (gnomekeyring.NoMatchError,
                gnomekeyring.DeniedError):
            logger.debug("No preexisting tokens found")
        else:
            logger.debug("Deleting %s tokens from the keyring" % len(items))
            for item in items:
                try:
                    self.keyring.item_delete_sync(None, item.item_id)
                except gnomekeyring.DeniedError:
                    logger.debug("Permission denied deleting token")

    def store_token(self, access_token):
        """Store the given access token in the keyring.

        The keyring item is identified by the OAuth realm and consumer
        key to support multiple instances.
        """
        logger.debug("Trying to store the token in the keyring")
        item_id = self.keyring.item_create_sync(
            None,
            gnomekeyring.ITEM_GENERIC_SECRET,
            'UbuntuOne token for %s' % self.realm,
            {'ubuntuone-realm': self.realm,
             'oauth-consumer-key': self.consumer.key},
            access_token.to_string(),
            True)

        # set ACLs on the key for all apps listed in xdg BaseDir, but only
        # the root level one, not the user-level one
        logger.debug("Setting ACLs on the token in the keyring")
        set_all_key_acls(item_id=item_id)

        # keyring seems to take a while to actually apply the change
        # for when other people retrieve it, so sleep a bit.
        # this ought to get fixed.
        import time
        time.sleep(4)

    def have_access_token(self):
        """Returns true if an access token is available from the keyring."""
        try:
            self.get_access_token()
        except NoAccessToken:
            return False
        else:
            return True

    def make_token_request(self, oauth_request):
        """Perform the given `OAuthRequest` and return the associated token."""
        # uses pycurl because that will fail on self-signed certs

        logger.debug("Making a token request")
        accum = StringIO.StringIO()
        c = pycurl.Curl()
        c.setopt(c.URL, str(oauth_request.http_url)) # no unicode
        c.setopt(c.WRITEFUNCTION, accum.write)
        c.setopt(c.POSTFIELDS, oauth_request.to_postdata())
        try:
            c.perform()
        except pycurl.error, e:
            logger.debug("There was some unknown login error '%s'", e)
            raise UnknownLoginError(e.message)
        c.close()
        accum.seek(0)
        data = accum.read()
        # we deliberately trap anything that might go wrong when parsing the
        # token, because we do not want this to explicitly fail
        # pylint: disable-msg=W0702
        try:
            out_token = oauth.OAuthToken.from_string(data)
            logger.debug("Token successfully requested")
            return out_token
        except:
            logger.debug("Token was not successfully retrieved: data was '%s'",
               data)

    def open_in_browser(self, url):
        """Open the given URL in the user's web browser."""
        logger.debug("Opening '%s' in the browser", url)
        ret = subprocess.call(["xdg-open", url])
        if ret != 0:
            raise Exception("Failed to launch browser")

    def acquire_access_token_if_online(self, description=None, store=False):
        """Check to see if we are online before trying to acquire"""
        # Get NetworkManager state
        logger.debug("Checking whether we are online")
        try:
            nm = dbus.SystemBus().get_object('org.freedesktop.NetworkManager',
                                             '/org/freedesktop/NetworkManager')
        except dbus.exceptions.DBusException:
            logger.warn("Unable to connect to NetworkManager. Trying anyway.")
            self.acquire_access_token(description, store)
        else:
            iface = dbus.Interface(nm, 'org.freedesktop.NetworkManager')

            def got_state(state):
                """Handler for when state() call succeeds."""
                if state == NM_STATE_CONNECTED:
                    logger.debug("We are online")
                    self.acquire_access_token(description, store)
                elif state == NM_STATE_CONNECTING:
                    logger.debug("We are currently going online")
                    # attach to NM's StateChanged signal
                    signal_match = nm.connect_to_signal(
                        signal_name="StateChanged",
                        handler_function=self.connection_established,
                        dbus_interface="org.freedesktop.NetworkManager")
                    # stash the details so the handler_function can get at them
                    self.saved_acquire_details = (signal_match, description,
                                                  store)
                else:
                    # NM is not connected: fail
                    logger.debug("We are not online")
                    raise NotOnlineError()

            def got_error():
                """Handler for D-Bus errors when calling state()."""
                logger.debug("Received D-Bus error")
                raise NotOnlineError()

            iface.state(reply_handler=got_state, error_handler=got_error)

    def connection_established(self, state):
        """NetworkManager's state has changed, and we're watching for
           a connection"""
        logger.debug("Online status has changed to %s" % state)
        if int(state) == NM_STATE_CONNECTED:
            signal_match, description, store = self.saved_acquire_details
            # disconnect the signal so we don't get called again
            signal_match.remove()
            # call the real acquire_access_token now it has a connection
            logger.debug("Correctly connected: now starting auth process")
            self.acquire_access_token(description, store)
        else:
            # connection changed but not to "connected", so keep waiting
            logger.debug("Not yet connected: continuing to wait")

    def acquire_access_token(self, description=None, store=False):
        """Create an OAuth access token authorised against the user."""
        signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()

        # Create a request token ...
        logger.debug("Creating a request token to begin access request")
        parameters = {}
        if description:
            parameters['description'] = description
        oauth_request = oauth.OAuthRequest.from_consumer_and_token(
            http_url=self.request_token_url,
            oauth_consumer=self.consumer,
            parameters=parameters)
        oauth_request.sign_request(signature_method, self.consumer, None)
        logger.debug("Making token request")
        self.request_token = self.make_token_request(oauth_request)

        # Request authorisation from the user
        # Add a nonce to the query so we know the callback (to our temp
        # webserver) came from us
        nonce = random.randint(1000000, 10000000)

        # start temporary webserver to receive browser response
        callback_url = self.get_temporary_httpd(nonce,
           self.retrieve_access_token, store)
        oauth_request = oauth.OAuthRequest.from_token_and_callback(
            http_url=self.user_authorisation_url,
            token=self.request_token,
            callback=callback_url)
        self.open_in_browser(oauth_request.to_url())

    def get_temporary_httpd(self, nonce, retrieve_function, store):
        "A separate class so it can be mocked in testing"
        logger.debug("Creating a listening temp web server")
        site = TemporaryTwistedWebServer(nonce=nonce,
          retrieve_function=retrieve_function, store_yes_no=store)
        temphttpd = server.Site(site)
        temphttpdport = reactor.listenTCP(0, temphttpd)
        callback_url = "http://127.0.0.1:%s/?nonce=%s" % (
          temphttpdport.getHost().port, nonce)
        site.set_port(temphttpdport)
        logger.debug("Webserver listening on port '%s'", temphttpdport)
        return callback_url

    def retrieve_access_token(self, store=False):
        """Retrieve the access token, once OAuth is done. This is a callback."""
        logger.debug("Access token callback from temp webserver")
        signature_method = oauth.OAuthSignatureMethod_PLAINTEXT()
        oauth_request = oauth.OAuthRequest.from_consumer_and_token(
            http_url=self.access_token_url,
            oauth_consumer=self.consumer,
            token=self.request_token)
        oauth_request.sign_request(
            signature_method, self.consumer, self.request_token)
        logger.debug("Retrieving access token from OAuth")
        access_token = self.make_token_request(oauth_request)
        if not access_token:
            logger.debug("Failed to get access token.")
            if self.callback_denied is not None:
                self.callback_denied()
        else:
            if store:
                logger.debug("Storing access token in keyring")
                self.store_token(access_token)
            logger.debug("Calling the callback_parent")
            self.callback_parent(access_token)

    def ensure_access_token(self, description=None):
        """Returns an access token, either from the keyring or newly acquired.

        If a new token is acquired, it will be stored in the keyring
        for future use.
        """
        try:
            access_token = self.get_access_token()
            self.callback_parent(access_token)
        except NoAccessToken:
            if self.do_login:
                access_token = self.acquire_access_token_if_online(
                    description,
                    store=True)
            else:
                if self.callback_denied is not None:
                    self.callback_denied()


class TemporaryTwistedWebServer(resource.Resource):
    """A temporary httpd for the oauth process to call back to"""
    isLeaf = True
    def __init__(self, nonce, store_yes_no, retrieve_function):
        """Initialize the temporary web server."""
        resource.Resource.__init__(self)
        self.nonce = nonce
        self.store_yes_no = store_yes_no
        self.retrieve_function = retrieve_function
        reactor.callLater(600, self.stop) # ten minutes
        self.port = None
    def set_port(self, port):
        """Save the Twisted port object so we can stop it later"""
        self.port = port
    def stop(self):
        """Stop the httpd"""
        logger.debug("Stopping temp webserver")
        self.port.stopListening()
    def render_GET(self, request):
        """Handle incoming web requests"""
        logger.debug("Incoming temp webserver hit received")
        nonce = request.args.get("nonce", [None])[0]
        token = request.args.get("oauth_token", [None])[0]
        url = request.args.get("return", ["https://ubuntuone.com/"])[0]
        if nonce and (str(nonce) == str(self.nonce)):
            self.retrieve_function(self.store_yes_no)
            reactor.callLater(3, self.stop)
            return """<!doctype html>
        <html><head><meta http-equiv="refresh"
        content="0;url=%(url)s">
        </head>
        <body>
        <p>You should now automatically <a
        href="%(url)s">return to %(url)s</a>.</p>
        </body>
        </html>
        """ % { 'url' : url }
        else:
            request.setResponseCode(400)
            return """<!doctype html>
        <html><head><title>Error</title></head>
        <body>
        <h1>There was an error</h1>
        <p>The authentication process has not succeeded. This may be a
        temporary problem; please try again in a few minutes.</p>
        </body>
        </html>
        """