Merge lp:~leonardr/launchpadlib/choose-your-client into lp:launchpadlib
- choose-your-client
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~leonardr/launchpadlib/choose-your-client |
Merge into: | lp:launchpadlib |
Diff against target: |
391 lines 8 files modified
setup.py (+1/-1) src/launchpadlib/credentials.py (+44/-3) src/launchpadlib/docs/command-line.txt (+1/-1) src/launchpadlib/docs/hosted-files.txt (+1/-1) src/launchpadlib/docs/introduction.txt (+78/-13) src/launchpadlib/docs/people.txt (+1/-0) src/launchpadlib/launchpad.py (+19/-12) src/launchpadlib/testing/helpers.py (+19/-3) |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/choose-your-client |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Graham Binns (community) | Approve | ||
Review via email: mp+14361@code.launchpad.net |
Commit message
Description of the change
Leonard Richardson (leonardr) wrote : | # |
Leonard Richardson (leonardr) wrote : | # |
I forgot to explicitly mention the good thing about this branch: the browser-based authorization workflow is now just the default. You can pass any RTAE class into login_with(), and launchpadlib will use that workflow to get the end-user's Launchpad username and password and authorize the request token.
> Previous branches added the RequestTokenAut
> getting the end-user to enter their Launchpad username and password to
> authorize a request token. I also added the
> TrustedTokenAut
> guides the user through the process in the console. But if you wanted to use
> convenience methods like login_with(), you still had to use the default
> authorization workflow which opens the user's web browser and tells them to
> hit enter when they're done.
>
> Because the convenience methods have been refactored to no longer require
> direct user interaction, it's now possible to test login_with() and
> get_token_
> use a dummy subclass of AuthorizeReques
> launchpadlib that requires manual testing is the
> AuthorizeReques
> but those are very small and just pass their arguments into a tested App
> class, and I didn't change those in this branch anyway.)
>
> I also upgraded the dependency to the newest version of lazr.restfulclient.
> Previously, login_with() didn't work with an 0.9.9 lazr.restfulclient, but it
> didn't cause any tests to fail because login_with() wasn't tested. Now it is
> tested, so you gotta have 0.9.10. The changes to the tests that add "..." to
> exception tracebacks are necessary because 0.9.10 tracebacks contain a lot
> more information.
>
> I'm not really happy with the current design. When testing this code I found
> it clunky to pass in a RequestTokenAut
> instantiated within get_token_
> instantiate the object first and then pass it in. The problem is that
> get_token_
> constructor requires the request token. I'm considering changing RTAE so that
> it can get the request token itself. However, that goes against my general
> rule of keeping RTAE as simple as possible so that it's easy to audit.
Graham Binns (gmb) : | # |
Preview Diff
1 | === modified file 'setup.py' |
2 | --- setup.py 2009-10-27 12:00:13 +0000 |
3 | +++ setup.py 2009-11-03 14:20:26 +0000 |
4 | @@ -60,7 +60,7 @@ |
5 | license='LGPL v3', |
6 | install_requires=[ |
7 | 'httplib2', |
8 | - 'lazr.restfulclient>=0.9.9', |
9 | + 'lazr.restfulclient>=0.9.10', |
10 | 'lazr.uri', |
11 | 'oauth', |
12 | 'setuptools', |
13 | |
14 | === modified file 'src/launchpadlib/credentials.py' |
15 | --- src/launchpadlib/credentials.py 2009-10-30 19:19:07 +0000 |
16 | +++ src/launchpadlib/credentials.py 2009-11-03 14:20:26 +0000 |
17 | @@ -27,6 +27,7 @@ |
18 | import base64 |
19 | import cgi |
20 | import httplib2 |
21 | +import sys |
22 | import textwrap |
23 | from urllib import urlencode, quote |
24 | from urlparse import urljoin |
25 | @@ -379,7 +380,7 @@ |
26 | username = self.input_username( |
27 | cached_username, self.message(self.INPUT_USERNAME)) |
28 | if username is None: |
29 | - self.open_login_page_in_user_browser( |
30 | + self.open_page_in_user_browser( |
31 | urljoin(self.web_root, "+login")) |
32 | raise NoLaunchpadAccount( |
33 | self.message(self.YOU_NEED_A_LAUNCHPAD_ACCOUNT)) |
34 | @@ -423,8 +424,8 @@ |
35 | variables.update(extra_variables) |
36 | return raw_message % variables |
37 | |
38 | - def open_login_page_in_user_browser(self, url): |
39 | - """Open the Launchpad login page in the user's web browser.""" |
40 | + def open_page_in_user_browser(self, url): |
41 | + """Open a web page in the user's web browser.""" |
42 | webbrowser.open(url) |
43 | |
44 | # You should define these methods in your subclass. |
45 | @@ -488,20 +489,60 @@ |
46 | self.output(suggested_message) |
47 | |
48 | |
49 | +class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine): |
50 | + """The simplest and most secure request token authorizer. |
51 | + |
52 | + This authorizer simply opens up the end-user's web browser to a |
53 | + Launchpad URL and lets the end-user authorize the request token |
54 | + themselves. |
55 | + """ |
56 | + |
57 | + def __init__(self, web_root, consumer_name, request_token, |
58 | + allow_access_levels=[], max_failed_attempts=3): |
59 | + page = "+authorize-token?oauth_token=%s" % request_token |
60 | + if len(allow_access_levels) > 0: |
61 | + page += ("&allow_permission=" + |
62 | + "&allow_permission=".join(allow_access_levels)) |
63 | + self.authorization_url = urljoin(web_root, page) |
64 | + |
65 | + super(AuthorizeRequestTokenWithBrowser, self).__init__( |
66 | + web_root, consumer_name, request_token, |
67 | + allow_access_levels, max_failed_attempts) |
68 | + |
69 | + def __call__(self): |
70 | + self.open_page_in_user_browser(self.authorization_url) |
71 | + print "The authorization page:" |
72 | + print " (%s)" % self.authorization_url |
73 | + print "should be opening in your browser. After you have authorized" |
74 | + print "this program to access Launchpad on your behalf you should come" |
75 | + print ("back here and press <Enter> to finish the authentication " |
76 | + "process.") |
77 | + self.wait_for_request_token_authorization() |
78 | + |
79 | + def wait_for_request_token_authorization(self): |
80 | + """Get the end-user to hit enter.""" |
81 | + sys.stdin.readline() |
82 | + |
83 | + |
84 | class TokenAuthorizationException(Exception): |
85 | pass |
86 | |
87 | + |
88 | class RequestTokenAlreadyAuthorized(TokenAuthorizationException): |
89 | pass |
90 | |
91 | + |
92 | class ClientError(TokenAuthorizationException): |
93 | pass |
94 | |
95 | + |
96 | class ServerError(TokenAuthorizationException): |
97 | pass |
98 | |
99 | + |
100 | class NoLaunchpadAccount(TokenAuthorizationException): |
101 | pass |
102 | |
103 | + |
104 | class TooManyAuthenticationFailures(TokenAuthorizationException): |
105 | pass |
106 | |
107 | === modified file 'src/launchpadlib/docs/command-line.txt' |
108 | --- src/launchpadlib/docs/command-line.txt 2009-11-02 12:20:47 +0000 |
109 | +++ src/launchpadlib/docs/command-line.txt 2009-11-03 14:20:26 +0000 |
110 | @@ -60,7 +60,7 @@ |
111 | performing such un-doctest-like actions. |
112 | |
113 | >>> class ConsoleApp(TrustedTokenAuthorizationConsoleApp): |
114 | - ... def open_login_page_in_user_browser(self, url): |
115 | + ... def open_page_in_user_browser(self, url): |
116 | ... """Print a status message.""" |
117 | ... self.output("[If this were a real application, the " |
118 | ... "end-user's web browser would be opened " |
119 | |
120 | === modified file 'src/launchpadlib/docs/hosted-files.txt' |
121 | --- src/launchpadlib/docs/hosted-files.txt 2009-07-09 13:27:28 +0000 |
122 | +++ src/launchpadlib/docs/hosted-files.txt 2009-11-03 14:20:26 +0000 |
123 | @@ -56,7 +56,7 @@ |
124 | Traceback (most recent call last): |
125 | ... |
126 | HTTPError: HTTP Error 400: Bad Request |
127 | - |
128 | + ... |
129 | |
130 | == Caching == |
131 | |
132 | |
133 | === modified file 'src/launchpadlib/docs/introduction.txt' |
134 | --- src/launchpadlib/docs/introduction.txt 2009-10-27 15:24:36 +0000 |
135 | +++ src/launchpadlib/docs/introduction.txt 2009-11-03 14:20:26 +0000 |
136 | @@ -213,18 +213,81 @@ |
137 | >>> credentials.access_token.context |
138 | 'firefox' |
139 | |
140 | -There's also a convenience method which does the access token |
141 | -negotiation and logs into the web service. It uses the methods |
142 | -documented above and once it has the request token's authorization URL |
143 | -it opens up a web browser for the user to authoriza it and asks him to |
144 | -come back and press <Enter> once that's done. When he does it, the |
145 | -request token is exchanged for an access token and the authentication is |
146 | -complete. |
147 | - |
148 | - # Since this will open up a web browser we're not going to actually run it |
149 | - # here. |
150 | - >>> # consumer_name = 'launchpadlib' |
151 | - >>> # launchpad = Launchpad.get_token_and_login(consumer_name) |
152 | +Authorizing the request token |
153 | +----------------------------- |
154 | + |
155 | +There are also two convenience method which do the access token |
156 | +negotiation and log into the web service: get_token_and_login() and |
157 | +login_with(). These convenience methods use the methods documented |
158 | +above to get a request token, and once it has the request token's |
159 | +authorization information, it makes the end-user authorize the request |
160 | +token by entering their Launchpad username and password. |
161 | + |
162 | +There are several ways of having the end-user authorize a request |
163 | +token, but the most secure is to open up the user's own web browser |
164 | +(other ways are described in trusted-client.txt). Because we don't |
165 | +want to actually open a web browser during this test, we'll create a |
166 | +fake authorizer that uses the SimulatedLaunchpadBrowser to authorize |
167 | +the request token. |
168 | + |
169 | + >>> from launchpadlib.testing.helpers import ( |
170 | + ... DummyAuthorizeRequestTokenWithBrowser) |
171 | + |
172 | + >>> class AuthorizeAsSalgado(DummyAuthorizeRequestTokenWithBrowser): |
173 | + ... def wait_for_request_token_authorization(self): |
174 | + ... """Simulate the authorizing user with their web browser.""" |
175 | + ... username = 'salgado@ubuntu.com' |
176 | + ... password = 'zeca' |
177 | + ... browser = SimulatedLaunchpadBrowser(self.web_root) |
178 | + ... browser.grant_access(username, password, self.request_token, |
179 | + ... 'READ_PUBLIC') |
180 | + |
181 | + >>> consumer_name = 'launchpadlib' |
182 | + >>> launchpad = Launchpad.get_token_and_login( |
183 | + ... consumer_name, service_root="http://api.launchpad.dev:8085/beta/", |
184 | + ... authorizer_class=AuthorizeAsSalgado) |
185 | + [If this were a real application, the end-user's web browser would |
186 | + be opened to http://launchpad.dev:8085/+authorize-token?oauth_token=...] |
187 | + The authorization page: |
188 | + (http://launchpad.dev:8085/+authorize-token?oauth_token=...) |
189 | + should be opening in your browser. After you have authorized |
190 | + this program to access Launchpad on your behalf you should come |
191 | + back here and press <Enter> to finish the authentication process. |
192 | + |
193 | +The login_with method will cache an access token once it gets one, so |
194 | +that the end-user doesn't have to authorize a request token every time |
195 | +they run the program. |
196 | + |
197 | + >>> import tempfile |
198 | + >>> cache_dir = tempfile.mkdtemp() |
199 | + >>> launchpad = Launchpad.login_with( |
200 | + ... consumer_name, service_root="http://api.launchpad.dev:8085/beta/", |
201 | + ... launchpadlib_dir=cache_dir, |
202 | + ... authorizer_class=AuthorizeAsSalgado) |
203 | + [If this were a real application...] |
204 | + The authorization page: |
205 | + ... |
206 | + >>> print launchpad.me.name |
207 | + salgado |
208 | + |
209 | +Now that the access token is authorized, we can call login_with() |
210 | +again and pass in a null authorizer. If there was no access token, |
211 | +this would fail, because there would be no way to authorize the |
212 | +request token. But since there's an access token cached in the |
213 | +cache directory, login_with() will succeed without even trying to |
214 | +authorize a request token. |
215 | + |
216 | + >>> launchpad = Launchpad.login_with( |
217 | + ... consumer_name, service_root="http://api.launchpad.dev:8085/beta/", |
218 | + ... launchpadlib_dir=cache_dir, |
219 | + ... authorizer_class=None) |
220 | + >>> print launchpad.me.name |
221 | + salgado |
222 | + |
223 | +A bit of clean-up: removing the cache directory. |
224 | + |
225 | + >>> import shutil |
226 | + >>> shutil.rmtree(cache_dir) |
227 | |
228 | The dictionary request token |
229 | ============================ |
230 | @@ -347,6 +410,7 @@ |
231 | Traceback (most recent call last): |
232 | ... |
233 | HTTPError: HTTP Error 401: Unauthorized |
234 | + ... |
235 | |
236 | The application is not allowed to access Launchpad with a bad access token. |
237 | |
238 | @@ -358,6 +422,7 @@ |
239 | Traceback (most recent call last): |
240 | ... |
241 | HTTPError: HTTP Error 401: Unauthorized |
242 | + ... |
243 | |
244 | The application is not allowed to access Launchpad with a bad access secret. |
245 | |
246 | @@ -369,7 +434,7 @@ |
247 | Traceback (most recent call last): |
248 | ... |
249 | HTTPError: HTTP Error 401: Unauthorized |
250 | - |
251 | + ... |
252 | |
253 | Clean up |
254 | ======== |
255 | |
256 | === modified file 'src/launchpadlib/docs/people.txt' |
257 | --- src/launchpadlib/docs/people.txt 2009-04-16 19:30:01 +0000 |
258 | +++ src/launchpadlib/docs/people.txt 2009-11-03 14:20:26 +0000 |
259 | @@ -160,6 +160,7 @@ |
260 | Traceback (most recent call last): |
261 | ... |
262 | HTTPError: HTTP Error 400: Bad Request |
263 | + ... |
264 | |
265 | Actually, the exception contains other useful information. |
266 | |
267 | |
268 | === modified file 'src/launchpadlib/launchpad.py' |
269 | --- src/launchpadlib/launchpad.py 2009-10-27 12:00:13 +0000 |
270 | +++ src/launchpadlib/launchpad.py 2009-11-03 14:20:26 +0000 |
271 | @@ -31,7 +31,8 @@ |
272 | from lazr.restfulclient._browser import RestfulHttp |
273 | from lazr.restfulclient.resource import ( |
274 | CollectionWithKeyBasedLookup, HostedFile, ServiceRoot) |
275 | -from launchpadlib.credentials import AccessToken, Credentials |
276 | +from launchpadlib.credentials import ( |
277 | + AccessToken, Credentials, AuthorizeRequestTokenWithBrowser) |
278 | from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT |
279 | from launchpadlib import uris |
280 | |
281 | @@ -127,7 +128,9 @@ |
282 | @classmethod |
283 | def get_token_and_login(cls, consumer_name, |
284 | service_root=uris.STAGING_SERVICE_ROOT, |
285 | - cache=None, timeout=None, proxy_info=None): |
286 | + cache=None, timeout=None, proxy_info=None, |
287 | + authorizer_class=AuthorizeRequestTokenWithBrowser, |
288 | + allow_access_levels=[], max_failed_attempts=3): |
289 | """Get credentials from Launchpad and log into the service root. |
290 | |
291 | This is a convenience method which will open up the user's preferred |
292 | @@ -156,21 +159,22 @@ |
293 | web_root_uri.path = "" |
294 | web_root_uri.host = web_root_uri.host.replace("api.", "", 1) |
295 | web_root = str(web_root_uri.ensureSlash()) |
296 | - authorization_url = credentials.get_request_token(web_root=web_root) |
297 | - webbrowser.open(authorization_url) |
298 | - print "The authorization page:" |
299 | - print " (%s)" % authorization_url |
300 | - print "should be opening in your browser. After you have authorized" |
301 | - print "this program to access Launchpad on your behalf you should come" |
302 | - print "back here and press <Enter> to finish the authentication process." |
303 | - sys.stdin.readline() |
304 | + authorization_json = credentials.get_request_token( |
305 | + web_root=web_root, token_format=Credentials.DICT_TOKEN_FORMAT) |
306 | + authorizer = authorizer_class( |
307 | + web_root, authorization_json['oauth_token_consumer'], |
308 | + authorization_json['oauth_token'], allow_access_levels, |
309 | + max_failed_attempts) |
310 | + authorizer() |
311 | credentials.exchange_request_token_for_access_token(web_root) |
312 | return cls(credentials, service_root, cache, timeout, proxy_info) |
313 | |
314 | @classmethod |
315 | def login_with(cls, consumer_name, |
316 | service_root=uris.STAGING_SERVICE_ROOT, |
317 | - launchpadlib_dir=None, timeout=None, proxy_info=None): |
318 | + launchpadlib_dir=None, timeout=None, proxy_info=None, |
319 | + authorizer_class=AuthorizeRequestTokenWithBrowser, |
320 | + allow_access_levels=[], max_failed_attempts=3): |
321 | """Log in to Launchpad with possibly cached credentials. |
322 | |
323 | This is a convenience method for either setting up new login |
324 | @@ -229,7 +233,10 @@ |
325 | else: |
326 | launchpad = cls.get_token_and_login( |
327 | consumer_name, service_root=service_root, cache=cache_path, |
328 | - timeout=timeout, proxy_info=proxy_info) |
329 | + timeout=timeout, proxy_info=proxy_info, |
330 | + authorizer_class=authorizer_class, |
331 | + allow_access_levels=allow_access_levels, |
332 | + max_failed_attempts=max_failed_attempts) |
333 | launchpad.credentials.save_to_path( |
334 | os.path.join(credentials_path, consumer_name)) |
335 | os.chmod( |
336 | |
337 | === modified file 'src/launchpadlib/testing/helpers.py' |
338 | --- src/launchpadlib/testing/helpers.py 2009-10-30 19:19:07 +0000 |
339 | +++ src/launchpadlib/testing/helpers.py 2009-11-03 14:20:26 +0000 |
340 | @@ -27,10 +27,12 @@ |
341 | 'salgado_with_full_permissions', |
342 | ] |
343 | |
344 | +import simplejson |
345 | |
346 | from launchpadlib.launchpad import Launchpad |
347 | from launchpadlib.credentials import ( |
348 | - RequestTokenAuthorizationEngine, Credentials) |
349 | + AuthorizeRequestTokenWithBrowser, Credentials, |
350 | + RequestTokenAuthorizationEngine, SimulatedLaunchpadBrowser) |
351 | |
352 | |
353 | class TestableLaunchpad(Launchpad): |
354 | @@ -39,7 +41,7 @@ |
355 | # Use our test service root. |
356 | TESTING_SERVICE_ROOT = 'http://api.launchpad.dev:8085/beta/' |
357 | |
358 | - def __init__(self, credentials, service_root_ignored=None, |
359 | + def __init__(self, credentials, service_root=None, |
360 | cache=None, timeout=None, proxy_info=None): |
361 | super(TestableLaunchpad, self).__init__( |
362 | credentials, TestableLaunchpad.TESTING_SERVICE_ROOT, |
363 | @@ -102,7 +104,7 @@ |
364 | return self.credentials.access_token |
365 | return None |
366 | |
367 | - def open_login_page_in_user_browser(self, url): |
368 | + def open_page_in_user_browser(self, url): |
369 | """Print a status message.""" |
370 | print ("[If this were a real application, the end-user's web " |
371 | "browser would be opened to %s]" % url) |
372 | @@ -131,6 +133,20 @@ |
373 | print message |
374 | |
375 | |
376 | +class DummyAuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithBrowser): |
377 | + |
378 | + def __init__(self, web_root, consumer_name, request_token, username, |
379 | + password, allow_access_levels=[], max_failed_attempts=3): |
380 | + super(DummyAuthorizeRequestTokenWithBrowser, self).__init__( |
381 | + web_root, consumer_name, request_token, allow_access_levels, |
382 | + max_failed_attempts) |
383 | + |
384 | + def open_page_in_user_browser(self, url): |
385 | + """Print a status message.""" |
386 | + print ("[If this were a real application, the end-user's web " |
387 | + "browser would be opened to %s]" % url) |
388 | + |
389 | + |
390 | class UserInput(object): |
391 | """A class to store fake user input in a readable way. |
392 |
Previous branches added the RequestTokenAut horizationEngin e, a generic way of getting the end-user to enter their Launchpad username and password to authorize a request token. I also added the TrustedTokenAut horizationConso leApp, a RequestTokenAut horizationEngin e that guides the user through the process in the console. But if you wanted to use convenience methods like login_with(), you still had to use the default authorization workflow which opens the user's web browser and tells them to hit enter when they're done.
Because the convenience methods have been refactored to no longer require direct user interaction, it's now possible to test login_with() and get_token_ and_login( ) in a doctest. I've added tests for these methods that use a dummy subclass of AuthorizeReques tTokenWithBrows er. Now the only part of launchpadlib that requires manual testing is the AuthorizeReques tTokenWithBrows er class itself. (And the command-line scripts, but those are very small and just pass their arguments into a tested App class, and I didn't change those in this branch anyway.)
I also upgraded the dependency to the newest version of lazr.restfulclient. Previously, login_with() didn't work with an 0.9.9 lazr.restfulclient, but it didn't cause any tests to fail because login_with() wasn't tested. Now it is tested, so you gotta have 0.9.10. The changes to the tests that add "..." to exception tracebacks are necessary because 0.9.10 tracebacks contain a lot more information.
I'm not really happy with the current design. When testing this code I found it clunky to pass in a RequestTokenAut horizationEngin e class that got instantiated within get_token_ and_login( ). It would be a lot easier if I could instantiate the object first and then pass it in. The problem is that get_token_ and_login( ) is the code that gets the request token, and the RTAE constructor requires the request token. I'm considering changing RTAE so that it can get the request token itself. However, that goes against my general rule of keeping RTAE as simple as possible so that it's easy to audit.