Merge lp:~leonardr/launchpadlib/separate-login-for-cronjob into lp:launchpadlib
- separate-login-for-cronjob
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Gavin Panella |
Approved revision: | 128 |
Merged at revision: | 103 |
Proposed branch: | lp:~leonardr/launchpadlib/separate-login-for-cronjob |
Merge into: | lp:launchpadlib |
Diff against target: |
1325 lines (+638/-234) 7 files modified
src/launchpadlib/NEWS.txt (+7/-0) src/launchpadlib/credentials.py (+139/-67) src/launchpadlib/launchpad.py (+200/-95) src/launchpadlib/testing/helpers.py (+53/-6) src/launchpadlib/tests/test_credential_store.py (+138/-0) src/launchpadlib/tests/test_http.py (+3/-2) src/launchpadlib/tests/test_launchpad.py (+98/-64) |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/separate-login-for-cronjob |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella | Approve | ||
Review via email: mp+44592@code.launchpad.net |
Commit message
Description of the change
This branch has three interrelated purposes:
1. Currently the "request token authorization engine" object includes two strategies: a strategy for authorizing an OAuth request token, and a strategy for storing the resulting launchpadlib credentials locally. This branch decouples those two strategies. The strategy for storing launchpadlib credentials is now kept in a separate object, the "credential store". The code from RequestTokenAut
2. Currently there is no way to store launchpadlib credentials unencrypted in a file on disk. We deliberately got rid of this feature for security reasons, but there is a legitimate case for this: see bug 686690. This branch introduces a new credential store, the UnencryptedFile
3. The difference between storing launchpadlib credentials unencrypted on disk vs. in the keyring is a big, big difference. We can't treat it as the difference between passing in a filename to login_with() and not passing in a filename. So I've split login_with() into two methods: login_securely() and login_insecurely(). You use login_insecurely() when you must: when your script must run without any user interaction whatsoever. You use login_securely() when you can.
I've taken this opportunity to deprecate most of the old login helpers: login_with(), login(), and get_token_
Since I'm defining new login methods, I've also taken this opportunity to reorder the arguments they accept. Over the past couple of years we've added lots of arguments to the end of the login_with() signature, and it got very disorganized.
4. Other notes:
I moved fake_keyring, FauxSocketModule, BadSaveKeyring, and InMemoryKeyring from test_launchpad.py to testing/helpers.py.
I will need to do a follow-up Launchpad branch to get it to use the new methods, and a follow-up launchpadlib branch to stop KnownTokens from using the deprecated login() method. (I put an XXX in this part of the code).
- 128. By Leonard Richardson
-
Merge from trunk.
Robert Collins (lifeless) wrote : | # |
Leonard Richardson (leonardr) wrote : | # |
I'm not attached to the current method names at all. That said, the difference is not quite "login-
Even though login_insecurely is "the one you should use for cron scripts", if there is no credential it will still cause a browser open and require user input. So we can't use my original ideas for names, "login_interactive" and "login_
My principles:
1. The difference between the methods is "look in the keyring" versus "look in an unencrypted (as far as launchpadlib knows) file".
2, I would like to guide people towards the keyring method. The unencrypted file method should be for people who have problems with the keyring method.
Related thoughts on encrypted filesystems:
I've got an encrypted home directory. I set up a cronjob to run a launchpadlib script. I use login_insecurely(), but I store my credential inside my home directory. Because I have an encrypted home directory, it's not really 'insecure'--it's about as secure as stuff in my keyring.
But the cronjob will only run when my home directory is unencrypted, ie. when I'm logged in. When I'm not logged in, the credential will be unreadable and the cronjob will fail. If I want the cronjob to run all the time, I need to store the credential in an unencrypted filesystem. I think that's the common cron scenario, and I'd say that is 'insecure', relatively speaking.
So, I don't think the existing names are terribly misleading. You can use login_insecurely() in a secure way, if you'll be logged in whenever the script runs. But if you're already logged in, login_securely() will work just as well. And the common case for login_securely() *is* more secure than the common case for login_insecurely().
Gavin Panella (allenap) wrote : | # |
The new CredentialStore stuff is cool.
I've got lots of comments, but they're all fairly minor.
[1]
+ * Launchpad.
+ interaction.
+ * Launchpad.
+ user interaction.
These names worry me. I don't want to debate these names if they have
already been discussed. I just want to say: if I am the only person to
have seen them then I think they need some discussion, though not a
lot.
[2]
+
+
+ def do_load(self, unique_key):
Dangerous extra blank line.
[3]
Some lint:
credentials.py:29: 'base64' imported but unused
credentials.py:34: 'sys' imported but unused
credentials.py:35: 'textwrap' imported but unused
credentials.py:37: 'quote' imported but unused
launchpad.py:25: 'socket' imported but unused
launchpad.py:26: 'stat' imported but unused
launchpad.py:29: 'HostedFile' imported but unused
launchpad.py:29: 'ScalarValue' imported but unused
launchpad.py:36: 'SystemWideCons
launchpad.py:52: 'STAGING_
launchpad.py:52: 'EDGE_SERVICE_ROOT' imported but unused
testing/
testing/
testing/
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
[4]
+ def __init__(self, file, credential_
+ super(Unencrypt
+ credential_
+ self.file = file
Because file() is a builtin function/type consider using filename or
filepath instead, or something like that.
[5]
+ This method is deprecated as of launchpadlib version
+ 1.9.0. You should use Launchpad.
+ anonymous access, Launchpad.
+ that need to run without any user interaction, and
+ Launchpad.
+ specifying a custom authorization_
Perhaps it's worth putting some or all of this into a deprecation
warning, warnings.
[6]
+@contextmanager
+def fake_keyring(fake):
+ original_keyring = launchpadlib.
+ launchpadlib.
+ yield
+ launchpadlib.
This will break when a test fails because the yield can raise an
exception. Something like the following would work better I think:
@contextmanager
def fake_keyring(fake):
orig...
Robert Collins (lifeless) wrote : | # |
So, these names then are essentially aliases to (pseudocode)
login(... store=keyringstore)
login(... store=filestore)
why not expose that instead.
login(... store=None)
"""
store: a store to request credentials from. If None, GnomeKeyringStore
will be used, as the most reliable and secure store we have available
today. Using a different store (see <...> for a list of stores) is
needed when running scripts outside of a desktop context such as from
cron.
"""
?
Leonard Richardson (leonardr) wrote : | # |
> So, these names then are essentially aliases to (pseudocode)
> login(... store=keyringstore)
> login(... store=filestore)
>
> why not expose that instead.
> login(... store=None)
> """
> store: a store to request credentials from. If None, GnomeKeyringStore
> will be used, as the most reliable and secure store we have available
> today. Using a different store (see <...> for a list of stores) is
> needed when running scripts outside of a desktop context such as from
> cron.
> """
I'm fine with this. The main problem is that we have already staked out the namespace of generic names. login() and login_with() are existing methods that I hope to deprecate with this branch.
I think it wouldn't be _too_ bad to change the behavior of login() in 1.9.0, since everyone I know of uses login_with(). We could even call it 2.0.0. (But, I think a 3.0.0 would not be far behind--I was planning to make another big backward-
Without an alias for cron usage, the code to run a cron script would look like this:
store = UnencryptedFile
launchpad = Launchpad.login("my application" "production", credential_
Martin, are you OK with this?
- 129. By Leonard Richardson
-
Response to feedback: lint cleanup, (hackily) replace mktemp with mkstemp.
Leonard Richardson (leonardr) wrote : | # |
I've addressed all of your comments except for [10]. I need to add
tests for the deprecation warning, which I will do with the
catch_warnings context manager. I also have some unknown test failures that
I don't remember having when I left for vacation last year, and I need
to look into those.
I got rid of most of the lint. launchpad.
launchpad.
client convenience, so I added comments to that effect.
Re: mkstemp. I deliberately avoided mkstemp because mkstemp creates an
empty file. lazr.restfulcli
if you give it an empty file. Should I do a lazr.restfulclient release
and make load_from_path() treat an empty file the same as a
nonexistant file?
In the meantime, I have put a hack in place to make mkstemp work on
the launchpadlib level.
See my previous comment re: the method names. I'm okay with one
generic name, but we took the most generic name ("login") in the first
version of launchpadlib, and then we took another generic name
("login_with") in a later revision.
Gavin Panella (allenap) wrote : | # |
> Re: mkstemp. I deliberately avoided mkstemp because mkstemp creates
> an empty file. lazr.restfulcli
> crashes if you give it an empty file. Should I do a
> lazr.restfulclient release and make load_from_path() treat an empty
> file the same as a nonexistant file?
>
> In the meantime, I have put a hack in place to make mkstemp work on
> the launchpadlib level.
An alternate approach might be to safely create a temporary directory
with tempfile.mkdtemp() then use static names within.
[13]
+ warnings.warn(
+ ("The Launchpad.%s() method is deprecated. You should use "
+ "Launchpad.
+ "Launchpad.
+ "without any user interaction, and Launchpad.
+ "for all other purposes.") % name)
I haven't used warnings much before so I don't know if it works well
in practice, but you could pass DeprecationWarning as a second
argument to warn(). I think this makes it easier for users to filter
it out without missing other warnings.
- 130. By Leonard Richardson
-
Re-activated deprecation warning.
- 131. By Leonard Richardson
-
Fix test failures, which were caused by cached responses from actual usage being sent to the NoNetworkLaunchpad.
- 132. By Leonard Richardson
-
Finally got rid of all test failures and unxpected deprecation warnings.
- 133. By Leonard Richardson
-
Removed login_insecurely and login_securely. They are both now part of login_with(), which has been un-deprecated.
- 134. By Leonard Richardson
-
We don't need to test credential_
save_failed twice now that there's only one method. - 135. By Leonard Richardson
-
Updated the NEWS to reflect my most recent change.
Leonard Richardson (leonardr) wrote : | # |
After evaluating my options I've decided to un-deprecate login_with(). This method acts like login_insecurely() when you specify a file to store the credential in, and acts like login_securely() when you don't. That's a perfect description of what I wanted login() to do.
Once we get rid of login(), we'll have space to come back to this problem and call the resulting method login(). At the very least, we need to rationalize the arguments to login_with(), which are out of control and presented in random order since we kept tacking arguments on to the end of the list.
Preview Diff
1 | === modified file 'src/launchpadlib/NEWS.txt' |
2 | --- src/launchpadlib/NEWS.txt 2010-12-20 12:41:52 +0000 |
3 | +++ src/launchpadlib/NEWS.txt 2011-01-04 19:04:27 +0000 |
4 | @@ -11,6 +11,13 @@ |
5 | |
6 | - The HTML generated by wadl-to-refhtml.xsl now validates. |
7 | |
8 | +- Most of the helper login methods have been deprecated. There are now |
9 | + only two helper methods: |
10 | + |
11 | + * Launchpad.login_anonymously, for anonymous credential-free access. |
12 | + * Launchpad.login_with, for programs that need a credential. |
13 | + |
14 | + |
15 | 1.8.0 (2010-11-15) |
16 | ================== |
17 | |
18 | |
19 | === modified file 'src/launchpadlib/credentials.py' |
20 | --- src/launchpadlib/credentials.py 2010-12-16 22:33:31 +0000 |
21 | +++ src/launchpadlib/credentials.py 2011-01-04 19:04:27 +0000 |
22 | @@ -21,19 +21,19 @@ |
23 | 'AccessToken', |
24 | 'AnonymousAccessToken', |
25 | 'AuthorizeRequestTokenWithBrowser', |
26 | + 'CredentialStore', |
27 | 'RequestTokenAuthorizationEngine', |
28 | 'Consumer', |
29 | 'Credentials', |
30 | ] |
31 | |
32 | -import base64 |
33 | import cgi |
34 | from cStringIO import StringIO |
35 | import httplib2 |
36 | -import sys |
37 | -import textwrap |
38 | +import os |
39 | +import stat |
40 | import time |
41 | -from urllib import urlencode, quote |
42 | +from urllib import urlencode |
43 | from urlparse import urljoin |
44 | import webbrowser |
45 | |
46 | @@ -212,6 +212,122 @@ |
47 | super(AnonymousAccessToken, self).__init__('','') |
48 | |
49 | |
50 | +class CredentialStore(object): |
51 | + """Store OAuth credentials locally. |
52 | + |
53 | + This is a generic superclass. To implement a specific way of |
54 | + storing credentials locally you'll need to subclass this class, |
55 | + and implement `do_save` and `do_load`. |
56 | + """ |
57 | + |
58 | + def __init__(self, credential_save_failed=None): |
59 | + """Constructor. |
60 | + |
61 | + :param credential_save_failed: A callback to be invoked if the |
62 | + save to local storage fails. You should never invoke this |
63 | + callback yourself! Instead, you should raise an exception |
64 | + from do_save(). |
65 | + """ |
66 | + self.credential_save_failed = credential_save_failed |
67 | + |
68 | + def save(self, credentials, unique_consumer_id): |
69 | + """Save the credentials and invoke the callback on failure.""" |
70 | + try: |
71 | + self.do_save(credentials, unique_consumer_id) |
72 | + except EXPLOSIVE_ERRORS: |
73 | + raise |
74 | + except: |
75 | + if self.credential_save_failed is not None: |
76 | + self.credential_save_failed() |
77 | + return credentials |
78 | + |
79 | + def do_save(self, credentials, unique_consumer_id): |
80 | + """Store newly-authorized credentials locally for later use. |
81 | + |
82 | + :param credentials: A Credentials object to save. |
83 | + :param unique_consumer_id: A string uniquely identifying an |
84 | + OAuth consumer on a Launchpad instance. |
85 | + """ |
86 | + raise NotImplementedError() |
87 | + |
88 | + def load(self, unique_key): |
89 | + """Retrieve credentials from a local store. |
90 | + |
91 | + This method is the inverse of `save`. |
92 | + |
93 | + There's no special behavior in this method--it just calls |
94 | + `do_load`. There _is_ special behavior in `save`, and this |
95 | + way, developers can remember to implement `do_save` and |
96 | + `do_load`, not `do_save` and `load`. |
97 | + |
98 | + :param unique_key: A string uniquely identifying an OAuth consumer |
99 | + on a Launchpad instance. |
100 | + |
101 | + :return: A `Credentials` object if one is found in the local |
102 | + store, and None otherise. |
103 | + """ |
104 | + return self.do_load(unique_key) |
105 | + |
106 | + def do_load(self, unique_key): |
107 | + """Retrieve credentials from a local store. |
108 | + |
109 | + This method is the inverse of `do_save`. |
110 | + |
111 | + :param unique_key: A string uniquely identifying an OAuth consumer |
112 | + on a Launchpad instance. |
113 | + |
114 | + :return: A `Credentials` object if one is found in the local |
115 | + store, and None otherise. |
116 | + """ |
117 | + raise NotImplementedError() |
118 | + |
119 | + |
120 | +class KeyringCredentialStore(CredentialStore): |
121 | + """Store credentials in the GNOME keyring or KDE wallet. |
122 | + |
123 | + This is a good solution for desktop applications and interactive |
124 | + scripts. It doesn't work for non-interactive scripts, or for |
125 | + integrating third-party websites into Launchpad. |
126 | + """ |
127 | + |
128 | + def do_save(self, credentials, unique_key): |
129 | + """Store newly-authorized credentials in the keyring.""" |
130 | + keyring.set_password( |
131 | + 'launchpadlib', unique_key, credentials.serialize()) |
132 | + |
133 | + def do_load(self, unique_key): |
134 | + """Retrieve credentials from the keyring.""" |
135 | + credential_string = keyring.get_password( |
136 | + 'launchpadlib', unique_key) |
137 | + if credential_string is not None: |
138 | + return Credentials.from_string(credential_string) |
139 | + return None |
140 | + |
141 | + |
142 | +class UnencryptedFileCredentialStore(CredentialStore): |
143 | + """Store credentials unencrypted in a file on disk. |
144 | + |
145 | + This is a good solution for scripts that need to run without any |
146 | + user interaction. |
147 | + """ |
148 | + |
149 | + def __init__(self, filename, credential_save_failed=None): |
150 | + super(UnencryptedFileCredentialStore, self).__init__( |
151 | + credential_save_failed) |
152 | + self.filename = filename |
153 | + |
154 | + def do_save(self, credentials, unique_key): |
155 | + """Save the credentials to disk.""" |
156 | + credentials.save_to_path(self.filename) |
157 | + |
158 | + def do_load(self, unique_key): |
159 | + """Load the credentials from disk.""" |
160 | + if (os.path.exists(self.filename) |
161 | + and not os.stat(self.filename)[stat.ST_SIZE] == 0): |
162 | + return Credentials.load_from_path(self.filename) |
163 | + return None |
164 | + |
165 | + |
166 | class RequestTokenAuthorizationEngine(object): |
167 | """The superclass of all request token authorizers. |
168 | |
169 | @@ -219,18 +335,12 @@ |
170 | since that varies depending on how you want the end-user to |
171 | authorize a request token. You'll need to subclass this class and |
172 | implement `make_end_user_authorize_token`. |
173 | - |
174 | - This class does implement a default strategy for storing |
175 | - authorized tokens in the GNOME keyring or KDE Wallet, but you can |
176 | - override this by implementing `store_credentials_locally` and |
177 | - `retrieve_credentials_from_local_store`. |
178 | """ |
179 | |
180 | UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED" |
181 | |
182 | def __init__(self, service_root, application_name=None, |
183 | - consumer_name=None, credential_save_failed=None, |
184 | - allow_access_levels=None): |
185 | + consumer_name=None, allow_access_levels=None): |
186 | """Base class initialization. |
187 | |
188 | :param service_root: The root of the Launchpad instance being |
189 | @@ -250,12 +360,6 @@ |
190 | integration. The exception is when you're integrating a |
191 | third-party website into Launchpad. |
192 | |
193 | - :param credential_save_failed: A callback method to be invoked |
194 | - if the credentials cannot be stored locally. |
195 | - |
196 | - You should not invoke this method yourself; instead, you |
197 | - should raise an exception in `store_credentials_locally()`. |
198 | - |
199 | :param allow_access_levels: A list of the Launchpad access |
200 | levels to present to the user. ('READ_PUBLIC' and so on.) |
201 | Your value for this argument will be ignored during a |
202 | @@ -272,7 +376,8 @@ |
203 | if application_name is not None and consumer_name is not None: |
204 | raise ValueError( |
205 | "You must provide only one of application_name and " |
206 | - "consumer_name.") |
207 | + "consumer_name. (You provided %r and %r.)" % ( |
208 | + application_name, consumer_name)) |
209 | |
210 | if consumer_name is None: |
211 | # System-wide integration. Create a system-wide consumer |
212 | @@ -290,7 +395,11 @@ |
213 | self.application_name = application_name |
214 | |
215 | self.allow_access_levels = allow_access_levels or [] |
216 | - self.credential_save_failed = credential_save_failed |
217 | + |
218 | + @property |
219 | + def unique_consumer_id(self): |
220 | + """Return a string identifying this consumer on this host.""" |
221 | + return self.consumer.key + '@' + self.service_root |
222 | |
223 | def authorization_url(self, request_token): |
224 | """Return the authorization URL for a request token. |
225 | @@ -307,17 +416,22 @@ |
226 | + allow_permission.join(self.allow_access_levels)) |
227 | return urljoin(self.web_root, page) |
228 | |
229 | - def __call__(self, credentials): |
230 | + def __call__(self, credentials, credential_store): |
231 | """Authorize a token and associate it with the given credentials. |
232 | |
233 | - The `credential_save_failed` callback will be invoked if |
234 | - there's a problem storing the credentials locally. It will not |
235 | - be invoked if there's a problem authorizing the credentials. |
236 | + If the credential store runs into a problem storing the |
237 | + credential locally, the `credential_save_failed` callback will |
238 | + be invoked. The callback will not be invoked if there's a |
239 | + problem authorizing the credentials. |
240 | |
241 | :param credentials: A `Credentials` object. If the end-user |
242 | authorizes these credentials, this object will have its |
243 | .access_token property set. |
244 | |
245 | + :param credential_store: A `CredentialStore` object. If the |
246 | + end-user authorizes the credentials, they will be |
247 | + persisted locally using this object. |
248 | + |
249 | :return: If the credentials are successfully authorized, the |
250 | return value is the `Credentials` object originally passed |
251 | in. Otherwise the return value is None. |
252 | @@ -328,13 +442,8 @@ |
253 | if credentials.access_token is None: |
254 | # The end-user refused to authorize the application. |
255 | return None |
256 | - try: |
257 | - self.store_credentials_locally(credentials) |
258 | - except EXPLOSIVE_ERRORS: |
259 | - raise |
260 | - except: |
261 | - if self.credential_save_failed is not None: |
262 | - self.credential_save_failed() |
263 | + # save() invokes the callback on failure. |
264 | + credential_store.save(credentials, self.unique_consumer_id) |
265 | return credentials |
266 | |
267 | def get_request_token(self, credentials): |
268 | @@ -363,43 +472,6 @@ |
269 | """ |
270 | raise NotImplementedError() |
271 | |
272 | - def store_credentials_locally(self, credentials): |
273 | - """Store newly-authorized credentials for later use. |
274 | - |
275 | - By default, credentials are stored in the GNOME keyring or KDE |
276 | - Wallet. This is a good solution for desktop applications and |
277 | - local scripts, but if you are integrating a third-party |
278 | - website into Launchpad you'll need to implement something else |
279 | - (such as storing the credentials in a database). |
280 | - """ |
281 | - keyring.set_password( |
282 | - 'launchpadlib', |
283 | - (credentials.consumer.key + '@' + self.service_root), |
284 | - credentials.serialize()) |
285 | - |
286 | - def retrieve_credentials_from_local_store(self): |
287 | - """Retrieve credentials from a local store. |
288 | - |
289 | - This method is the inverse of `store_credentials_locally`. The |
290 | - default implementation looks for credentials stored in the |
291 | - GNOME keyring or KDE Wallet. |
292 | - |
293 | - :return: A `Credentials` object if one is found in the local |
294 | - store, and None otherise. |
295 | - """ |
296 | - credential_string = keyring.get_password( |
297 | - 'launchpadlib', self.consumer.key + '@' + self.service_root) |
298 | - if credential_string is not None: |
299 | - credentials = Credentials.from_string(credential_string) |
300 | - # The application name wasn't stored locally, because in a |
301 | - # desktop integration scenario, a single set of |
302 | - # credentials may be shared by many applications. We need |
303 | - # to set the application name for this specific instance |
304 | - # of the credentials. |
305 | - credentials.consumer.application_name = self.application_name |
306 | - return credentials |
307 | - return None |
308 | - |
309 | |
310 | class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine): |
311 | """The simplest (and, right now, the only) request token authorizer. |
312 | |
313 | === modified file 'src/launchpadlib/launchpad.py' |
314 | --- src/launchpadlib/launchpad.py 2010-12-16 21:47:02 +0000 |
315 | +++ src/launchpadlib/launchpad.py 2011-01-04 19:04:27 +0000 |
316 | @@ -22,24 +22,23 @@ |
317 | ] |
318 | |
319 | import os |
320 | -import socket |
321 | -import stat |
322 | import urlparse |
323 | +import warnings |
324 | |
325 | from lazr.restfulclient.resource import ( |
326 | CollectionWithKeyBasedLookup, |
327 | - HostedFile, |
328 | - ScalarValue, |
329 | + HostedFile, # Re-import for client convenience |
330 | + ScalarValue, # Re-import for client convenience |
331 | ServiceRoot, |
332 | ) |
333 | from lazr.restfulclient._browser import RestfulHttp |
334 | from launchpadlib.credentials import ( |
335 | AccessToken, |
336 | AnonymousAccessToken, |
337 | - AuthorizeRequestTokenWithBrowser, |
338 | Consumer, |
339 | Credentials, |
340 | - SystemWideConsumer, |
341 | + KeyringCredentialStore, |
342 | + UnencryptedFileCredentialStore, |
343 | ) |
344 | from launchpadlib import uris |
345 | |
346 | @@ -135,7 +134,8 @@ |
347 | and self.authorization_engine is not None): |
348 | # This access token is bad. Scrap it and create a new one. |
349 | self.launchpad.credentials.access_token = None |
350 | - self.authorization_engine(self.launchpad.credentials) |
351 | + self.authorization_engine( |
352 | + self.launchpad.credentials, self.launchpad.credential_store) |
353 | # Retry the request with the new credentials. |
354 | return self._request(*args) |
355 | return response, content |
356 | @@ -160,7 +160,7 @@ |
357 | RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES) |
358 | |
359 | def __init__(self, credentials, authorization_engine, |
360 | - service_root=uris.STAGING_SERVICE_ROOT, |
361 | + credential_store, service_root=uris.STAGING_SERVICE_ROOT, |
362 | cache=None, timeout=None, proxy_info=None, |
363 | version=DEFAULT_VERSION): |
364 | """Root access to the Launchpad API. |
365 | @@ -186,6 +186,8 @@ |
366 | "the version name from the root URI." % version) |
367 | raise ValueError(error) |
368 | |
369 | + self.credential_store = credential_store |
370 | + |
371 | # We already have an access token, but it might expire or |
372 | # become invalid during use. Store the authorization engine in |
373 | # case we need to authorize a new token during use. |
374 | @@ -204,18 +206,27 @@ |
375 | return AuthorizeRequestTokenWithBrowser(*args) |
376 | |
377 | @classmethod |
378 | + def credential_store_factory(cls, credential_save_failed): |
379 | + return KeyringCredentialStore(credential_save_failed) |
380 | + |
381 | + @classmethod |
382 | def login(cls, consumer_name, token_string, access_secret, |
383 | service_root=uris.STAGING_SERVICE_ROOT, |
384 | cache=None, timeout=None, proxy_info=None, |
385 | authorization_engine=None, allow_access_levels=None, |
386 | - max_failed_attempts=None, credential_save_failed=None, |
387 | - version=DEFAULT_VERSION): |
388 | + max_failed_attempts=None, credential_store=None, |
389 | + credential_save_failed=None, version=DEFAULT_VERSION): |
390 | """Convenience method for setting up access credentials. |
391 | |
392 | When all three pieces of credential information (the consumer |
393 | name, the access token and the access secret) are available, this |
394 | method can be used to quickly log into the service root. |
395 | |
396 | + This method is deprecated as of launchpadlib version |
397 | + 1.9.0. You should use Launchpad.login_anonymously() for |
398 | + anonymous access, and Launchpad.login_with() for all other |
399 | + purposes. |
400 | + |
401 | :param consumer_name: the application name. |
402 | :type consumer_name: string |
403 | :param token_string: the access token, as appropriate for the |
404 | @@ -237,36 +248,33 @@ |
405 | :return: The web service root |
406 | :rtype: `Launchpad` |
407 | """ |
408 | + cls._warn_of_deprecated_login_method("login") |
409 | access_token = AccessToken(token_string, access_secret) |
410 | credentials = Credentials( |
411 | consumer_name=consumer_name, access_token=access_token) |
412 | if authorization_engine is None: |
413 | authorization_engine = cls.authorization_engine_factory( |
414 | - service_root, None, consumer_name, allow_access_levels, |
415 | + service_root, consumer_name, allow_access_levels) |
416 | + if credential_store is None: |
417 | + credential_store = cls.credential_store_factory( |
418 | credential_save_failed) |
419 | - return cls(credentials, authorization_engine, service_root, cache, |
420 | - timeout, proxy_info, version) |
421 | + return cls(credentials, authorization_engine, credential_store, |
422 | + service_root, cache, timeout, proxy_info, version) |
423 | |
424 | @classmethod |
425 | def get_token_and_login(cls, consumer_name, |
426 | service_root=uris.STAGING_SERVICE_ROOT, |
427 | cache=None, timeout=None, proxy_info=None, |
428 | authorization_engine=None, allow_access_levels=[], |
429 | - max_failed_attempts=None, |
430 | + max_failed_attempts=None, credential_store=None, |
431 | credential_save_failed=None, |
432 | version=DEFAULT_VERSION): |
433 | """Get credentials from Launchpad and log into the service root. |
434 | |
435 | - This method is deprecated as of launchpadlib version 1.9.0. A |
436 | - launchpadlib application running on the end-user's computer |
437 | - should use `Launchpad.login_with()`. A launchpadlib |
438 | - application running on a web server, integrating Launchpad |
439 | - into some other website, should use |
440 | - `Credentials.get_request_token()` to obtain the authorization |
441 | - URL and |
442 | - `Credentials.exchange_request_token_for_access_token()` to |
443 | - obtain the actual OAuth access token. Then you can call |
444 | - `Launchpad.login()`. |
445 | + This method is deprecated as of launchpadlib version |
446 | + 1.9.0. You should use Launchpad.login_anonymously() for |
447 | + anonymous access and Launchpad.login_with() for all other |
448 | + purposes. |
449 | |
450 | :param consumer_name: Either a consumer name, as appropriate for |
451 | the `Consumer` constructor, or a premade Consumer object. |
452 | @@ -279,11 +287,28 @@ |
453 | `credential_save_failed`. |
454 | :param allow_access_levels: This argument is ignored, and only |
455 | present to preserve backwards compatibility. |
456 | - :param max_failed_attempts: This argument is ignored, and only |
457 | - present to preserve backwards compatibility. |
458 | :return: The web service root |
459 | :rtype: `Launchpad` |
460 | """ |
461 | + cls._warn_of_deprecated_login_method("get_token_and_login") |
462 | + return cls._authorize_token_and_login( |
463 | + consumer_name, service_root, cache, timeout, proxy_info, |
464 | + authorization_engine, allow_access_levels, |
465 | + credential_store, credential_save_failed, version) |
466 | + |
467 | + @classmethod |
468 | + def _authorize_token_and_login( |
469 | + cls, consumer_name, service_root, cache, timeout, proxy_info, |
470 | + authorization_engine, allow_access_levels, credential_store, |
471 | + credential_save_failed, version): |
472 | + """Authorize a request token. Log in with the resulting access token. |
473 | + |
474 | + This is the private, non-deprecated implementation of the |
475 | + deprecated method get_token_and_login(). Once |
476 | + get_token_and_login() is removed, this code can be streamlined |
477 | + and moved into its other call site, login_with(). |
478 | + """ |
479 | + |
480 | if isinstance(consumer_name, Consumer): |
481 | # Create the credentials with no Consumer, then set its .consumer |
482 | # property directly. |
483 | @@ -295,11 +320,23 @@ |
484 | credentials = Credentials(consumer_name) |
485 | if authorization_engine is None: |
486 | authorization_engine = cls.authorization_engine_factory( |
487 | - service_root, None, consumer_name, allow_access_levels, |
488 | + service_root, consumer_name, None, allow_access_levels) |
489 | + if credential_store is None: |
490 | + credential_store = cls.credential_store_factory( |
491 | credential_save_failed) |
492 | - credentials = authorization_engine(credentials) |
493 | - return cls(credentials, authorization_engine, service_root, cache, |
494 | - timeout, proxy_info, version) |
495 | + else: |
496 | + # A credential store was passed in, so we won't be using |
497 | + # any provided value for credential_save_failed. But at |
498 | + # least make sure we weren't given a conflicting value, |
499 | + # since that makes the calling code look confusing. |
500 | + cls._assert_login_argument_consistency( |
501 | + "credential_save_failed", credential_save_failed, |
502 | + credential_store.credential_save_failed, |
503 | + "credential_store") |
504 | + |
505 | + credentials = authorization_engine(credentials, credential_store) |
506 | + return cls(credentials, authorization_engine, credential_store, |
507 | + service_root, cache, timeout, proxy_info, version) |
508 | |
509 | @classmethod |
510 | def login_anonymously( |
511 | @@ -311,7 +348,7 @@ |
512 | service_root_dir) = cls._get_paths(service_root, launchpadlib_dir) |
513 | token = AnonymousAccessToken() |
514 | credentials = Credentials(consumer_name, access_token=token) |
515 | - return cls(credentials, None, service_root=service_root, |
516 | + return cls(credentials, None, None, service_root=service_root, |
517 | cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
518 | version=version) |
519 | |
520 | @@ -322,23 +359,36 @@ |
521 | authorization_engine=None, allow_access_levels=None, |
522 | max_failed_attempts=None, credentials_file=None, |
523 | version=DEFAULT_VERSION, consumer_name=None, |
524 | - credential_save_failed=None): |
525 | - """Log in to Launchpad with possibly cached credentials. |
526 | + credential_save_failed=None, credential_store=None): |
527 | + """Log in to Launchpad, possibly acquiring and storing credentials. |
528 | |
529 | - Use this method to get a `Launchpad` object if you are writing |
530 | - a desktop application or a script. If the end-user has no |
531 | - cached Launchpad credential, their browser will open and |
532 | - they'll be asked to log in and authorize a desktop |
533 | + Use this method to get a `Launchpad` object. If the end-user |
534 | + has no cached Launchpad credential, their browser will open |
535 | + and they'll be asked to log in and authorize a desktop |
536 | integration. The authorized Launchpad credential will be |
537 | - stored, so that the next time your program (or any other |
538 | - program run by that user on the same computer) needs a |
539 | - Launchpad credential, it will be retrieved from local storage. |
540 | - |
541 | - By subclassing `RequestTokenAuthorizationEngine` and passing |
542 | - in an instance of the subclass as `authorization_engine`, you |
543 | - can change what happens when the end-user needs to authorize |
544 | - the Launchpad credential, and you can change how the |
545 | - authorized credential is stored and retrieved locally. |
546 | + stored securely: in the GNOME keyring, the KDE Wallet, or in |
547 | + an encrypted file on disk. |
548 | + |
549 | + The next time your program (or any other program run by that |
550 | + user on the same computer) invokes this method, the end-user |
551 | + will be prompted to unlock their keyring (or equivalent), and |
552 | + the credential will be retrieved from local storage and |
553 | + reused. |
554 | + |
555 | + You can customize this behavior in three ways: |
556 | + |
557 | + 1. Pass in a filename to `credentials_file`. The end-user's |
558 | + credential will be written to that file, and on subsequent |
559 | + runs read from that file. |
560 | + |
561 | + 2. Subclass `CredentialStore` and pass in an instance of the |
562 | + subclass as `credential_store`. This lets you change how |
563 | + the end-user's credential is stored and retrieved locally. |
564 | + |
565 | + 3. Subclass `RequestTokenAuthorizationEngine` and pass in an |
566 | + instance of the subclass as `authorization_engine`. This |
567 | + lets you change change what happens when the end-user needs |
568 | + to authorize the Launchpad credential. |
569 | |
570 | :param application_name: The application name. This is *not* |
571 | the OAuth consumer name. Unless a consumer_name is also |
572 | @@ -356,25 +406,14 @@ |
573 | cache. |
574 | :type launchpadlib_dir: string |
575 | |
576 | - :param authorization_engine: A strategy for getting the end-user to |
577 | - authorize an OAuth request token, for exchanging the |
578 | - request token for an access token, and for storing the |
579 | - access token locally so that it can be reused. |
580 | + :param authorization_engine: A strategy for getting the |
581 | + end-user to authorize an OAuth request token, for |
582 | + exchanging the request token for an access token, and for |
583 | + storing the access token locally so that it can be |
584 | + reused. By default, launchpadlib will open the end-user's |
585 | + web browser to have them authorize the request token. |
586 | :type authorization_engine: `RequestTokenAuthorizationEngine` |
587 | |
588 | - :param credential_save_failed: a callback that is called upon |
589 | - a failure to save the credentials locally. This argument is |
590 | - used to construct the default `authorization_engine`, so if |
591 | - you pass in your own `authorization_engine` any value for |
592 | - this argument will be ignored. |
593 | - :type credential_save_failed: A callable |
594 | - |
595 | - :param consumer_name: The consumer name, as appropriate for |
596 | - the `Consumer` constructor. You probably don't want to |
597 | - provide this, since providing it will prevent you from |
598 | - taking advantage of desktop-wide integration. |
599 | - :type consumer_name: string |
600 | - |
601 | :param allow_access_levels: The acceptable access levels for |
602 | this application. |
603 | |
604 | @@ -386,8 +425,32 @@ |
605 | |
606 | :type allow_access_levels: list of strings |
607 | |
608 | - :param credentials_file: ignored, only here for backward compatability |
609 | - :type credentials_file: string |
610 | + :param max_failed_attempts: Ignored; only present for |
611 | + backwards compatibility. |
612 | + |
613 | + :param credentials_file: The path to a file in which to store |
614 | + this user's OAuth access token. |
615 | + |
616 | + :param version: The version of the Launchpad web service to use. |
617 | + |
618 | + :param consumer_name: The consumer name, as appropriate for |
619 | + the `Consumer` constructor. You probably don't want to |
620 | + provide this, since providing it will prevent you from |
621 | + taking advantage of desktop-wide integration. |
622 | + :type consumer_name: string |
623 | + |
624 | + :param credential_save_failed: a callback that is called upon |
625 | + a failure to save the credentials locally. This argument is |
626 | + used to construct the default `credential_store`, so if |
627 | + you pass in your own `credential_store` any value for |
628 | + this argument will be ignored. |
629 | + :type credential_save_failed: A callable |
630 | + |
631 | + :param credential_store: A strategy for storing an OAuth |
632 | + access token locally. By default, tokens are stored in the |
633 | + GNOME keyring (or equivalent). If `credentials_file` is |
634 | + provided, then tokens are stored unencrypted in that file. |
635 | + :type credential_store: `CredentialStore` |
636 | |
637 | :return: A web service root authorized as the end-user. |
638 | :rtype: `Launchpad` |
639 | @@ -402,69 +465,111 @@ |
640 | "At least one of application_name, consumer_name, or " |
641 | "authorization_engine must be provided.") |
642 | |
643 | + if credentials_file is not None and credential_store is not None: |
644 | + raise ValueError( |
645 | + "At most one of credentials_file and credential_store " |
646 | + "must be provided.") |
647 | + |
648 | + if credential_store is None: |
649 | + if credentials_file is not None: |
650 | + # The end-user wants credentials stored in an |
651 | + # unencrypted file. |
652 | + credential_store = UnencryptedFileCredentialStore( |
653 | + credentials_file, credential_save_failed) |
654 | + else: |
655 | + credential_store = cls.credential_store_factory( |
656 | + credential_save_failed) |
657 | + else: |
658 | + # A credential store was passed in, so we won't be using |
659 | + # any provided value for credential_save_failed. But at |
660 | + # least make sure we weren't given a conflicting value, |
661 | + # since that makes the calling code look confusing. |
662 | + cls._assert_login_argument_consistency( |
663 | + 'credential_save_failed', credential_save_failed, |
664 | + credential_store.credential_save_failed, |
665 | + "credential_store") |
666 | + credential_store = credential_store |
667 | + |
668 | if authorization_engine is None: |
669 | authorization_engine = cls.authorization_engine_factory( |
670 | service_root, application_name, consumer_name, |
671 | - credential_save_failed, allow_access_levels) |
672 | + allow_access_levels) |
673 | else: |
674 | # An authorization engine was passed in, so we won't be |
675 | # using any provided values for application_name, |
676 | # consumer_name, or allow_access_levels. But at least make |
677 | # sure we weren't given conflicting values, since that |
678 | # makes the calling code look confusing. |
679 | - cls._assert_login_with_argument_consistency( |
680 | + cls._assert_login_argument_consistency( |
681 | "application_name", application_name, |
682 | authorization_engine.application_name) |
683 | |
684 | - cls._assert_login_with_argument_consistency( |
685 | + cls._assert_login_argument_consistency( |
686 | "consumer_name", consumer_name, |
687 | authorization_engine.consumer.key) |
688 | |
689 | - cls._assert_login_with_argument_consistency( |
690 | + cls._assert_login_argument_consistency( |
691 | "allow_access_levels", allow_access_levels, |
692 | authorization_engine.allow_access_levels) |
693 | |
694 | - credentials = authorization_engine.retrieve_credentials_from_local_store() |
695 | + credentials = credential_store.load( |
696 | + authorization_engine.unique_consumer_id) |
697 | |
698 | if credentials is None: |
699 | # Credentials were not found in the local store. Go |
700 | # through the authorization process. |
701 | - launchpad = cls.get_token_and_login( |
702 | - authorization_engine.consumer, service_root=service_root, |
703 | - cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
704 | - authorization_engine=authorization_engine, |
705 | - version=version) |
706 | + launchpad = cls._authorize_token_and_login( |
707 | + authorization_engine.consumer, service_root, |
708 | + cache_path, timeout, proxy_info, authorization_engine, |
709 | + allow_access_levels, credential_store, |
710 | + credential_save_failed, version) |
711 | else: |
712 | + # The application name wasn't stored locally, because in a |
713 | + # desktop integration scenario, a single set of |
714 | + # credentials may be shared by many applications. We need |
715 | + # to set the application name for this specific instance |
716 | + # of the credentials. |
717 | + credentials.consumer.application_name = ( |
718 | + authorization_engine.application_name) |
719 | + |
720 | # Credentials were found in the local store. Create a |
721 | # Launchpad object. |
722 | launchpad = cls( |
723 | - credentials, authorization_engine, service_root=service_root, |
724 | - cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
725 | - version=version) |
726 | + credentials, authorization_engine, credential_store, |
727 | + service_root=service_root, cache=cache_path, timeout=timeout, |
728 | + proxy_info=proxy_info, version=version) |
729 | return launchpad |
730 | |
731 | @classmethod |
732 | - def _assert_login_with_argument_consistency( |
733 | - cls, argument_name, argument_value, authorization_engine_value): |
734 | - """Helper to find conflicting values passed into login_with. |
735 | - |
736 | - Many of the arguments to login_with are used to build an |
737 | - authorization engine. If an authorization engine is passed in, |
738 | - many of the arguments become redundant. We'll allow redundant |
739 | - arguments through, but if a login_with argument *conflicts* with |
740 | - the argument in the provided authorization engine, we raise an |
741 | - error. |
742 | + def _warn_of_deprecated_login_method(cls, name): |
743 | + warnings.warn( |
744 | + ("The Launchpad.%s() method is deprecated. You should use " |
745 | + "Launchpad.login_anonymous() for anonymous access and " |
746 | + "Launchpad.login_with() for all other purposes.") % name, |
747 | + DeprecationWarning) |
748 | + |
749 | + @classmethod |
750 | + def _assert_login_argument_consistency( |
751 | + cls, argument_name, argument_value, object_value, |
752 | + object_name="authorization engine"): |
753 | + """Helper to find conflicting values passed into the login methods. |
754 | + |
755 | + Many of the arguments to login_with are used to build other |
756 | + objects--the authorization engine or the credential store. If |
757 | + these objects are provided directly, many of the arguments |
758 | + become redundant. We'll allow redundant arguments through, but |
759 | + if a argument *conflicts* with the corresponding value in the |
760 | + provided object, we raise an error. |
761 | """ |
762 | inconsistent_value_message = ( |
763 | "Inconsistent values given for %s: " |
764 | - "(%r passed in to login_with(), versus %r in authorization " |
765 | - "engine). You don't need to pass in %s to login_with() if you " |
766 | - "pass in an authorization engine, so just omit that argument.") |
767 | - if (argument_value is not None |
768 | - and argument_value != authorization_engine_value): |
769 | + "(%r passed in, versus %r in %s). " |
770 | + "You don't need to pass in %s if you pass in %s, " |
771 | + "so just omit that argument.") |
772 | + if (argument_value is not None and argument_value != object_value): |
773 | raise ValueError(inconsistent_value_message % ( |
774 | - argument_name, argument_value, authorization_engine_value, |
775 | - argument_name)) |
776 | + argument_name, argument_value, object_value, |
777 | + object_name, argument_name, object_name)) |
778 | |
779 | |
780 | @classmethod |
781 | |
782 | === modified file 'src/launchpadlib/testing/helpers.py' |
783 | --- src/launchpadlib/testing/helpers.py 2010-12-16 21:47:02 +0000 |
784 | +++ src/launchpadlib/testing/helpers.py 2011-01-04 19:04:27 +0000 |
785 | @@ -21,6 +21,10 @@ |
786 | |
787 | __metaclass__ = type |
788 | __all__ = [ |
789 | + 'BadSaveKeyring', |
790 | + 'fake_keyring', |
791 | + 'FauxSocketModule', |
792 | + 'InMemoryKeyring', |
793 | 'NoNetworkAuthorizationEngine', |
794 | 'NoNetworkLaunchpad', |
795 | 'TestableLaunchpad', |
796 | @@ -29,13 +33,12 @@ |
797 | 'salgado_with_full_permissions', |
798 | ] |
799 | |
800 | -import simplejson |
801 | +from contextlib import contextmanager |
802 | |
803 | +import launchpadlib |
804 | from launchpadlib.launchpad import Launchpad |
805 | from launchpadlib.credentials import ( |
806 | AccessToken, |
807 | - AuthorizeRequestTokenWithBrowser, |
808 | - Credentials, |
809 | RequestTokenAuthorizationEngine, |
810 | ) |
811 | |
812 | @@ -91,10 +94,11 @@ |
813 | It can't be used to interact with the API. |
814 | """ |
815 | |
816 | - def __init__(self, credentials, authorization_engine, service_root, |
817 | - cache, timeout, proxy_info, version): |
818 | + def __init__(self, credentials, authorization_engine, credential_store, |
819 | + service_root, cache, timeout, proxy_info, version): |
820 | self.credentials = credentials |
821 | self.authorization_engine = authorization_engine |
822 | + self.credential_store = credential_store |
823 | self.passed_in_args = dict( |
824 | service_root=service_root, cache=cache, timeout=timeout, |
825 | proxy_info=proxy_info, version=version) |
826 | @@ -104,8 +108,51 @@ |
827 | return NoNetworkAuthorizationEngine(*args) |
828 | |
829 | |
830 | +@contextmanager |
831 | +def fake_keyring(fake): |
832 | + original_keyring = launchpadlib.credentials.keyring |
833 | + launchpadlib.credentials.keyring = fake |
834 | + try: |
835 | + yield |
836 | + finally: |
837 | + launchpadlib.credentials.keyring = original_keyring |
838 | + |
839 | + |
840 | +class FauxSocketModule: |
841 | + """A socket module replacement that provides a fake hostname.""" |
842 | + |
843 | + def gethostname(self): |
844 | + return 'HOSTNAME' |
845 | + |
846 | + |
847 | +class BadSaveKeyring: |
848 | + """A keyring that generates errors when saving passwords.""" |
849 | + |
850 | + def get_password(self, service, username): |
851 | + return None |
852 | + |
853 | + def set_password(self, service, username, password): |
854 | + raise RuntimeError |
855 | + |
856 | + |
857 | +class InMemoryKeyring: |
858 | + """A keyring that saves passwords only in memory.""" |
859 | + |
860 | + def __init__(self): |
861 | + self.data = {} |
862 | + |
863 | + def set_password(self, service, username, password): |
864 | + self.data[service, username] = password |
865 | + |
866 | + def get_password(self, service, username): |
867 | + return self.data.get((service, username)) |
868 | + |
869 | + |
870 | class KnownTokens: |
871 | - """Known access token/secret combinations.""" |
872 | + """Known access token/secret combinations. |
873 | + |
874 | + XXX This will need to be redone when integrating into Launchpad. |
875 | + """ |
876 | |
877 | def __init__(self, token_string, access_secret): |
878 | self.token_string = token_string |
879 | |
880 | === added file 'src/launchpadlib/tests/test_credential_store.py' |
881 | --- src/launchpadlib/tests/test_credential_store.py 1970-01-01 00:00:00 +0000 |
882 | +++ src/launchpadlib/tests/test_credential_store.py 2011-01-04 19:04:27 +0000 |
883 | @@ -0,0 +1,138 @@ |
884 | +# Copyright 2010 Canonical Ltd. |
885 | + |
886 | +# This file is part of launchpadlib. |
887 | +# |
888 | +# launchpadlib is free software: you can redistribute it and/or modify it |
889 | +# under the terms of the GNU Lesser General Public License as published by the |
890 | +# Free Software Foundation, version 3 of the License. |
891 | +# |
892 | +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
893 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
894 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
895 | +# for more details. |
896 | +# |
897 | +# You should have received a copy of the GNU Lesser General Public License |
898 | +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
899 | + |
900 | +"""Tests for the credential store classes.""" |
901 | + |
902 | +import os |
903 | +import tempfile |
904 | +import unittest |
905 | + |
906 | +from launchpadlib.testing.helpers import ( |
907 | + fake_keyring, |
908 | + InMemoryKeyring, |
909 | +) |
910 | + |
911 | +from launchpadlib.credentials import ( |
912 | + AccessToken, |
913 | + Credentials, |
914 | + KeyringCredentialStore, |
915 | + UnencryptedFileCredentialStore, |
916 | +) |
917 | + |
918 | + |
919 | +class CredentialStoreTestCase(unittest.TestCase): |
920 | + |
921 | + def make_credential(self, consumer_key): |
922 | + """Helper method to make a fake credential.""" |
923 | + return Credentials( |
924 | + "app name", consumer_secret='consumer_secret:42', |
925 | + access_token=AccessToken(consumer_key, 'access_secret:168')) |
926 | + |
927 | + |
928 | +class TestUnencryptedFileCredentialStore(CredentialStoreTestCase): |
929 | + """Tests for the UnencryptedFileCredentialStore class.""" |
930 | + |
931 | + def setUp(self): |
932 | + ignore, self.filename = tempfile.mkstemp() |
933 | + self.store = UnencryptedFileCredentialStore(self.filename) |
934 | + |
935 | + def tearDown(self): |
936 | + if os.path.exists(self.filename): |
937 | + os.remove(self.filename) |
938 | + |
939 | + def test_save_and_load(self): |
940 | + # Make sure you can save and load credentials to a file. |
941 | + credential = self.make_credential("consumer key") |
942 | + self.store.save(credential, "unique key") |
943 | + credential2 = self.store.load("unique key") |
944 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
945 | + |
946 | + def test_unique_id_doesnt_matter(self): |
947 | + # If a file contains a credential, that credential will be |
948 | + # accessed no matter what unique ID you specify. |
949 | + credential = self.make_credential("consumer key") |
950 | + self.store.save(credential, "some key") |
951 | + credential2 = self.store.load("some other key") |
952 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
953 | + |
954 | + def test_file_only_contains_one_credential(self): |
955 | + # A credential file may contain only one credential. If you |
956 | + # write two credentials with different unique IDs to the same |
957 | + # file, the first credential will be overwritten with the |
958 | + # second. |
959 | + credential1 = self.make_credential("consumer key") |
960 | + credential2 = self.make_credential("consumer key2") |
961 | + self.store.save(credential1, "unique key 1") |
962 | + self.store.save(credential1, "unique key 2") |
963 | + loaded = self.store.load("unique key 1") |
964 | + self.assertEquals(loaded.consumer.key, credential2.consumer.key) |
965 | + |
966 | + |
967 | +class TestKeyringCredentialStore(CredentialStoreTestCase): |
968 | + """Tests for the KeyringCredentialStore class.""" |
969 | + |
970 | + def setUp(self): |
971 | + self.keyring = InMemoryKeyring() |
972 | + self.store = KeyringCredentialStore() |
973 | + |
974 | + def test_save_and_load(self): |
975 | + # Make sure you can save and load credentials to a keyring. |
976 | + with fake_keyring(self.keyring): |
977 | + credential = self.make_credential("consumer key") |
978 | + self.store.save(credential, "unique key") |
979 | + credential2 = self.store.load("unique key") |
980 | + self.assertEquals( |
981 | + credential.consumer.key, credential2.consumer.key) |
982 | + |
983 | + def test_lookup_by_unique_key(self): |
984 | + # Credentials in the keyring are looked up by the unique ID |
985 | + # under which they were stored. |
986 | + with fake_keyring(self.keyring): |
987 | + credential1 = self.make_credential("consumer key1") |
988 | + self.store.save(credential1, "key 1") |
989 | + |
990 | + credential2 = self.make_credential("consumer key2") |
991 | + self.store.save(credential2, "key 2") |
992 | + |
993 | + loaded1 = self.store.load("key 1") |
994 | + self.assertEquals( |
995 | + credential1.consumer.key, loaded1.consumer.key) |
996 | + |
997 | + loaded2 = self.store.load("key 2") |
998 | + self.assertEquals( |
999 | + credential2.consumer.key, loaded2.consumer.key) |
1000 | + |
1001 | + def test_reused_unique_id_overwrites_old_credential(self): |
1002 | + # Writing a credential to the keyring with a given unique ID |
1003 | + # will overwrite any credential stored under that ID. |
1004 | + |
1005 | + with fake_keyring(self.keyring): |
1006 | + credential1 = self.make_credential("consumer key1") |
1007 | + self.store.save(credential1, "the only key") |
1008 | + |
1009 | + credential2 = self.make_credential("consumer key2") |
1010 | + self.store.save(credential2, "the only key") |
1011 | + |
1012 | + loaded = self.store.load("the only key") |
1013 | + self.assertEquals( |
1014 | + credential2.consumer.key, loaded.consumer.key) |
1015 | + |
1016 | + def test_bad_unique_id_returns_none(self): |
1017 | + # Trying to load a credential without providing a good unique |
1018 | + # ID will get you None. |
1019 | + with fake_keyring(self.keyring): |
1020 | + self.assertEquals(None, self.store.load("no such key")) |
1021 | + |
1022 | |
1023 | === modified file 'src/launchpadlib/tests/test_http.py' |
1024 | --- src/launchpadlib/tests/test_http.py 2010-12-20 17:44:36 +0000 |
1025 | +++ src/launchpadlib/tests/test_http.py 2011-01-04 19:04:27 +0000 |
1026 | @@ -71,14 +71,15 @@ |
1027 | :param responses: A list of HttpResponse objects to use |
1028 | in response to requests. |
1029 | """ |
1030 | + super(SimulatedResponsesHttp, self).__init__(*args) |
1031 | self.sent_responses = [] |
1032 | self.unsent_responses = responses |
1033 | - super(SimulatedResponsesHttp, self).__init__(*args) |
1034 | + self.cache = None |
1035 | |
1036 | def _request(self, *args): |
1037 | response = self.unsent_responses.popleft() |
1038 | self.sent_responses.append(response) |
1039 | - return self.retry_on_bad_token(response, response.content) |
1040 | + return self.retry_on_bad_token(response, response.content, *args) |
1041 | |
1042 | |
1043 | class SimulatedResponsesLaunchpad(Launchpad): |
1044 | |
1045 | === modified file 'src/launchpadlib/tests/test_launchpad.py' |
1046 | --- src/launchpadlib/tests/test_launchpad.py 2010-12-20 17:44:36 +0000 |
1047 | +++ src/launchpadlib/tests/test_launchpad.py 2011-01-04 19:04:27 +0000 |
1048 | @@ -23,9 +23,8 @@ |
1049 | import socket |
1050 | import stat |
1051 | import tempfile |
1052 | -import textwrap |
1053 | import unittest |
1054 | -from contextlib import contextmanager |
1055 | +import warnings |
1056 | |
1057 | from lazr.restfulclient.resource import ServiceRoot |
1058 | |
1059 | @@ -38,9 +37,17 @@ |
1060 | import launchpadlib.launchpad |
1061 | from launchpadlib.launchpad import Launchpad |
1062 | from launchpadlib.testing.helpers import ( |
1063 | + BadSaveKeyring, |
1064 | + fake_keyring, |
1065 | + FauxSocketModule, |
1066 | + InMemoryKeyring, |
1067 | NoNetworkAuthorizationEngine, |
1068 | NoNetworkLaunchpad, |
1069 | ) |
1070 | +from launchpadlib.credentials import ( |
1071 | + KeyringCredentialStore, |
1072 | + UnencryptedFileCredentialStore, |
1073 | + ) |
1074 | |
1075 | # A dummy service root for use in tests |
1076 | SERVICE_ROOT = "http://api.example.com/" |
1077 | @@ -112,7 +119,7 @@ |
1078 | version = "version-foo" |
1079 | root = uris.service_roots['staging'] + version |
1080 | try: |
1081 | - Launchpad(None, None, service_root=root, version=version) |
1082 | + Launchpad(None, None, None, service_root=root, version=version) |
1083 | except ValueError, e: |
1084 | self.assertTrue(str(e).startswith( |
1085 | "It looks like you're using a service root that incorporates " |
1086 | @@ -124,30 +131,17 @@ |
1087 | # Make sure the problematic URL is caught even if it has a |
1088 | # slash on the end. |
1089 | root += '/' |
1090 | - self.assertRaises(ValueError, Launchpad, None, None, |
1091 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
1092 | service_root=root, version=version) |
1093 | |
1094 | # Test that the default version has the same problem |
1095 | # when no explicit version is specified |
1096 | default_version = NoNetworkLaunchpad.DEFAULT_VERSION |
1097 | root = uris.service_roots['staging'] + default_version + '/' |
1098 | - self.assertRaises(ValueError, Launchpad, None, None, |
1099 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
1100 | service_root=root) |
1101 | |
1102 | |
1103 | -class InMemoryKeyring: |
1104 | - """A keyring that saves passwords only in memory.""" |
1105 | - |
1106 | - def __init__(self): |
1107 | - self.data = {} |
1108 | - |
1109 | - def set_password(self, service, username, password): |
1110 | - self.data[service, username] = password |
1111 | - |
1112 | - def get_password(self, service, username): |
1113 | - return self.data.get((service, username)) |
1114 | - |
1115 | - |
1116 | class TestRequestTokenAuthorizationEngine(unittest.TestCase): |
1117 | """Tests for the RequestTokenAuthorizationEngine class.""" |
1118 | |
1119 | @@ -174,8 +168,33 @@ |
1120 | SERVICE_ROOT, application_name='name', consumer_name='name') |
1121 | |
1122 | |
1123 | -class TestLaunchpadLoginWith(unittest.TestCase): |
1124 | - """Tests for Launchpad.login_with().""" |
1125 | +class TestLaunchpadLoginWithCredentialsFile(unittest.TestCase): |
1126 | + """Tests for Launchpad.login_with() with a credentials file.""" |
1127 | + |
1128 | + def test_filename(self): |
1129 | + ignore, filename = tempfile.mkstemp() |
1130 | + launchpad = NoNetworkLaunchpad.login_with( |
1131 | + application_name='not important', credentials_file=filename) |
1132 | + |
1133 | + # The credentials are stored unencrypted in the file you |
1134 | + # specify. |
1135 | + credentials = Credentials.load_from_path(filename) |
1136 | + self.assertEquals(credentials.consumer.key, |
1137 | + launchpad.credentials.consumer.key) |
1138 | + os.remove(filename) |
1139 | + |
1140 | + def test_cannot_specify_both_filename_and_store(self): |
1141 | + ignore, filename = tempfile.mkstemp() |
1142 | + store = KeyringCredentialStore() |
1143 | + self.assertRaises( |
1144 | + ValueError, NoNetworkLaunchpad.login_with, |
1145 | + application_name='not important', credentials_file=filename, |
1146 | + credential_store=store) |
1147 | + os.remove(filename) |
1148 | + |
1149 | + |
1150 | +class KeyringTest(unittest.TestCase): |
1151 | + """Base class for tests that use the keyring.""" |
1152 | |
1153 | def setUp(self): |
1154 | # For these tests we want to disable retrieving or storing credentials |
1155 | @@ -183,30 +202,28 @@ |
1156 | # instaed. |
1157 | self._saved_keyring = launchpadlib.credentials.keyring |
1158 | launchpadlib.credentials.keyring = InMemoryKeyring() |
1159 | - self.temp_dir = tempfile.mkdtemp() |
1160 | |
1161 | def tearDown(self): |
1162 | # Restore the gnomekeyring module that we disabled in setUp. |
1163 | launchpadlib.credentials.keyring = self._saved_keyring |
1164 | + |
1165 | + |
1166 | +class TestLaunchpadLoginWith(KeyringTest): |
1167 | + """Tests for Launchpad.login_with().""" |
1168 | + |
1169 | + def setUp(self): |
1170 | + super(TestLaunchpadLoginWith, self).setUp() |
1171 | + self.temp_dir = tempfile.mkdtemp() |
1172 | + |
1173 | + def tearDown(self): |
1174 | + super(TestLaunchpadLoginWith, self).tearDown() |
1175 | shutil.rmtree(self.temp_dir) |
1176 | |
1177 | - def test_max_failed_attempts_accepted(self): |
1178 | - # You can pass in a value for the 'max_failed_attempts' |
1179 | - # argument, even though that argument doesn't do anything. |
1180 | - launchpad = NoNetworkLaunchpad.login_with( |
1181 | - 'not important', max_failed_attempts=5) |
1182 | - |
1183 | - def test_credentials_file_accepted(self): |
1184 | - # You can pass in a value for the 'credentials_file' |
1185 | - # argument, even though that argument doesn't do anything. |
1186 | - launchpad = NoNetworkLaunchpad.login_with( |
1187 | - 'not important', credentials_file='foo') |
1188 | - |
1189 | def test_dirs_created(self): |
1190 | # The path we pass into login_with() is the directory where |
1191 | - # cache and credentials for all service roots are stored. |
1192 | + # cache for all service roots are stored. |
1193 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
1194 | - launchpad = NoNetworkLaunchpad.login_with( |
1195 | + NoNetworkLaunchpad.login_with( |
1196 | 'not important', service_root=SERVICE_ROOT, |
1197 | launchpadlib_dir=launchpadlib_dir) |
1198 | # The 'launchpadlib' dir got created. |
1199 | @@ -233,7 +250,7 @@ |
1200 | statinfo = os.stat(launchpadlib_dir) |
1201 | mode = stat.S_IMODE(statinfo.st_mode) |
1202 | self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) |
1203 | - launchpad = NoNetworkLaunchpad.login_with( |
1204 | + NoNetworkLaunchpad.login_with( |
1205 | 'not important', service_root=SERVICE_ROOT, |
1206 | launchpadlib_dir=launchpadlib_dir) |
1207 | # Verify the mode has been changed to 0700 |
1208 | @@ -243,7 +260,7 @@ |
1209 | |
1210 | def test_dirs_created_are_secure(self): |
1211 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
1212 | - launchpad = NoNetworkLaunchpad.login_with( |
1213 | + NoNetworkLaunchpad.login_with( |
1214 | 'not important', service_root=SERVICE_ROOT, |
1215 | launchpadlib_dir=launchpadlib_dir) |
1216 | self.assertTrue(os.path.isdir(launchpadlib_dir)) |
1217 | @@ -300,8 +317,7 @@ |
1218 | # token. |
1219 | engine = NoNetworkAuthorizationEngine( |
1220 | SERVICE_ROOT, 'application name') |
1221 | - launchpad = NoNetworkLaunchpad.login_with( |
1222 | - authorization_engine=engine) |
1223 | + NoNetworkLaunchpad.login_with(authorization_engine=engine) |
1224 | self.assertEquals(engine.request_tokens_obtained, 1) |
1225 | self.assertEquals(engine.access_tokens_obtained, 1) |
1226 | |
1227 | @@ -311,9 +327,13 @@ |
1228 | self.assertRaises(ValueError, NoNetworkLaunchpad.login_with) |
1229 | |
1230 | def test_application_name_identifies_app(self): |
1231 | + # If you pass in application_name, that's good enough to identify |
1232 | + # your application. |
1233 | NoNetworkLaunchpad.login_with(application_name="name") |
1234 | |
1235 | def test_consumer_name_identifies_app(self): |
1236 | + # If you pass in consumer_name, that's good enough to identify |
1237 | + # your application. |
1238 | NoNetworkLaunchpad.login_with(consumer_name="name") |
1239 | |
1240 | def test_inconsistent_application_name_rejected(self): |
1241 | @@ -344,6 +364,19 @@ |
1242 | allow_access_levels=['BAR'], |
1243 | authorization_engine=engine) |
1244 | |
1245 | + def test_inconsistent_credential_save_failed(self): |
1246 | + # Catch an attempt to specify inconsistent callbacks for |
1247 | + # credential save failure. |
1248 | + def callback1(): |
1249 | + pass |
1250 | + store = KeyringCredentialStore(credential_save_failed=callback1) |
1251 | + |
1252 | + def callback2(): |
1253 | + pass |
1254 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
1255 | + "app name", credential_store=store, |
1256 | + credential_save_failed=callback2) |
1257 | + |
1258 | def test_non_desktop_integration(self): |
1259 | # When doing a non-desktop integration, you must specify a |
1260 | # consumer_name. You can pass a list of allowable access |
1261 | @@ -472,30 +505,32 @@ |
1262 | self.assertRaises( |
1263 | ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo') |
1264 | |
1265 | - |
1266 | -class FauxSocketModule: |
1267 | - """A socket module replacement that provides a fake hostname.""" |
1268 | - |
1269 | - def gethostname(self): |
1270 | - return 'HOSTNAME' |
1271 | - |
1272 | - |
1273 | -class BadSaveKeyring: |
1274 | - """A keyring that generates errors when saving passwords.""" |
1275 | - |
1276 | - def get_password(self, service, username): |
1277 | - return None |
1278 | - |
1279 | - def set_password(self, service, username, password): |
1280 | - raise RuntimeError |
1281 | - |
1282 | - |
1283 | -@contextmanager |
1284 | -def fake_keyring(fake): |
1285 | - original_keyring = launchpadlib.credentials.keyring |
1286 | - launchpadlib.credentials.keyring = fake |
1287 | - yield |
1288 | - launchpadlib.credentials.keyring = original_keyring |
1289 | + def test_max_failed_attempts_accepted(self): |
1290 | + # You can pass in a value for the 'max_failed_attempts' |
1291 | + # argument, even though that argument doesn't do anything. |
1292 | + NoNetworkLaunchpad.login_with( |
1293 | + 'not important', max_failed_attempts=5) |
1294 | + |
1295 | + |
1296 | +class TestDeprecatedLoginMethods(KeyringTest): |
1297 | + """Make sure the deprecated login methods still work.""" |
1298 | + |
1299 | + def test_login_is_deprecated(self): |
1300 | + # login() works but triggers a deprecation warning. |
1301 | + with warnings.catch_warnings(record=True) as caught: |
1302 | + warnings.simplefilter("always") |
1303 | + launchpad = NoNetworkLaunchpad.login( |
1304 | + 'consumer', 'token', 'secret') |
1305 | + self.assertEquals(len(caught), 1) |
1306 | + self.assertEquals(caught[0].category, DeprecationWarning) |
1307 | + |
1308 | + def test_get_token_and_login_is_deprecated(self): |
1309 | + # get_token_and_login() works but triggers a deprecation warning. |
1310 | + with warnings.catch_warnings(record=True) as caught: |
1311 | + warnings.simplefilter("always") |
1312 | + launchpad = NoNetworkLaunchpad.get_token_and_login('consumer') |
1313 | + self.assertEquals(len(caught), 1) |
1314 | + self.assertEquals(caught[0].category, DeprecationWarning) |
1315 | |
1316 | |
1317 | class TestCredenitialSaveFailedCallback(unittest.TestCase): |
1318 | @@ -578,7 +613,6 @@ |
1319 | keyring = InMemoryKeyring() |
1320 | # Be paranoid about the keyring starting out empty. |
1321 | assert not keyring.data, 'oops, a fresh keyring has data in it' |
1322 | - service_root = 'http://api.example.com/' |
1323 | with fake_keyring(keyring): |
1324 | # Create stored credentials for the same application but against |
1325 | # two different sites (service roots). |
Reading the cover, login_insecurely and login_securely seem like value
judgements rather than api changes.
Isn't the real difference can-prompt- for-credentials cached- credentials- only
login-and-
login-using-
?
After all, disk files in an encrypted per-account system are
approximately as secure as those in the gnome keyring (both need only
compromise the account to access the credentials).