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