Merge lp:~leonardr/launchpadlib/trusted-workflow into lp:launchpadlib
- trusted-workflow
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~leonardr/launchpadlib/trusted-workflow |
Merge into: | lp:launchpadlib |
Diff against target: |
569 lines 5 files modified
src/launchpadlib/apps.py (+45/-0) src/launchpadlib/credentials.py (+219/-0) src/launchpadlib/docs/browser.txt (+5/-1) src/launchpadlib/docs/trusted-client.txt (+160/-0) src/launchpadlib/testing/helpers.py (+89/-0) |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/trusted-workflow |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Edwin Grubbs (community) | code | Approve | |
Review via email: mp+14107@code.launchpad.net |
Commit message
Description of the change
Leonard Richardson (leonardr) wrote : | # |
Edwin Grubbs (edwin-grubbs) wrote : | # |
Hi Leonard,
This branch looks good. Just one comment below.
merge-approved
-Edwin
>=== modified file 'src/launchpadl
>--- src/launchpadli
>+++ src/launchpadli
>+
>+ def get_http_
Missing docstring.
>+ username = self.input_
>+ cached_username, self.message(
>+ if username is None:
>+ self.open_
>+ urljoin(
>+ raise NoLaunchpadAccount(
>+ self.message(
>+ password = self.input_
>+ response, content = self.browser.
>+ username, password, self.request_token, self.allow_
>+ if response.status == 500:
>+ raise ServerError(
>+ elif response.status == 401:
>+ failed_attempts += 1
>+ if failed_attempts == self.max_
>+ raise TooManyAuthenti
>+ self.message(
>+ else:
>+ self.authentica
>+ self.message(
>+ return self.get_
>+ token_info = simplejson.
>+ return username, password, token_info
Preview Diff
1 | === added file 'src/launchpadlib/apps.py' | |||
2 | --- src/launchpadlib/apps.py 1970-01-01 00:00:00 +0000 | |||
3 | +++ src/launchpadlib/apps.py 2009-10-28 20:20:23 +0000 | |||
4 | @@ -0,0 +1,45 @@ | |||
5 | 1 | # Copyright 2009 Canonical Ltd. | ||
6 | 2 | |||
7 | 3 | # This file is part of launchpadlib. | ||
8 | 4 | # | ||
9 | 5 | # launchpadlib is free software: you can redistribute it and/or modify it | ||
10 | 6 | # under the terms of the GNU Lesser General Public License as published by the | ||
11 | 7 | # Free Software Foundation, version 3 of the License. | ||
12 | 8 | # | ||
13 | 9 | # launchpadlib is distributed in the hope that it will be useful, but WITHOUT | ||
14 | 10 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | ||
15 | 11 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License | ||
16 | 12 | # for more details. | ||
17 | 13 | # | ||
18 | 14 | # You should have received a copy of the GNU Lesser General Public License | ||
19 | 15 | # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. | ||
20 | 16 | |||
21 | 17 | """Command-line applications for Launchpadlib. | ||
22 | 18 | |||
23 | 19 | This module contains the code for various applications. The applications | ||
24 | 20 | themselves are kept in bin/. | ||
25 | 21 | """ | ||
26 | 22 | |||
27 | 23 | import simplejson | ||
28 | 24 | |||
29 | 25 | from launchpadlib.credentials import Credentials | ||
30 | 26 | from launchpadlib.uris import lookup_web_root | ||
31 | 27 | |||
32 | 28 | |||
33 | 29 | class RequestTokenApp(object): | ||
34 | 30 | """An application that creates request tokens.""" | ||
35 | 31 | |||
36 | 32 | def __init__(self, web_root, consumer_name, context): | ||
37 | 33 | """Initialize.""" | ||
38 | 34 | self.web_root = lookup_web_root(web_root) | ||
39 | 35 | self.credentials = Credentials(consumer_name) | ||
40 | 36 | self.context = context | ||
41 | 37 | |||
42 | 38 | def run(self): | ||
43 | 39 | """Get a request token and return JSON information about it.""" | ||
44 | 40 | token = self.credentials.get_request_token( | ||
45 | 41 | self.context, self.web_root, | ||
46 | 42 | token_format=Credentials.DICT_TOKEN_FORMAT) | ||
47 | 43 | return simplejson.dumps(token) | ||
48 | 44 | |||
49 | 45 | |||
50 | 0 | 46 | ||
51 | === modified file 'src/launchpadlib/credentials.py' | |||
52 | --- src/launchpadlib/credentials.py 2009-10-27 15:24:36 +0000 | |||
53 | +++ src/launchpadlib/credentials.py 2009-10-28 20:20:23 +0000 | |||
54 | @@ -19,6 +19,7 @@ | |||
55 | 19 | __metaclass__ = type | 19 | __metaclass__ = type |
56 | 20 | __all__ = [ | 20 | __all__ = [ |
57 | 21 | 'AccessToken', | 21 | 'AccessToken', |
58 | 22 | 'AuthorizeRequestTokenProcess', | ||
59 | 22 | 'Consumer', | 23 | 'Consumer', |
60 | 23 | 'Credentials', | 24 | 'Credentials', |
61 | 24 | ] | 25 | ] |
62 | @@ -238,3 +239,221 @@ | |||
63 | 238 | elif not 'Almost finished' in content: | 239 | elif not 'Almost finished' in content: |
64 | 239 | response.status = 500 # Internal Server Error | 240 | response.status = 500 # Internal Server Error |
65 | 240 | return response, content | 241 | return response, content |
66 | 242 | |||
67 | 243 | |||
68 | 244 | class AuthorizeRequestTokenProcess(object): | ||
69 | 245 | |||
70 | 246 | UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED" | ||
71 | 247 | |||
72 | 248 | # Suggested messages for clients to display in common situations. | ||
73 | 249 | |||
74 | 250 | AUTHENTICATION_FAILURE = "I can't log in with the credentials you gave me. Let's try again." | ||
75 | 251 | |||
76 | 252 | CHOOSE_ACCESS_LEVEL = """Now it's time for you to decide how much power to give "%(app)s" over your Launchpad account.""" | ||
77 | 253 | |||
78 | 254 | CHOOSE_ACCESS_LEVEL_ONE = CHOOSE_ACCESS_LEVEL + """ | ||
79 | 255 | |||
80 | 256 | "%(app)s" says it needs the following level of access to your Launchpad account: "%(level)s". It can't work with any other level of access, so denying this level of access means prohibiting "%(app)s" from using your Launchpad account at all.""" | ||
81 | 257 | |||
82 | 258 | CHOSE_OTHER_THAN_UNAUTHORIZED = """Okay, I'm telling Launchpad to grant "%(app)s" access to your account.""" | ||
83 | 259 | |||
84 | 260 | CHOSE_UNAUTHORIZED = """Okay, I'm going to cancel the request that "%(app)s" made for access to your account. You can always set this up again later.""" | ||
85 | 261 | |||
86 | 262 | CLIENT_ERROR = """Sorry, but Launchpad is behaving in a way this client doesn't understand. There might be a bug in the client, a bug in the server, or this client might just be out of date.""" | ||
87 | 263 | |||
88 | 264 | CONSUMER_MISMATCH = """WARNING: The application you're using told me its name was "%(old_consumer)s", but it told Launchpad its name was "%(real_consumer)s". This is probably not a problem, but it's a little suspicious, so you might want to look into this before continuing. I'll refer to the application as "%(real_consumer)s" from this point on.""" | ||
89 | 265 | |||
90 | 266 | INPUT_USERNAME = "What email address do you use on Launchpad?" | ||
91 | 267 | |||
92 | 268 | INPUT_PASSWORD = "What's your Launchpad password?" | ||
93 | 269 | |||
94 | 270 | REQUEST_TOKEN_ALREADY_AUTHORIZED = """It looks like you already approved this request to grant "%(app)s" access to your Launchpad account. You shouldn't need to do anything more.""" | ||
95 | 271 | |||
96 | 272 | SERVER_ERROR = "There seems to be something wrong on the Launchpad server side, and I can't continue. Hopefully this is a temporary problem, but if it persists, it's probably because of a bug in Lauchpad." | ||
97 | 273 | |||
98 | 274 | STARTUP_MESSAGE = """An application identified as "%(app)s" wants to access Launchpad on your behalf. I'm the Launchpad credential client and I'm here to ask for your Launchpad username and password. | ||
99 | 275 | |||
100 | 276 | I'll use your Launchpad password to give "%(app)s" limited access to your Launchpad account. I will not show your password to "%(app)s" itself.""" | ||
101 | 277 | |||
102 | 278 | SUCCESS = """You're all done! You should now be able to use Launchpad integration features of "%(app)s." """ | ||
103 | 279 | |||
104 | 280 | SUCCESS_UNAUTHORIZED = """You're all done! "%(app)s" still doesn't have access to your Launchpad account.""" | ||
105 | 281 | |||
106 | 282 | TOO_MANY_AUTHENTICATION_FAILURES = """You've failed the password entry too many times. I'm going to exit back to "%(app)s." Try again once you've solved the problem with your Launchpad account.""" | ||
107 | 283 | |||
108 | 284 | YOU_NEED_A_LAUNCHPAD_ACCOUNT = """OK, you'll need to get yourself a Launchpad account before you can integrate Launchpad into "%(app)s." | ||
109 | 285 | |||
110 | 286 | I'm opening the Launchpad registration page in your web browser so you can create an account. Once you've created an account, you can try this again.""" | ||
111 | 287 | |||
112 | 288 | def __init__(self, web_root, consumer_name, request_token, | ||
113 | 289 | allow_access_levels=[], max_failed_attempts=3): | ||
114 | 290 | self.web_root = web_root | ||
115 | 291 | self.consumer_name = consumer_name | ||
116 | 292 | self.request_token = request_token | ||
117 | 293 | self.browser = SimulatedLaunchpadBrowser(self.web_root) | ||
118 | 294 | self.max_failed_attempts = max_failed_attempts | ||
119 | 295 | self.allow_access_levels = allow_access_levels | ||
120 | 296 | |||
121 | 297 | def __call__(self): | ||
122 | 298 | |||
123 | 299 | self.startup(self.message(self.STARTUP_MESSAGE)) | ||
124 | 300 | |||
125 | 301 | # Have the end-user enter their Launchpad username and password. | ||
126 | 302 | # Make sure the credentials are valid, and get information | ||
127 | 303 | # about the request token as a side effect. | ||
128 | 304 | username, password, token_info = self.get_http_credentials() | ||
129 | 305 | |||
130 | 306 | # Update this object with fresh information about the request token. | ||
131 | 307 | self.token_info = token_info | ||
132 | 308 | self.allow_access_levels = token_info['access_levels'] | ||
133 | 309 | self._check_consumer() | ||
134 | 310 | |||
135 | 311 | # Have the end-user choose an access level from the fresh list. | ||
136 | 312 | if len(self.allow_access_levels) == 2: | ||
137 | 313 | # There's only one choice: allow access at a certain level | ||
138 | 314 | # or don't allow access at all. | ||
139 | 315 | message = self.CHOOSE_ACCESS_LEVEL_ONE | ||
140 | 316 | level = [level['title'] for level in self.allow_access_levels | ||
141 | 317 | if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL] | ||
142 | 318 | extra = {'level' : level[0]} | ||
143 | 319 | else: | ||
144 | 320 | message = self.CHOOSE_ACCESS_LEVEL | ||
145 | 321 | extra = None | ||
146 | 322 | access_level = self.input_access_level( | ||
147 | 323 | self.allow_access_levels, self.message(message, extra)) | ||
148 | 324 | |||
149 | 325 | # Notify the program of the user's choice. | ||
150 | 326 | if access_level == self.UNAUTHORIZED_ACCESS_LEVEL: | ||
151 | 327 | self.user_chose_unauthorized( | ||
152 | 328 | self.message(self.CHOSE_UNAUTHORIZED)) | ||
153 | 329 | else: | ||
154 | 330 | self.user_chose_other_than_unauthorized( | ||
155 | 331 | access_level, | ||
156 | 332 | self.message(self.CHOSE_OTHER_THAN_UNAUTHORIZED)) | ||
157 | 333 | |||
158 | 334 | # Try to grant the specified level of access to the request token. | ||
159 | 335 | response, content = self.browser.grant_access( | ||
160 | 336 | username, password, self.request_token, access_level) | ||
161 | 337 | if response.status == 409: | ||
162 | 338 | raise RequestTokenAlreadyAuthorized( | ||
163 | 339 | self.message(self.REQUEST_TOKEN_ALREADY_AUTHORIZED)) | ||
164 | 340 | elif response.status == 400: | ||
165 | 341 | raise ClientError(self.message(self.CLIENT_ERROR)) | ||
166 | 342 | elif response.status == 500: | ||
167 | 343 | raise ServerError(self.message(SERVER_ERROR)) | ||
168 | 344 | if access_level == self.UNAUTHORIZED_ACCESS_LEVEL: | ||
169 | 345 | message = self.SUCCESS_UNAUTHORIZED | ||
170 | 346 | else: | ||
171 | 347 | message = self.SUCCESS | ||
172 | 348 | self.success(self.message(message)) | ||
173 | 349 | |||
174 | 350 | def get_http_credentials(self, cached_username=None, failed_attempts=0): | ||
175 | 351 | username = self.input_username( | ||
176 | 352 | cached_username, self.message(self.INPUT_USERNAME)) | ||
177 | 353 | if username is None: | ||
178 | 354 | self.open_login_page_in_user_browser( | ||
179 | 355 | urljoin(self.web_root, "+login")) | ||
180 | 356 | raise NoLaunchpadAccount( | ||
181 | 357 | self.message(self.YOU_NEED_A_LAUNCHPAD_ACCOUNT)) | ||
182 | 358 | password = self.input_password(self.message(self.INPUT_PASSWORD)) | ||
183 | 359 | response, content = self.browser.get_token_info( | ||
184 | 360 | username, password, self.request_token, self.allow_access_levels) | ||
185 | 361 | if response.status == 500: | ||
186 | 362 | raise ServerError(self.message(SERVER_ERROR)) | ||
187 | 363 | elif response.status == 401: | ||
188 | 364 | failed_attempts += 1 | ||
189 | 365 | if failed_attempts == self.max_failed_attempts: | ||
190 | 366 | raise TooManyAuthenticationFailures( | ||
191 | 367 | self.message(self.TOO_MANY_AUTHENTICATION_FAILURES)) | ||
192 | 368 | else: | ||
193 | 369 | self.authentication_failure( | ||
194 | 370 | self.message(self.AUTHENTICATION_FAILURE)) | ||
195 | 371 | return self.get_http_credentials(username, failed_attempts) | ||
196 | 372 | token_info = simplejson.loads(content) | ||
197 | 373 | return username, password, token_info | ||
198 | 374 | |||
199 | 375 | def _check_consumer(self): | ||
200 | 376 | """Sanity-check the server consumer against the client consumer.""" | ||
201 | 377 | real_consumer = self.token_info['oauth_token_consumer'] | ||
202 | 378 | if real_consumer != self.consumer_name: | ||
203 | 379 | message = self.message( | ||
204 | 380 | self.CONSUMER_MISMATCH, { 'old_consumer' : self.consumer_name, | ||
205 | 381 | 'real_consumer' : real_consumer }) | ||
206 | 382 | self.server_consumer_differs_from_client_consumer( | ||
207 | 383 | self.consumer_name, real_consumer, message) | ||
208 | 384 | self.consumer_name = real_consumer | ||
209 | 385 | |||
210 | 386 | def message(self, raw_message, extra_variables=None): | ||
211 | 387 | """Prepare a message by plugging in the app name.""" | ||
212 | 388 | variables = { 'app' : self.consumer_name } | ||
213 | 389 | if extra_variables is not None: | ||
214 | 390 | variables.update(extra_variables) | ||
215 | 391 | return raw_message % variables | ||
216 | 392 | |||
217 | 393 | def open_login_page_in_user_browser(self, url): | ||
218 | 394 | """Open the Launchpad login page in the user's web browser.""" | ||
219 | 395 | webbrowser.open(url) | ||
220 | 396 | |||
221 | 397 | # You should define these methods in your subclass. | ||
222 | 398 | |||
223 | 399 | def startup(self, suggested_message): | ||
224 | 400 | """Hook method called on startup.""" | ||
225 | 401 | |||
226 | 402 | def input_username(self, cached_username, suggested_message): | ||
227 | 403 | """Collect the Launchpad username from the end-user. | ||
228 | 404 | |||
229 | 405 | :param cached_username: A username from a previous entry attempt, | ||
230 | 406 | to be presented as the default. | ||
231 | 407 | """ | ||
232 | 408 | raise NotImplementedError() | ||
233 | 409 | |||
234 | 410 | def input_password(self, suggested_message): | ||
235 | 411 | """Collect the Launchpad password from the end-user.""" | ||
236 | 412 | raise NotImplementedError() | ||
237 | 413 | |||
238 | 414 | def input_access_level(self, available_levels, suggested_message): | ||
239 | 415 | """Collect the desired level of access from the end-user.""" | ||
240 | 416 | raise NotImplementedError() | ||
241 | 417 | |||
242 | 418 | def authentication_failure(self, suggested_message): | ||
243 | 419 | """The user entered invalid credentials.""" | ||
244 | 420 | |||
245 | 421 | def user_chose_unauthorized(self): | ||
246 | 422 | """The user refused to authorize a request token.""" | ||
247 | 423 | |||
248 | 424 | def user_chose_other_than_unauthorized(self, access_level): | ||
249 | 425 | """The user authorized a request token with some access level.""" | ||
250 | 426 | |||
251 | 427 | def server_consumer_differs_from_client_consumer( | ||
252 | 428 | self, client_name, real_name, suggested_message): | ||
253 | 429 | """The client seems to be lying or mistaken about its name. | ||
254 | 430 | |||
255 | 431 | When requesting a request token, the client told Launchpad | ||
256 | 432 | that its consumer name was "foo". Now the client is telling the | ||
257 | 433 | end-user that its name is "bar". Something is fishy and at the very | ||
258 | 434 | least the end-user should be warned about this. | ||
259 | 435 | """ | ||
260 | 436 | pass | ||
261 | 437 | |||
262 | 438 | def success(self, suggested_message): | ||
263 | 439 | """The token was successfully authorized.""" | ||
264 | 440 | pass | ||
265 | 441 | |||
266 | 442 | |||
267 | 443 | class TokenAuthorizationException(Exception): | ||
268 | 444 | pass | ||
269 | 445 | |||
270 | 446 | class RequestTokenAlreadyAuthorized(TokenAuthorizationException): | ||
271 | 447 | pass | ||
272 | 448 | |||
273 | 449 | class ClientError(TokenAuthorizationException): | ||
274 | 450 | pass | ||
275 | 451 | |||
276 | 452 | class ServerError(TokenAuthorizationException): | ||
277 | 453 | pass | ||
278 | 454 | |||
279 | 455 | class NoLaunchpadAccount(TokenAuthorizationException): | ||
280 | 456 | pass | ||
281 | 457 | |||
282 | 458 | class TooManyAuthenticationFailures(TokenAuthorizationException): | ||
283 | 459 | pass | ||
284 | 241 | 460 | ||
285 | === modified file 'src/launchpadlib/docs/browser.txt' | |||
286 | --- src/launchpadlib/docs/browser.txt 2009-10-27 15:24:36 +0000 | |||
287 | +++ src/launchpadlib/docs/browser.txt 2009-10-28 20:20:23 +0000 | |||
288 | @@ -51,7 +51,11 @@ | |||
289 | 51 | ... username, password, request_token) | 51 | ... username, password, request_token) |
290 | 52 | >>> print response['content-type'] | 52 | >>> print response['content-type'] |
291 | 53 | application/json | 53 | application/json |
293 | 54 | >>> response['content-location'] == validate_url | 54 | |
294 | 55 | # XXX leonardr 2009-10-28 bug=462773 These two URLs should be | ||
295 | 56 | # exactly the same, but the Content-Location is missing the | ||
296 | 57 | # lp.context. | ||
297 | 58 | >>> validate_url.startswith(response['content-location']) | ||
298 | 55 | True | 59 | True |
299 | 56 | 60 | ||
300 | 57 | >>> import simplejson | 61 | >>> import simplejson |
301 | 58 | 62 | ||
302 | === added file 'src/launchpadlib/docs/trusted-client.txt' | |||
303 | --- src/launchpadlib/docs/trusted-client.txt 1970-01-01 00:00:00 +0000 | |||
304 | +++ src/launchpadlib/docs/trusted-client.txt 2009-10-28 20:20:23 +0000 | |||
305 | @@ -0,0 +1,160 @@ | |||
306 | 1 | *********************** | ||
307 | 2 | Making a trusted client | ||
308 | 3 | *********************** | ||
309 | 4 | |||
310 | 5 | To authorize a request token, the end-user must type in their | ||
311 | 6 | Launchpad username and password. Obviously, typing your password into | ||
312 | 7 | a random program is a bad idea. The best case is to use a program you | ||
313 | 8 | already trust with your Launchpad password: your web browser. | ||
314 | 9 | |||
315 | 10 | But if you're writing an application that can't open the end-user's | ||
316 | 11 | web browser, or you just really want a token authorization client that | ||
317 | 12 | has the same UI as the rest of your application, you should use one of | ||
318 | 13 | the trusted clients packaged with launchpadlib, rather than writing | ||
319 | 14 | your own client. | ||
320 | 15 | |||
321 | 16 | All the trusted clients are based on the same core code and implement | ||
322 | 17 | the same workflow. This test implements a scriptable trusted client | ||
323 | 18 | and uses it to test the behavior of the standard workflow. | ||
324 | 19 | |||
325 | 20 | >>> from launchpadlib.testing.helpers import ( | ||
326 | 21 | ... ScriptableRequestTokenAuthorization) | ||
327 | 22 | |||
328 | 23 | Here we see the normal workflow, in which the user inputs all the | ||
329 | 24 | correct data to authorize a request token. | ||
330 | 25 | |||
331 | 26 | >>> auth = ScriptableRequestTokenAuthorization( | ||
332 | 27 | ... "consumer", "salgado@ubuntu.com", "zeca", | ||
333 | 28 | ... "WRITE_PRIVATE", | ||
334 | 29 | ... allow_access_levels = ["WRITE_PUBLIC", "WRITE_PRIVATE"]) | ||
335 | 30 | >>> access_token = auth() | ||
336 | 31 | What email address do you use on Launchpad? | ||
337 | 32 | What's your Launchpad password? | ||
338 | 33 | Now it's time for you to decide how much power to give "consumer" ... | ||
339 | 34 | ['UNAUTHORIZED', 'WRITE_PUBLIC', 'WRITE_PRIVATE'] | ||
340 | 35 | Okay, I'm telling Launchpad to grant "consumer" access to your account. | ||
341 | 36 | You're all done! You should now be able to use Launchpad ... | ||
342 | 37 | |||
343 | 38 | Ordinarily, the third-party program will create a request token and | ||
344 | 39 | pass it into the trusted client. The test class is a little unusual: | ||
345 | 40 | it takes care of creating the request token and, after the end-user | ||
346 | 41 | has authorized it, exchanges the request token for an access | ||
347 | 42 | token. This way we can verify that the entire end-to-end process | ||
348 | 43 | works. | ||
349 | 44 | |||
350 | 45 | >>> access_token.key is not None | ||
351 | 46 | True | ||
352 | 47 | |||
353 | 48 | Denying access | ||
354 | 49 | ============== | ||
355 | 50 | |||
356 | 51 | It's always possible for the end-user to deny access to the | ||
357 | 52 | application. This will make it impossible to convert the request token | ||
358 | 53 | into an access token. | ||
359 | 54 | |||
360 | 55 | >>> auth = ScriptableRequestTokenAuthorization( | ||
361 | 56 | ... "consumer", "salgado@ubuntu.com", "zeca", "UNAUTHORIZED") | ||
362 | 57 | >>> access_token = auth() | ||
363 | 58 | What email address do you use on Launchpad? | ||
364 | 59 | ... | ||
365 | 60 | Okay, I'm going to cancel the request that "consumer" made... | ||
366 | 61 | You're all done! "consumer" still doesn't have access... | ||
367 | 62 | >>> access_token is None | ||
368 | 63 | True | ||
369 | 64 | |||
370 | 65 | Only one allowable access level | ||
371 | 66 | =============================== | ||
372 | 67 | |||
373 | 68 | When the application being authenticated only allows one access level, | ||
374 | 69 | the authorizer creates a special message for display to the end-user. | ||
375 | 70 | |||
376 | 71 | >>> auth = ScriptableRequestTokenAuthorization( | ||
377 | 72 | ... "consumer", "salgado@ubuntu.com", "zeca", | ||
378 | 73 | ... "WRITE_PRIVATE", allow_access_levels=["WRITE_PRIVATE"]) | ||
379 | 74 | |||
380 | 75 | >>> auth() | ||
381 | 76 | What email address do you use on Launchpad? | ||
382 | 77 | ... | ||
383 | 78 | "consumer" says it needs the following level of access to your Launchpad | ||
384 | 79 | account: "Change Anything". It can't work with any other level of access, | ||
385 | 80 | so denying this level of access means prohibiting "consumer" from | ||
386 | 81 | using your Launchpad account at all. | ||
387 | 82 | ... | ||
388 | 83 | |||
389 | 84 | Error handling | ||
390 | 85 | ============== | ||
391 | 86 | |||
392 | 87 | Things can go wrong in many ways, most of which we can test with our | ||
393 | 88 | scriptable authorizer. Here's a utility method to run the | ||
394 | 89 | authorization process with a badly-scripted authorizer and print the | ||
395 | 90 | resulting exception. | ||
396 | 91 | |||
397 | 92 | >>> from launchpadlib.credentials import TokenAuthorizationException | ||
398 | 93 | >>> def print_error(auth): | ||
399 | 94 | ... try: | ||
400 | 95 | ... auth() | ||
401 | 96 | ... except TokenAuthorizationException, e: | ||
402 | 97 | ... print str(e) | ||
403 | 98 | |||
404 | 99 | Authentication failures | ||
405 | 100 | ----------------------- | ||
406 | 101 | |||
407 | 102 | If the user doesn't have a Launchpad account, or refuses to type in | ||
408 | 103 | their email address, the authorizer will open their web browser to the | ||
409 | 104 | login page, and raise an exception. | ||
410 | 105 | |||
411 | 106 | >>> auth = ScriptableRequestTokenAuthorization( | ||
412 | 107 | ... "consumer", None, "zeca", "WRITE_PRIVATE") | ||
413 | 108 | >>> print_error(auth) | ||
414 | 109 | What email address do you use on Launchpad? | ||
415 | 110 | [If this were a real application, ... opened to http://launchpad.dev:8085/+login] | ||
416 | 111 | OK, you'll need to get yourself a Launchpad account before you can ... | ||
417 | 112 | <BLANKLINE> | ||
418 | 113 | I'm opening the Launchpad registration page in your web browser ... | ||
419 | 114 | |||
420 | 115 | If the user enters the wrong username/password combination too many | ||
421 | 116 | times, the authorizer will give up and raise an exception. | ||
422 | 117 | |||
423 | 118 | >>> auth = ScriptableRequestTokenAuthorization( | ||
424 | 119 | ... "consumer", "salgado@ubuntu.com", "baddpassword", | ||
425 | 120 | ... "WRITE_PRIVATE") | ||
426 | 121 | >>> print_error(auth) | ||
427 | 122 | What email address do you use on Launchpad? | ||
428 | 123 | What's your Launchpad password? | ||
429 | 124 | I can't log in with the credentials you gave me. Let's try again. | ||
430 | 125 | What email address do you use on Launchpad? | ||
431 | 126 | Cached username: salgado@ubuntu.com | ||
432 | 127 | What's your Launchpad password? | ||
433 | 128 | You've failed the password entry too many times... | ||
434 | 129 | |||
435 | 130 | The max_failed_attempts argument controls how many attempts the user | ||
436 | 131 | is given to enter their username and password. | ||
437 | 132 | |||
438 | 133 | >>> auth = ScriptableRequestTokenAuthorization( | ||
439 | 134 | ... "consumer", "bad username", "zeca", | ||
440 | 135 | ... "WRITE_PRIVATE", max_failed_attempts=1) | ||
441 | 136 | >>> print_error(auth) | ||
442 | 137 | What email address do you use on Launchpad? | ||
443 | 138 | What's your Launchpad password? | ||
444 | 139 | You've failed the password entry too many times... | ||
445 | 140 | |||
446 | 141 | Client duplicity | ||
447 | 142 | ---------------- | ||
448 | 143 | |||
449 | 144 | If the third-party client gives one consumer name to Launchpad, and a | ||
450 | 145 | different consumer name to the authorizer, the authorizer will detect | ||
451 | 146 | this possible duplicity and print a warning. | ||
452 | 147 | |||
453 | 148 | >>> auth = ScriptableRequestTokenAuthorization( | ||
454 | 149 | ... "consumer1", "salgado@ubuntu.com", "zeca", | ||
455 | 150 | ... "WRITE_PRIVATE") | ||
456 | 151 | |||
457 | 152 | We'll simulate this by changing the authorizer's .consumer_name after | ||
458 | 153 | it obtained a request token from Launchpad. | ||
459 | 154 | |||
460 | 155 | >>> auth.consumer_name = "consumer2" | ||
461 | 156 | >>> auth() | ||
462 | 157 | What email address do you use on Launchpad? | ||
463 | 158 | ... | ||
464 | 159 | WARNING: The application you're using told me its name was "consumer2", but it told Launchpad its name was "consumer1"... | ||
465 | 160 | ... | ||
466 | 0 | 161 | ||
467 | === modified file 'src/launchpadlib/testing/helpers.py' | |||
468 | --- src/launchpadlib/testing/helpers.py 2009-10-27 12:00:13 +0000 | |||
469 | +++ src/launchpadlib/testing/helpers.py 2009-10-28 20:20:23 +0000 | |||
470 | @@ -29,6 +29,8 @@ | |||
471 | 29 | 29 | ||
472 | 30 | 30 | ||
473 | 31 | from launchpadlib.launchpad import Launchpad | 31 | from launchpadlib.launchpad import Launchpad |
474 | 32 | from launchpadlib.credentials import ( | ||
475 | 33 | AuthorizeRequestTokenProcess, Credentials) | ||
476 | 32 | 34 | ||
477 | 33 | 35 | ||
478 | 34 | class TestableLaunchpad(Launchpad): | 36 | class TestableLaunchpad(Launchpad): |
479 | @@ -61,3 +63,90 @@ | |||
480 | 61 | salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test') | 63 | salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test') |
481 | 62 | salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret') | 64 | salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret') |
482 | 63 | nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery') | 65 | nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery') |
483 | 66 | |||
484 | 67 | |||
485 | 68 | class ScriptableRequestTokenAuthorization(AuthorizeRequestTokenProcess): | ||
486 | 69 | """A request token process that doesn't need any user input. | ||
487 | 70 | |||
488 | 71 | The AuthorizeRequestTokenProcess is supposed to be hooked up to a | ||
489 | 72 | user interface, but that makes it difficult to test. This subclass | ||
490 | 73 | is designed to be easy to test. | ||
491 | 74 | """ | ||
492 | 75 | |||
493 | 76 | def __init__(self, consumer_name, username, password, choose_access_level, | ||
494 | 77 | allow_access_levels=[], max_failed_attempts=2, | ||
495 | 78 | web_root="http://launchpad.dev:8085/"): | ||
496 | 79 | |||
497 | 80 | # Get a request token. | ||
498 | 81 | self.credentials = Credentials(consumer_name) | ||
499 | 82 | self.credentials.get_request_token(web_root=web_root) | ||
500 | 83 | |||
501 | 84 | # Initialize the superclass with the new request token. | ||
502 | 85 | super(ScriptableRequestTokenAuthorization, self).__init__( | ||
503 | 86 | web_root, consumer_name, self.credentials._request_token.key, | ||
504 | 87 | allow_access_levels, max_failed_attempts) | ||
505 | 88 | |||
506 | 89 | self.username = username | ||
507 | 90 | self.password = password | ||
508 | 91 | self.choose_access_level = choose_access_level | ||
509 | 92 | |||
510 | 93 | def __call__(self): | ||
511 | 94 | super(ScriptableRequestTokenAuthorization, self).__call__() | ||
512 | 95 | |||
513 | 96 | # Now verify that it worked by exchanging the authorized | ||
514 | 97 | # request token for an access token. | ||
515 | 98 | if self.choose_access_level != self.UNAUTHORIZED_ACCESS_LEVEL: | ||
516 | 99 | self.credentials.exchange_request_token_for_access_token( | ||
517 | 100 | web_root=self.web_root) | ||
518 | 101 | return self.credentials.access_token | ||
519 | 102 | return None | ||
520 | 103 | |||
521 | 104 | def open_login_page_in_user_browser(self, url): | ||
522 | 105 | """Print a status message.""" | ||
523 | 106 | print ("[If this were a real application, the end-user's web " | ||
524 | 107 | "browser would be opened to %s]" % url) | ||
525 | 108 | |||
526 | 109 | def setup(self, suggested_message): | ||
527 | 110 | print suggested_message | ||
528 | 111 | |||
529 | 112 | def input_username(self, cached_username, suggested_message): | ||
530 | 113 | """Collect the Launchpad username from the end-user.""" | ||
531 | 114 | print suggested_message | ||
532 | 115 | if cached_username is not None: | ||
533 | 116 | print "Cached username: " + cached_username | ||
534 | 117 | return self.username | ||
535 | 118 | |||
536 | 119 | def input_password(self, suggested_message): | ||
537 | 120 | """Collect the Launchpad password from the end-user.""" | ||
538 | 121 | print suggested_message | ||
539 | 122 | return self.password | ||
540 | 123 | |||
541 | 124 | def input_access_level(self, available_levels, suggested_message): | ||
542 | 125 | """Collect the desired level of access from the end-user.""" | ||
543 | 126 | print suggested_message | ||
544 | 127 | print [level['value'] for level in available_levels] | ||
545 | 128 | return self.choose_access_level | ||
546 | 129 | |||
547 | 130 | def authentication_failure(self, suggested_message): | ||
548 | 131 | """The user entered invalid credentials.""" | ||
549 | 132 | print suggested_message | ||
550 | 133 | |||
551 | 134 | def user_chose_unauthorized(self, suggested_message): | ||
552 | 135 | """The user refused to authorize a request token.""" | ||
553 | 136 | print suggested_message | ||
554 | 137 | |||
555 | 138 | def user_chose_other_than_unauthorized(self, access_level, | ||
556 | 139 | suggested_message): | ||
557 | 140 | """The user authorized a request token with some access level.""" | ||
558 | 141 | print suggested_message | ||
559 | 142 | |||
560 | 143 | def server_consumer_differs_from_client_consumer( | ||
561 | 144 | self, client_name, real_name, suggested_message): | ||
562 | 145 | """The client seems to be lying or mistaken about its name.""" | ||
563 | 146 | print suggested_message | ||
564 | 147 | |||
565 | 148 | def success(self, suggested_message): | ||
566 | 149 | """The token was successfully authorized.""" | ||
567 | 150 | print suggested_message | ||
568 | 151 | |||
569 | 152 |
This branch introduces a generic engine for a trusted request- token-authoriza tion client. I'm doing this because by and large, third-party developers aren't satisfied with opening the user's web browser and having them authorize a request token through the browser. They want a client that fits in with the rest of the UI. This generic engine can be used by command-line and GUI clients, and I've tested it by writing a scriptable client that implements stubs for all the plugin methods.
I have not written all the tests I'm going to write for this client, but I've written a representative sample, and I want to get what I have landed because this branch is getting kind of large.
There are two unrelated changes in this branch that I should have caught in my earlier branch (json-token- format) :
1. I added launchpadlib/ apps.py, which was written for the previous branch but I forgot to bzr add it. This contains the class tested by doc/command- line.txt.
2. A test in browser.txt was failing because of a bug in Launchpad. I filed a bug, hacked the test, and added a XXX.