Merge lp:~ddstreet/launchpadlib/nosudo into lp:launchpadlib
- nosudo
- Merge into trunk
Status: | Merged | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Merged at revision: | 178 | ||||||||||||
Proposed branch: | lp:~ddstreet/launchpadlib/nosudo | ||||||||||||
Merge into: | lp:launchpadlib | ||||||||||||
Diff against target: |
322 lines (+171/-44) 2 files modified
src/launchpadlib/credentials.py (+151/-43) src/launchpadlib/launchpad.py (+20/-1) |
||||||||||||
To merge this branch: | bzr merge lp:~ddstreet/launchpadlib/nosudo | ||||||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+379694@code.launchpad.net |
Description of the change
- 178. By Dan Streetman
-
Do not use keyring or browser under sudo
Colin Watson (cjwatson) : | # |
- 179. By Dan Streetman
-
Fixes based on MR feedback
Dan Streetman (ddstreet) wrote : | # |
- 180. By Dan Streetman
-
Fallback to MemoryCredentia
lStore if NoKeyringError Instead of passing NoKeyringError up to calling application, change
default behavior to fallback to MemoryCredentialStore. LP: #1864204
Dan Streetman (ddstreet) wrote : | # |
I meant to create a new branch with the last commit for a separate MR, but I don't know the bzr cmdline well enough so it seems I accidentally pushed it to this branch. I'll leave it in this MR since it does follow on the previous commits for this but if you would prefer I can try to move it into a separate MR.
Dan Streetman (ddstreet) wrote : | # |
Also, it would be *super awesome* if launchpadlib could be moved into a git repo instead of bzr... ;-)
Colin Watson (cjwatson) wrote : | # |
Looks mostly good, but tox reports that this fails tests on Python 2.7 and 3.5 like this:
Failure in test test_credential
Traceback (most recent call last):
File "/usr/lib/
testMethod()
File "/home/
self.
File "/usr/lib/
assertion_
File "/usr/lib/
raise self.failureExc
AssertionError: 0 != 1
Failure in test test_default_
Traceback (most recent call last):
File "/usr/lib/
testMethod()
File "/home/
launchpadli
File "/usr/lib/
callableObj
File "/usr/lib/
"{0} not raised"
AssertionError: RuntimeError not raised
This is because 2.7 and 3.5 are no longer supported by the version of keyring that has your patch to add the NoKeyringError exception, so tox picks an older version of keyring: in other words, this is indicating a slight incompatibility with the older keyring version.
Things seem to work well if I apply this patch on top:
=== modified file 'src/launchpadl
--- old/src/
+++ new/src/
@@ -393,7 +393,12 @@ class KeyringCredenti
try:
- except NoKeyringError:
+ except NoKeyringError as e:
+ # keyring < 21.2.0 raises RuntimeError rather than anything more
+ # specific. Make sure it's the exception we're interested in.
+ if (NoKeyringError == RuntimeError and
+ 'No recommended backend was available' not in str(e)):
+ raise
if self._fallback:
else:
@@ -405,7 +410,12 @@ class KeyringCredenti
try:
- except NoKeyringError:
+ except NoKeyringError as e:
+ # keyring < 21.2.0 raises RuntimeError rather than anything more
+ # specific. Make sure it's the exception we're interested in.
+ if (NoKeyringError == Runtime...
- 181. By Dan Streetman
-
Apply cjwatson patch to handle older Keyring code that may throw RuntimeError to indicate real error, instead of NoKeyringError
Dan Streetman (ddstreet) wrote : | # |
Yep, I see what you're saying; I can't really think of any better way to handle older Keyring code that would throw RuntimeError to indicate a real error. I applied your patch, and should now be pushed here. Thanks!
Colin Watson (cjwatson) : | # |
Preview Diff
1 | === modified file 'src/launchpadlib/credentials.py' | |||
2 | --- src/launchpadlib/credentials.py 2020-02-04 14:26:36 +0000 | |||
3 | +++ src/launchpadlib/credentials.py 2020-04-13 20:43:34 +0000 | |||
4 | @@ -41,6 +41,10 @@ | |||
5 | 41 | from sys import stdin | 41 | from sys import stdin |
6 | 42 | import time | 42 | import time |
7 | 43 | try: | 43 | try: |
8 | 44 | from keyring.errors import NoKeyringError | ||
9 | 45 | except ImportError: | ||
10 | 46 | NoKeyringError = RuntimeError | ||
11 | 47 | try: | ||
12 | 44 | from urllib.parse import urlencode | 48 | from urllib.parse import urlencode |
13 | 45 | except ImportError: | 49 | except ImportError: |
14 | 46 | from urllib import urlencode | 50 | from urllib import urlencode |
15 | @@ -359,6 +363,12 @@ | |||
16 | 359 | 363 | ||
17 | 360 | B64MARKER = b"<B64>" | 364 | B64MARKER = b"<B64>" |
18 | 361 | 365 | ||
19 | 366 | def __init__(self, credential_save_failed=None, fallback=False): | ||
20 | 367 | super(KeyringCredentialStore, self).__init__(credential_save_failed) | ||
21 | 368 | self._fallback = None | ||
22 | 369 | if fallback: | ||
23 | 370 | self._fallback = MemoryCredentialStore(credential_save_failed) | ||
24 | 371 | |||
25 | 362 | @staticmethod | 372 | @staticmethod |
26 | 363 | def _ensure_keyring_imported(): | 373 | def _ensure_keyring_imported(): |
27 | 364 | """Ensure the keyring module is imported (postponing side effects). | 374 | """Ensure the keyring module is imported (postponing side effects). |
28 | @@ -380,14 +390,36 @@ | |||
29 | 380 | # Gnome and KDE, when newlines are included in the password. Avoid | 390 | # Gnome and KDE, when newlines are included in the password. Avoid |
30 | 381 | # this problem by base 64 encoding the serialized value. | 391 | # this problem by base 64 encoding the serialized value. |
31 | 382 | serialized = self.B64MARKER + b64encode(serialized) | 392 | serialized = self.B64MARKER + b64encode(serialized) |
34 | 383 | keyring.set_password( | 393 | try: |
35 | 384 | 'launchpadlib', unique_key, serialized.decode('utf-8')) | 394 | keyring.set_password( |
36 | 395 | 'launchpadlib', unique_key, serialized.decode('utf-8')) | ||
37 | 396 | except NoKeyringError as e: | ||
38 | 397 | # keyring < 21.2.0 raises RuntimeError rather than anything more | ||
39 | 398 | # specific. Make sure it's the exception we're interested in. | ||
40 | 399 | if (NoKeyringError == RuntimeError and | ||
41 | 400 | 'No recommended backend was available' not in str(e)): | ||
42 | 401 | raise | ||
43 | 402 | if self._fallback: | ||
44 | 403 | self._fallback.save(credentials, unique_key) | ||
45 | 404 | else: | ||
46 | 405 | raise | ||
47 | 385 | 406 | ||
48 | 386 | def do_load(self, unique_key): | 407 | def do_load(self, unique_key): |
49 | 387 | """Retrieve credentials from the keyring.""" | 408 | """Retrieve credentials from the keyring.""" |
50 | 388 | self._ensure_keyring_imported() | 409 | self._ensure_keyring_imported() |
53 | 389 | credential_string = keyring.get_password( | 410 | try: |
54 | 390 | 'launchpadlib', unique_key) | 411 | credential_string = keyring.get_password( |
55 | 412 | 'launchpadlib', unique_key) | ||
56 | 413 | except NoKeyringError as e: | ||
57 | 414 | # keyring < 21.2.0 raises RuntimeError rather than anything more | ||
58 | 415 | # specific. Make sure it's the exception we're interested in. | ||
59 | 416 | if (NoKeyringError == RuntimeError and | ||
60 | 417 | 'No recommended backend was available' not in str(e)): | ||
61 | 418 | raise | ||
62 | 419 | if self._fallback: | ||
63 | 420 | return self._fallback.load(unique_key) | ||
64 | 421 | else: | ||
65 | 422 | raise | ||
66 | 391 | if credential_string is not None: | 423 | if credential_string is not None: |
67 | 392 | if isinstance(credential_string, unicode_type): | 424 | if isinstance(credential_string, unicode_type): |
68 | 393 | credential_string = credential_string.encode('utf8') | 425 | credential_string = credential_string.encode('utf8') |
69 | @@ -434,6 +466,25 @@ | |||
70 | 434 | return None | 466 | return None |
71 | 435 | 467 | ||
72 | 436 | 468 | ||
73 | 469 | class MemoryCredentialStore(CredentialStore): | ||
74 | 470 | """CredentialStore that stores keys only in memory. | ||
75 | 471 | |||
76 | 472 | This can be used to provide a CredentialStore instance without | ||
77 | 473 | actually saving any key to persistent storage. | ||
78 | 474 | """ | ||
79 | 475 | def __init__(self, credential_save_failed=None): | ||
80 | 476 | super(MemoryCredentialStore, self).__init__(credential_save_failed) | ||
81 | 477 | self._credentials = {} | ||
82 | 478 | |||
83 | 479 | def do_save(self, credentials, unique_key): | ||
84 | 480 | """Store the credentials in our dict""" | ||
85 | 481 | self._credentials[unique_key] = credentials | ||
86 | 482 | |||
87 | 483 | def do_load(self, unique_key): | ||
88 | 484 | """Retrieve the credentials from our dict""" | ||
89 | 485 | return self._credentials.get(unique_key) | ||
90 | 486 | |||
91 | 487 | |||
92 | 437 | class RequestTokenAuthorizationEngine(object): | 488 | class RequestTokenAuthorizationEngine(object): |
93 | 438 | """The superclass of all request token authorizers. | 489 | """The superclass of all request token authorizers. |
94 | 439 | 490 | ||
95 | @@ -579,12 +630,75 @@ | |||
96 | 579 | raise NotImplementedError() | 630 | raise NotImplementedError() |
97 | 580 | 631 | ||
98 | 581 | 632 | ||
101 | 582 | class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine): | 633 | class AuthorizeRequestTokenWithURL(RequestTokenAuthorizationEngine): |
102 | 583 | """The simplest (and, right now, the only) request token authorizer. | 634 | """Authorize using a URL. |
103 | 635 | |||
104 | 636 | This authorizer simply shows the URL for the user to open for | ||
105 | 637 | authorization, and waits until the server responds. | ||
106 | 638 | """ | ||
107 | 639 | |||
108 | 640 | WAITING_FOR_USER = ( | ||
109 | 641 | "Please open this authorization page:\n" | ||
110 | 642 | " (%s)\n" | ||
111 | 643 | "in your browser. Use your browser to authorize\n" | ||
112 | 644 | "this program to access Launchpad on your behalf.") | ||
113 | 645 | WAITING_FOR_LAUNCHPAD = ( | ||
114 | 646 | "Press Enter after authorizing in your browser.") | ||
115 | 647 | |||
116 | 648 | def output(self, message): | ||
117 | 649 | """Display a message. | ||
118 | 650 | |||
119 | 651 | By default, prints the message to standard output. The message | ||
120 | 652 | does not require any user interaction--it's solely | ||
121 | 653 | informative. | ||
122 | 654 | """ | ||
123 | 655 | print(message) | ||
124 | 656 | |||
125 | 657 | def notify_end_user_authorization_url(self, authorization_url): | ||
126 | 658 | """Notify the end-user of the URL.""" | ||
127 | 659 | self.output(self.WAITING_FOR_USER % authorization_url) | ||
128 | 660 | |||
129 | 661 | def check_end_user_authorization(self, credentials): | ||
130 | 662 | """Check if the end-user authorized""" | ||
131 | 663 | try: | ||
132 | 664 | credentials.exchange_request_token_for_access_token( | ||
133 | 665 | self.web_root) | ||
134 | 666 | except HTTPError as e: | ||
135 | 667 | if e.response.status == 403: | ||
136 | 668 | # The user decided not to authorize this | ||
137 | 669 | # application. | ||
138 | 670 | raise EndUserDeclinedAuthorization(e.content) | ||
139 | 671 | else: | ||
140 | 672 | if e.response.status != 401: | ||
141 | 673 | # There was an error accessing the server. | ||
142 | 674 | print("Unexpected response from Launchpad:") | ||
143 | 675 | print(e) | ||
144 | 676 | # The user has not made a decision yet. | ||
145 | 677 | raise EndUserNoAuthorization(e.content) | ||
146 | 678 | return credentials.access_token is not None | ||
147 | 679 | |||
148 | 680 | def wait_for_end_user_authorization(self, credentials): | ||
149 | 681 | """Wait for the end-user to authorize""" | ||
150 | 682 | self.output(self.WAITING_FOR_LAUNCHPAD) | ||
151 | 683 | stdin.readline() | ||
152 | 684 | self.check_end_user_authorization(credentials) | ||
153 | 685 | |||
154 | 686 | def make_end_user_authorize_token(self, credentials, request_token): | ||
155 | 687 | """Have the end-user authorize the token using a URL.""" | ||
156 | 688 | authorization_url = self.authorization_url(request_token) | ||
157 | 689 | self.notify_end_user_authorization_url(authorization_url) | ||
158 | 690 | self.wait_for_end_user_authorization(credentials) | ||
159 | 691 | |||
160 | 692 | |||
161 | 693 | class AuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithURL): | ||
162 | 694 | """Authorize using a URL that pops-up automatically in a browser. | ||
163 | 584 | 695 | ||
164 | 585 | This authorizer simply opens up the end-user's web browser to a | 696 | This authorizer simply opens up the end-user's web browser to a |
165 | 586 | Launchpad URL and lets the end-user authorize the request token | 697 | Launchpad URL and lets the end-user authorize the request token |
166 | 587 | themselves. | 698 | themselves. |
167 | 699 | |||
168 | 700 | This is the same as its superclass, except this class also | ||
169 | 701 | performs the browser automatic opening of the URL. | ||
170 | 588 | """ | 702 | """ |
171 | 589 | 703 | ||
172 | 590 | WAITING_FOR_USER = ( | 704 | WAITING_FOR_USER = ( |
173 | @@ -594,10 +708,10 @@ | |||
174 | 594 | "this program to access Launchpad on your behalf.") | 708 | "this program to access Launchpad on your behalf.") |
175 | 595 | TIMEOUT_MESSAGE = "Press Enter to continue or wait (%d) seconds..." | 709 | TIMEOUT_MESSAGE = "Press Enter to continue or wait (%d) seconds..." |
176 | 596 | TIMEOUT = 5 | 710 | TIMEOUT = 5 |
177 | 597 | WAITING_FOR_LAUNCHPAD = ( | ||
178 | 598 | "Waiting to hear from Launchpad about your decision...") | ||
179 | 599 | TERMINAL_BROWSERS = ('www-browser', 'links', 'links2', 'lynx', | 711 | TERMINAL_BROWSERS = ('www-browser', 'links', 'links2', 'lynx', |
180 | 600 | 'elinks', 'elinks-lite', 'netrik', 'w3m') | 712 | 'elinks', 'elinks-lite', 'netrik', 'w3m') |
181 | 713 | WAITING_FOR_LAUNCHPAD = ( | ||
182 | 714 | "Waiting to hear from Launchpad about your decision...") | ||
183 | 601 | 715 | ||
184 | 602 | def __init__(self, service_root, application_name, consumer_name=None, | 716 | def __init__(self, service_root, application_name, consumer_name=None, |
185 | 603 | credential_save_failed=None, allow_access_levels=None): | 717 | credential_save_failed=None, allow_access_levels=None): |
186 | @@ -620,20 +734,10 @@ | |||
187 | 620 | service_root, application_name, None, | 734 | service_root, application_name, None, |
188 | 621 | credential_save_failed) | 735 | credential_save_failed) |
189 | 622 | 736 | ||
204 | 623 | def output(self, message): | 737 | def notify_end_user_authorization_url(self, authorization_url): |
205 | 624 | """Display a message. | 738 | """Notify the end-user of the URL.""" |
206 | 625 | 739 | super(AuthorizeRequestTokenWithBrowser, | |
207 | 626 | By default, prints the message to standard output. The message | 740 | self).notify_end_user_authorization_url(authorization_url) |
194 | 627 | does not require any user interaction--it's solely | ||
195 | 628 | informative. | ||
196 | 629 | """ | ||
197 | 630 | print(message) | ||
198 | 631 | |||
199 | 632 | def make_end_user_authorize_token(self, credentials, request_token): | ||
200 | 633 | """Have the end-user authorize the token in their browser.""" | ||
201 | 634 | |||
202 | 635 | authorization_url = self.authorization_url(request_token) | ||
203 | 636 | self.output(self.WAITING_FOR_USER % authorization_url) | ||
208 | 637 | 741 | ||
209 | 638 | try: | 742 | try: |
210 | 639 | browser_obj = webbrowser.get() | 743 | browser_obj = webbrowser.get() |
211 | @@ -651,28 +755,20 @@ | |||
212 | 651 | if rlist: | 755 | if rlist: |
213 | 652 | stdin.readline() | 756 | stdin.readline() |
214 | 653 | 757 | ||
215 | 654 | self.output(self.WAITING_FOR_LAUNCHPAD) | ||
216 | 655 | if browser_obj is not None: | 758 | if browser_obj is not None: |
217 | 656 | webbrowser.open(authorization_url) | 759 | webbrowser.open(authorization_url) |
218 | 760 | |||
219 | 761 | def wait_for_end_user_authorization(self, credentials): | ||
220 | 762 | """Wait for the end-user to authorize""" | ||
221 | 763 | self.output(self.WAITING_FOR_LAUNCHPAD) | ||
222 | 657 | start_time = time.time() | 764 | start_time = time.time() |
223 | 658 | while credentials.access_token is None: | 765 | while credentials.access_token is None: |
224 | 659 | time.sleep(access_token_poll_time) | 766 | time.sleep(access_token_poll_time) |
225 | 660 | try: | 767 | try: |
241 | 661 | credentials.exchange_request_token_for_access_token( | 768 | if self.check_end_user_authorization(credentials): |
242 | 662 | self.web_root) | 769 | break |
243 | 663 | break | 770 | except EndUserNoAuthorization: |
244 | 664 | except HTTPError as e: | 771 | pass |
230 | 665 | if e.response.status == 403: | ||
231 | 666 | # The user decided not to authorize this | ||
232 | 667 | # application. | ||
233 | 668 | raise EndUserDeclinedAuthorization(e.content) | ||
234 | 669 | elif e.response.status == 401: | ||
235 | 670 | # The user has not made a decision yet. | ||
236 | 671 | pass | ||
237 | 672 | else: | ||
238 | 673 | # There was an error accessing the server. | ||
239 | 674 | print("Unexpected response from Launchpad:") | ||
240 | 675 | print(e) | ||
245 | 676 | if time.time() >= start_time + access_token_poll_timeout: | 772 | if time.time() >= start_time + access_token_poll_timeout: |
246 | 677 | raise TokenAuthorizationTimedOut( | 773 | raise TokenAuthorizationTimedOut( |
247 | 678 | "Timed out after %d seconds." % access_token_poll_timeout) | 774 | "Timed out after %d seconds." % access_token_poll_timeout) |
248 | @@ -686,11 +782,23 @@ | |||
249 | 686 | pass | 782 | pass |
250 | 687 | 783 | ||
251 | 688 | 784 | ||
257 | 689 | class EndUserDeclinedAuthorization(TokenAuthorizationException): | 785 | class EndUserAuthorizationFailed(TokenAuthorizationException): |
258 | 690 | pass | 786 | """Superclass exception for all failures of end-user authorization""" |
259 | 691 | 787 | pass | |
260 | 692 | 788 | ||
261 | 693 | class TokenAuthorizationTimedOut(TokenAuthorizationException): | 789 | |
262 | 790 | class EndUserDeclinedAuthorization(EndUserAuthorizationFailed): | ||
263 | 791 | """End-user declined authorization""" | ||
264 | 792 | pass | ||
265 | 793 | |||
266 | 794 | |||
267 | 795 | class EndUserNoAuthorization(EndUserAuthorizationFailed): | ||
268 | 796 | """End-user did not perform any authorization""" | ||
269 | 797 | pass | ||
270 | 798 | |||
271 | 799 | |||
272 | 800 | class TokenAuthorizationTimedOut(EndUserNoAuthorization): | ||
273 | 801 | """End-user did not perform any authorization in timeout period""" | ||
274 | 694 | pass | 802 | pass |
275 | 695 | 803 | ||
276 | 696 | 804 | ||
277 | 697 | 805 | ||
278 | === modified file 'src/launchpadlib/launchpad.py' | |||
279 | --- src/launchpadlib/launchpad.py 2015-11-17 18:23:24 +0000 | |||
280 | +++ src/launchpadlib/launchpad.py 2020-04-13 20:43:34 +0000 | |||
281 | @@ -47,8 +47,10 @@ | |||
282 | 47 | AccessToken, | 47 | AccessToken, |
283 | 48 | AnonymousAccessToken, | 48 | AnonymousAccessToken, |
284 | 49 | AuthorizeRequestTokenWithBrowser, | 49 | AuthorizeRequestTokenWithBrowser, |
285 | 50 | AuthorizeRequestTokenWithURL, | ||
286 | 50 | Consumer, | 51 | Consumer, |
287 | 51 | Credentials, | 52 | Credentials, |
288 | 53 | MemoryCredentialStore, | ||
289 | 52 | KeyringCredentialStore, | 54 | KeyringCredentialStore, |
290 | 53 | UnencryptedFileCredentialStore, | 55 | UnencryptedFileCredentialStore, |
291 | 54 | ) | 56 | ) |
292 | @@ -213,12 +215,29 @@ | |||
293 | 213 | proxy_info) | 215 | proxy_info) |
294 | 214 | 216 | ||
295 | 215 | @classmethod | 217 | @classmethod |
296 | 218 | def _is_sudo(cls): | ||
297 | 219 | return (set(['SUDO_USER', 'SUDO_UID', 'SUDO_GID']) & | ||
298 | 220 | set(os.environ.keys())) | ||
299 | 221 | |||
300 | 222 | @classmethod | ||
301 | 216 | def authorization_engine_factory(cls, *args): | 223 | def authorization_engine_factory(cls, *args): |
302 | 224 | if cls._is_sudo(): | ||
303 | 225 | # Do not try to open browser window under sudo; | ||
304 | 226 | # we probably don't have access to the X session, | ||
305 | 227 | # and some browsers (e.g. chromium) won't run as root | ||
306 | 228 | # LP: #1825014 | ||
307 | 229 | return AuthorizeRequestTokenWithURL(*args) | ||
308 | 217 | return AuthorizeRequestTokenWithBrowser(*args) | 230 | return AuthorizeRequestTokenWithBrowser(*args) |
309 | 218 | 231 | ||
310 | 219 | @classmethod | 232 | @classmethod |
311 | 220 | def credential_store_factory(cls, credential_save_failed): | 233 | def credential_store_factory(cls, credential_save_failed): |
313 | 221 | return KeyringCredentialStore(credential_save_failed) | 234 | if cls._is_sudo(): |
314 | 235 | # Do not try to store credentials under sudo; | ||
315 | 236 | # it can be problematic with shared sudo access, | ||
316 | 237 | # and we may not have access to the normal keyring provider | ||
317 | 238 | # LP: #1862948 | ||
318 | 239 | return MemoryCredentialStore(credential_save_failed) | ||
319 | 240 | return KeyringCredentialStore(credential_save_failed, fallback=True) | ||
320 | 222 | 241 | ||
321 | 223 | @classmethod | 242 | @classmethod |
322 | 224 | def login(cls, consumer_name, token_string, access_secret, | 243 | def login(cls, consumer_name, token_string, access_secret, |
updated based on your feedback; let me know if you have any more feedback with the updated code.
Sorry if I am not getting the bzr commits correct, I use git and have almost no experience with bzr :(