Merge lp:~benji/launchpadlib/keyring into lp:launchpadlib
- keyring
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 100 |
Proposed branch: | lp:~benji/launchpadlib/keyring |
Merge into: | lp:launchpadlib |
Diff against target: |
464 lines (+330/-24) 3 files modified
src/launchpadlib/NEWS.txt (+5/-0) src/launchpadlib/launchpad.py (+115/-21) src/launchpadlib/tests/test_launchpad.py (+210/-3) |
To merge this branch: | bzr merge lp:~benji/launchpadlib/keyring |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Leonard Richardson (community) | Needs Fixing | ||
Review via email: mp+36365@code.launchpad.net |
Commit message
Description of the change
Make launchpadlib attempt to store/load authentication credentials
in/from the Gnome keyring.
Leonard Richardson (leonardr) wrote : | # |
- 101. By Benji York <benji@benji-laptop>
-
use a context manager to encapsulate a reoccurring pattern
Leonard Richardson (leonardr) : | # |
Robert Collins (lifeless) wrote : | # |
This is spectacularly risky unless you keep per-application access
restrictions. I haven't checked the code to see if thats what you're
doing.
It's-not-
Rob
Benji York (benji) wrote : | # |
On Thu, Sep 23, 2010 at 5:25 AM, Robert Collins
<email address hidden> wrote:
> This is spectacularly risky unless you keep per-application access
> restrictions.
Can you unpack that a bit? I don't follow.
--
Benji York
Robert Collins (lifeless) wrote : | # |
See the thread about this overall conceptual change on the mailling list.
Leonard Richardson (leonardr) wrote : | # |
I agree that you should wait to land this until the overall change is resolved, just to avoid confusion. But there are, currently, no per-application access restrictions in launchpadlib. It just looks like there are. Tokens are stored in standard locations in files on disk, and any running application can look for a token that meets its needs and use it. This was something we planned to *fix* by moving the tokens into the GNOME keyring, but it turns out the GNOME keyring is no different in this regard.
IMO this change is a net gain for security, even if we don't make the larger change to the way tokens work in a desktop context. Storing OAuth tokens on disk means that if someone steals your hard drive, they can see your tokens, unless you encrypted your home directory. Storing tokens in a keyring ensures that they are always stored encrypted on disk.
Robert Collins (lifeless) wrote : | # |
On Fri, Sep 24, 2010 at 9:09 AM, Leonard Richardson
<email address hidden> wrote:
> I agree that you should wait to land this until the overall change is resolved, just to avoid confusion. But there are, currently, no per-application access restrictions in launchpadlib. It just looks like there are. Tokens are stored in standard locations in files on disk, and any running application can look for a token that meets its needs and use it. This was something we planned to *fix* by moving the tokens into the GNOME keyring, but it turns out the GNOME keyring is no different in this regard.
Applications manually store their keys; they can handoff to a personal
wallet with different characteristics to gnome keyring.
> IMO this change is a net gain for security, even if we don't make the larger change to the way tokens work in a desktop context. Storing OAuth tokens on disk means that if someone steals your hard drive, they can see your tokens, unless you encrypted your home directory. Storing tokens in a keyring ensures that they are always stored encrypted on disk.
In the Ubuntu context we ship with per file encryption available by default.
Leonard Richardson (leonardr) wrote : | # |
> Applications manually store their keys; they can handoff to a personal
> wallet with different characteristics to gnome keyring.
Applications *may* manually store their keys, but almost every one I've seen uses Launchpad.
This branch changes login_with() to store the keys in the GNOME keyring instead of on disk. Applications that don't use login_with() are still free to do whatever they want.
Leonard Richardson (leonardr) wrote : | # |
I'm un-approving this branch because Sidnei da Silva on launchpad-dev mentioned the keyring package (http://
Preview Diff
1 | === modified file 'src/launchpadlib/NEWS.txt' |
2 | --- src/launchpadlib/NEWS.txt 2010-08-23 19:51:55 +0000 |
3 | +++ src/launchpadlib/NEWS.txt 2010-09-22 21:58:38 +0000 |
4 | @@ -2,6 +2,11 @@ |
5 | NEWS for launchpadlib |
6 | ===================== |
7 | |
8 | +1.7.0 (2010-09-23) |
9 | +================== |
10 | + |
11 | +- Store authorization tokens in the Gnome keyring, when available. |
12 | + |
13 | 1.6.5 (2010-08-23) |
14 | ================== |
15 | |
16 | |
17 | === modified file 'src/launchpadlib/launchpad.py' |
18 | --- src/launchpadlib/launchpad.py 2010-08-23 19:51:55 +0000 |
19 | +++ src/launchpadlib/launchpad.py 2010-09-22 21:58:38 +0000 |
20 | @@ -22,7 +22,9 @@ |
21 | ] |
22 | |
23 | import os |
24 | +import socket |
25 | import stat |
26 | +import cStringIO |
27 | import urlparse |
28 | |
29 | from lazr.uri import URI |
30 | @@ -33,6 +35,13 @@ |
31 | AuthorizeRequestTokenWithBrowser) |
32 | from launchpadlib import uris |
33 | |
34 | +# The Gnome keyring module (part of gtk) is optional |
35 | +try: |
36 | + import gnomekeyring |
37 | +except ImportError: |
38 | + gnomekeyring = None |
39 | + |
40 | + |
41 | # Import some constants for backwards compatibility. This way, old |
42 | # scripts that have 'from launchpad import EDGE_SERVICE_ROOT' will still |
43 | # work. |
44 | @@ -96,6 +105,85 @@ |
45 | collection_of = 'distribution' |
46 | |
47 | |
48 | +def get_keyring_attr(consumer_name): |
49 | + token_name = '%s (launchpadlib on %s)' % ( |
50 | + consumer_name, socket.gethostname()) |
51 | + return { |
52 | + 'key-type': 'launchpadlib credentials', |
53 | + 'token-name': token_name, |
54 | + } |
55 | + |
56 | + |
57 | +def query_keyring_for_credentials(consumer_name): |
58 | + """Load the credentials from the the Gnome keyring, if available.""" |
59 | + if gnomekeyring is None: |
60 | + return None |
61 | + if not gnomekeyring.is_available(): |
62 | + return None |
63 | + |
64 | + try: |
65 | + items = gnomekeyring.find_items_sync( |
66 | + gnomekeyring.ITEM_GENERIC_SECRET, get_keyring_attr(consumer_name)) |
67 | + except gnomekeyring.NoMatchError: |
68 | + return None |
69 | + except gnomekeyring.CancelledError: |
70 | + # The user pressed "Cancel" when prompted to unlock their keyring. |
71 | + return None |
72 | + |
73 | + assert len(items) == 1, 'no more than one entry should ever match' |
74 | + credentials = Credentials() |
75 | + credentials.load(cStringIO.StringIO(items[0].secret)) |
76 | + return credentials |
77 | + |
78 | + |
79 | +def attempt_to_save_credentials_in_keyring(credentials, consumer_name): |
80 | + """Save the credentials to the the Gnome keyring, if available.""" |
81 | + if gnomekeyring is None: |
82 | + return False |
83 | + if not gnomekeyring.is_available(): |
84 | + return False |
85 | + |
86 | + # Get the credentials as a string. |
87 | + sio = cStringIO.StringIO() |
88 | + credentials.save(sio) |
89 | + value = sio.getvalue() |
90 | + |
91 | + # If the login keyring is locked (e.g., they have auto login enabled) a |
92 | + # dialog box prompting for their keyring password will be displayed. |
93 | + # TODO make this return False if the user cancels |
94 | + try: |
95 | + gnomekeyring.item_create_sync( |
96 | + 'login', gnomekeyring.ITEM_GENERIC_SECRET, consumer_name, |
97 | + get_keyring_attr(consumer_name), value, True) |
98 | + except gnomekeyring.CancelledError: |
99 | + # The user pressed "Cancel" when prompted to unlock their keyring. |
100 | + return False |
101 | + |
102 | + return True |
103 | + |
104 | + |
105 | +def save_credentials(credentials, consumer_credentials_path, consumer_name): |
106 | + if attempt_to_save_credentials_in_keyring(credentials, consumer_name): |
107 | + # It worked. Nothing more to do. |
108 | + return |
109 | + else: |
110 | + # We couldn't get the credentials into the key |
111 | + credentials.save_to_path(consumer_credentials_path) |
112 | + os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE) |
113 | + |
114 | + |
115 | +def load_credentials(consumer_name, consumer_credentials_path): |
116 | + keyring_credentials = query_keyring_for_credentials(consumer_name) |
117 | + if keyring_credentials is not None: |
118 | + return keyring_credentials |
119 | + elif os.path.exists(consumer_credentials_path): |
120 | + credentials = Credentials.load_from_path(consumer_credentials_path) |
121 | + attempt_to_save_credentials_in_keyring(credentials, consumer_name) |
122 | + return credentials |
123 | + else: |
124 | + return None |
125 | + |
126 | + |
127 | class Launchpad(ServiceRoot): |
128 | """Root Launchpad API class. |
129 | |
130 | @@ -240,17 +328,17 @@ |
131 | """Log in to Launchpad with possibly cached credentials. |
132 | |
133 | This is a convenience method for either setting up new login |
134 | - credentials, or re-using existing ones. When a login token is generated |
135 | - using this method, the resulting credentials will be saved in |
136 | - `credentials_file`, or if not given, into the `launchpadlib_dir` |
137 | - directory. If the same `credentials_file`/`launchpadlib_dir` is passed |
138 | - in a second time, the credentials in for the consumer will be used |
139 | - automatically. |
140 | - |
141 | - Each consumer has their own credentials per service root in |
142 | - `launchpadlib_dir`. `launchpadlib_dir` is also used for caching |
143 | - fetched objects. The cache is per service root, and shared by |
144 | - all consumers. |
145 | + credentials, or re-using existing ones. When a login token is |
146 | + generated using this method, the resulting credentials will be stored |
147 | + in one of these locations (given in order of priority): the Gnome |
148 | + keyring (if available), `credentials_file` (if provided), or as a last |
149 | + resort the `launchpadlib_dir` directory. |
150 | + |
151 | + Subsequent calls to this method will load the credentials from one of |
152 | + the aformentioned locations in the same priority order. |
153 | + |
154 | + `launchpadlib_dir` is also used for caching fetched objects. The cache |
155 | + is per service root, and shared by all consumers. |
156 | |
157 | See `Launchpad.get_token_and_login()` for more information about |
158 | how new tokens are generated. |
159 | @@ -274,30 +362,35 @@ |
160 | (service_root, launchpadlib_dir, cache_path, |
161 | service_root_dir) = cls._get_paths(service_root, launchpadlib_dir) |
162 | credentials_path = os.path.join(service_root_dir, 'credentials') |
163 | + |
164 | if not os.path.exists(credentials_path): |
165 | os.makedirs(credentials_path, 0700) |
166 | + |
167 | if credentials_file is None: |
168 | consumer_credentials_path = os.path.join(credentials_path, |
169 | consumer_name) |
170 | else: |
171 | consumer_credentials_path = credentials_file |
172 | - if os.path.exists(consumer_credentials_path): |
173 | - credentials = Credentials.load_from_path( |
174 | - consumer_credentials_path) |
175 | - launchpad = cls( |
176 | - credentials, service_root=service_root, cache=cache_path, |
177 | - timeout=timeout, proxy_info=proxy_info, version=version) |
178 | - else: |
179 | + |
180 | + credentials = load_credentials( |
181 | + consumer_name, consumer_credentials_path) |
182 | + |
183 | + if credentials is None: |
184 | launchpad = cls.get_token_and_login( |
185 | consumer_name, service_root=service_root, cache=cache_path, |
186 | timeout=timeout, proxy_info=proxy_info, |
187 | authorizer_class=authorizer_class, |
188 | allow_access_levels=allow_access_levels, |
189 | max_failed_attempts=max_failed_attempts, version=version) |
190 | + # New credentials were created, so save them for future use. |
191 | if launchpad is not None: |
192 | - launchpad.credentials.save_to_path(consumer_credentials_path) |
193 | - os.chmod( |
194 | - consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE) |
195 | + save_credentials( |
196 | + launchpad.credentials, consumer_credentials_path, |
197 | + consumer_name) |
198 | + else: |
199 | + launchpad = cls( |
200 | + credentials, service_root=service_root, cache=cache_path, |
201 | + timeout=timeout, proxy_info=proxy_info, version=version) |
202 | return launchpad |
203 | |
204 | @classmethod |
205 | @@ -336,3 +429,4 @@ |
206 | if not os.path.exists(cache_path): |
207 | os.makedirs(cache_path, 0700) |
208 | return (service_root, launchpadlib_dir, cache_path, service_root_dir) |
209 | + |
210 | |
211 | === modified file 'src/launchpadlib/tests/test_launchpad.py' |
212 | --- src/launchpadlib/tests/test_launchpad.py 2010-08-23 19:51:55 +0000 |
213 | +++ src/launchpadlib/tests/test_launchpad.py 2010-09-22 21:58:38 +0000 |
214 | @@ -20,16 +20,28 @@ |
215 | |
216 | import os |
217 | import shutil |
218 | +import socket |
219 | import stat |
220 | import tempfile |
221 | +import textwrap |
222 | import unittest |
223 | +from contextlib import contextmanager |
224 | |
225 | from lazr.restfulclient.resource import ServiceRoot |
226 | |
227 | from launchpadlib.credentials import ( |
228 | - AccessToken, AuthorizeRequestTokenWithBrowser, Credentials) |
229 | -from launchpadlib.launchpad import Launchpad |
230 | + AccessToken, |
231 | + AuthorizeRequestTokenWithBrowser, |
232 | + Credentials, |
233 | + ) |
234 | +from launchpadlib.launchpad import ( |
235 | + Launchpad, |
236 | + attempt_to_save_credentials_in_keyring, |
237 | + get_keyring_attr, |
238 | + query_keyring_for_credentials, |
239 | + ) |
240 | from launchpadlib import uris |
241 | +import launchpadlib.launchpad |
242 | |
243 | class NoNetworkLaunchpad(Launchpad): |
244 | """A Launchpad instance for tests with no network access. |
245 | @@ -152,9 +164,15 @@ |
246 | """Tests for Launchpad.login_with().""" |
247 | |
248 | def setUp(self): |
249 | + # For these tests we want to disable retrieving or storing credentials |
250 | + # in the Gnome keyring. |
251 | + self._saved_gnomekeyring = launchpadlib.launchpad.gnomekeyring |
252 | + launchpadlib.launchpad.gnomekeyring = None |
253 | self.temp_dir = tempfile.mkdtemp() |
254 | |
255 | def tearDown(self): |
256 | + # Restore the gnomekeyring module that we disabled in setUp. |
257 | + launchpadlib.launchpad.gnomekeyring = self._saved_gnomekeyring |
258 | shutil.rmtree(self.temp_dir) |
259 | |
260 | def test_dirs_created(self): |
261 | @@ -383,7 +401,7 @@ |
262 | ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo') |
263 | |
264 | def test_separate_credentials_file(self): |
265 | - my_credentials_path = os.path.join(self.temp_dir, 'my_creds') |
266 | + my_credentials_path = os.path.join(self.temp_dir, 'my_creds') |
267 | launchpad = NoNetworkLaunchpad.login_with( |
268 | 'app name', launchpadlib_dir=self.temp_dir, |
269 | credentials_file=my_credentials_path, |
270 | @@ -405,5 +423,194 @@ |
271 | self.assertFalse(launchpad.get_token_and_login_called) |
272 | |
273 | |
274 | +class FauxSocketModule: |
275 | + """A socket module replacement that provides a fake hostname.""" |
276 | + |
277 | + def gethostname(self): |
278 | + return 'HOSTNAME' |
279 | + |
280 | + |
281 | +class FauxCancelledError(Exception): |
282 | + """A substitute for gnomekeyring.CancelledError.""" |
283 | + |
284 | + |
285 | +class FauxNoMatchError(Exception): |
286 | + """A substitute for gnomekeyring.NoMatchError.""" |
287 | + |
288 | + |
289 | +class BaseFauxKeyringModule: |
290 | + """A base to build fake keyring modules with various behaviors.""" |
291 | + |
292 | + CancelledError = FauxCancelledError |
293 | + NoMatchError = FauxNoMatchError |
294 | + ITEM_GENERIC_SECRET = 42 |
295 | + |
296 | + def is_available(self): |
297 | + return True |
298 | + |
299 | + |
300 | +class UnavailableKeyring: |
301 | + """A keyring that behaves as if the Gnome keyring daemon is not running.""" |
302 | + def is_available(self): |
303 | + return False |
304 | + |
305 | + |
306 | +class CancelledKeyring(BaseFauxKeyringModule): |
307 | + """A keyring that behaves as if the user canceled password entry.""" |
308 | + |
309 | + def find_items_sync(self, type, attr): |
310 | + raise self.CancelledError |
311 | + |
312 | + def item_create_sync(self, *args): |
313 | + raise self.CancelledError |
314 | + |
315 | + |
316 | +class NoCredentialsKeyring(BaseFauxKeyringModule): |
317 | + """A keyring that doesn't contain the credentials.""" |
318 | + |
319 | + def find_items_sync(self, type, attr): |
320 | + raise self.NoMatchError |
321 | + |
322 | + |
323 | +class GoodKeyring(BaseFauxKeyringModule): |
324 | + """A keyring that behaves as if the load and store operations succeed.""" |
325 | + |
326 | + def item_create_sync(self, *args): |
327 | + pass |
328 | + |
329 | + def find_items_sync(self, type, attr): |
330 | + credentials_data = textwrap.dedent("""\ |
331 | + [1] |
332 | + consumer_secret = CONSUMER SECRET |
333 | + access_token = ACCESS TOKEN |
334 | + consumer_key = CONSUMER NAME |
335 | + access_secret = ACCESS SECRET""") |
336 | + |
337 | + class FauxKeyringItem: |
338 | + secret = credentials_data |
339 | + |
340 | + return [FauxKeyringItem()] |
341 | + |
342 | + |
343 | +@contextmanager |
344 | +def fake_keyring(fake): |
345 | + original = launchpadlib.launchpad.gnomekeyring |
346 | + launchpadlib.launchpad.gnomekeyring = fake |
347 | + yield |
348 | + launchpadlib.launchpad.gnomekeyring = original |
349 | + |
350 | + |
351 | +class TestLoadFromKeyring(unittest.TestCase): |
352 | + |
353 | + def setUp(self): |
354 | + # launchpadlib.launchpad uses the socket module to look up the |
355 | + # hostname, obviously that can vary so we replace the socket module |
356 | + # with a fake that returns a fake hostname. |
357 | + launchpadlib.launchpad.socket = FauxSocketModule() |
358 | + self.temp_dir = tempfile.mkdtemp() |
359 | + |
360 | + def tearDown(self): |
361 | + shutil.rmtree(self.temp_dir) |
362 | + launchpadlib.launchpad.socket = socket |
363 | + |
364 | + def test_get_keyring_attr(self): |
365 | + # Gnome keyring entries are identified by a set of "attributes". We |
366 | + # construct the attributes such that each application will have a |
367 | + # unique entry that shouldn't conflict with an applications other uses |
368 | + # of the keyring. |
369 | + self.assertEqual( |
370 | + get_keyring_attr('CONSUMER NAME'), |
371 | + {'key-type': 'launchpadlib credentials', |
372 | + 'token-name': 'CONSUMER NAME (launchpadlib on HOSTNAME)'}) |
373 | + |
374 | + def test_no_keyring_module(self): |
375 | + # If the gnomekeyring module cannot be imported (and is therefore set |
376 | + # to None) an attempt to load credentials from the keyring will return |
377 | + # None instead. |
378 | + with fake_keyring(None): |
379 | + self.assertEqual( |
380 | + query_keyring_for_credentials('CONSUMER NAME'), None) |
381 | + |
382 | + def test_no_keyring_available(self): |
383 | + # If the gnomekeyring module can be imported but the keyring daemon is |
384 | + # not running, an attempt to load credentials from the keyring will |
385 | + # return None instead. |
386 | + |
387 | + with fake_keyring(UnavailableKeyring()): |
388 | + self.assertEqual( |
389 | + query_keyring_for_credentials('CONSUMER NAME'), None) |
390 | + |
391 | + def test_keyring_does_not_have_credentials(self): |
392 | + # If the keyring doesn't have the credentials stored in it, an attempt |
393 | + # to load credentials from the keyring will return None. |
394 | + with fake_keyring(NoCredentialsKeyring()): |
395 | + self.assertEqual( |
396 | + query_keyring_for_credentials('CONSUMER NAME'), None) |
397 | + |
398 | + def test_user_canceled_password_prompt(self): |
399 | + # If the user cancels the dialog asking for their password to unlock |
400 | + # the keyring the keyring will return None. |
401 | + with fake_keyring(CancelledKeyring()): |
402 | + self.assertEqual( |
403 | + query_keyring_for_credentials('CONSUMER NAME'), None) |
404 | + |
405 | + def test_successful_credentials_lookup(self): |
406 | + # If serialized credentials are found in the keyring, a Credentials |
407 | + # object is constructed and returned. |
408 | + with fake_keyring(GoodKeyring()): |
409 | + credentials = query_keyring_for_credentials('CONSUMER NAME') |
410 | + self.assertNotEqual(credentials, None) |
411 | + self.assertEqual( |
412 | + credentials.user_agent_params['oauth_consumer'], |
413 | + 'CONSUMER NAME') |
414 | + self.assertEqual(credentials.access_token.key, 'ACCESS TOKEN') |
415 | + |
416 | + |
417 | +class FauxCredentials: |
418 | + """A stand-in for the launchpadlib Credentials object.""" |
419 | + |
420 | + def save(self, writable_file): |
421 | + writable_file.write('faux credentials') |
422 | + |
423 | + |
424 | +class TestSaveToKeyring(unittest.TestCase): |
425 | + |
426 | + def setUp(self): |
427 | + # launchpadlib.launchpad uses the socket module to look up the |
428 | + # hostname, obviously that can vary so we replace the socket module |
429 | + # with a fake that returns a fake hostname. |
430 | + launchpadlib.launchpad.socket = FauxSocketModule() |
431 | + self.temp_dir = tempfile.mkdtemp() |
432 | + |
433 | + def tearDown(self): |
434 | + shutil.rmtree(self.temp_dir) |
435 | + launchpadlib.launchpad.socket = socket |
436 | + |
437 | + def test_no_keyring_available(self): |
438 | + # If the gnomekeyring module can be imported but the keyring daemon is |
439 | + # not running, an attempt to save credentials from the keyring will |
440 | + # return False to indicate that the data was not saved. |
441 | + with fake_keyring(UnavailableKeyring()): |
442 | + result = attempt_to_save_credentials_in_keyring( |
443 | + None, 'CONSUMER NAME') |
444 | + self.assertEqual(result, False) |
445 | + |
446 | + def test_user_canceled_password_prompt(self): |
447 | + # If the user cancels the dialog asking for their password to unlock |
448 | + # the keyring the credentials will not be saved in the keyring. |
449 | + with fake_keyring(CancelledKeyring()): |
450 | + result = attempt_to_save_credentials_in_keyring( |
451 | + FauxCredentials(), 'CONSUMER NAME') |
452 | + self.assertEqual(result, False) |
453 | + |
454 | + def test_successful_save(self): |
455 | + # If the credentials were successfully written to the keyring, True is |
456 | + # returned. |
457 | + with fake_keyring(GoodKeyring()): |
458 | + result = attempt_to_save_credentials_in_keyring( |
459 | + FauxCredentials(), 'CONSUMER NAME') |
460 | + self.assertEqual(result, True) |
461 | + |
462 | + |
463 | def test_suite(): |
464 | return unittest.TestLoader().loadTestsFromName(__name__) |
Looks good. I like the fake keyring. A few suggestions that I mentioned on IRC:
Add a context manager for setting and resetting the fake keyring.
See if you can move the keyring code into credentials.py.
Rework the docstring about the hierarchy of "places to look for/store credentials": we check the keyring first, then a provided file, then the default launchpadlib location.
And actually, it might make more sense to check the provided file *before* checking the keyring. (Though if the key isn't found, I think it still makes sense to write it to the keyring rather than the provided file.)
Also make sure to mention the change in the NEWS.