Merge lp:~ddstreet/launchpadlib/nosudo into lp:launchpadlib

Proposed by Dan Streetman
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
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+379694@code.launchpad.net

Commit message

When running under sudo, credentials should not be stored nor should a browser be started.

LP: #1825014
LP: #1862948

To post a comment you must log in.
lp:~ddstreet/launchpadlib/nosudo updated
178. By Dan Streetman

Do not use keyring or browser under sudo

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Information
lp:~ddstreet/launchpadlib/nosudo updated
179. By Dan Streetman

Fixes based on MR feedback

Revision history for this message
Dan Streetman (ddstreet) wrote :

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 :(

lp:~ddstreet/launchpadlib/nosudo updated
180. By Dan Streetman

Fallback to MemoryCredentialStore if NoKeyringError

Instead of passing NoKeyringError up to calling application, change
default behavior to fallback to MemoryCredentialStore.

LP: #1864204

Revision history for this message
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.

Revision history for this message
Dan Streetman (ddstreet) wrote :

Also, it would be *super awesome* if launchpadlib could be moved into a git repo instead of bzr... ;-)

Revision history for this message
Colin Watson (cjwatson) wrote :
Download full text (3.6 KiB)

Looks mostly good, but tox reports that this fails tests on Python 2.7 and 3.5 like this:

Failure in test test_credentials_save_failed (launchpadlib.tests.test_launchpad.TestCredenitialSaveFailedCallback)
Traceback (most recent call last):
  File "/usr/lib/python2.7/unittest/case.py", line 329, in run
    testMethod()
  File "/home/cjwatson/src/canonical/launchpadlib/launchpadlib/.tox/py27/local/lib/python2.7/site-packages/launchpadlib/tests/test_launchpad.py", line 632, in test_credentials_save_failed
    self.assertEqual(len(callback_called), 1)
  File "/usr/lib/python2.7/unittest/case.py", line 513, in assertEqual
    assertion_func(first, second, msg=msg)
  File "/usr/lib/python2.7/unittest/case.py", line 506, in _baseAssertEqual
    raise self.failureException(msg)
AssertionError: 0 != 1

Failure in test test_default_credentials_save_failed_is_to_raise_exception (launchpadlib.tests.test_launchpad.TestCredenitialSaveFailedCallback)
Traceback (most recent call last):
  File "/usr/lib/python2.7/unittest/case.py", line 329, in run
    testMethod()
  File "/home/cjwatson/src/canonical/launchpadlib/launchpadlib/.tox/py27/local/lib/python2.7/site-packages/launchpadlib/tests/test_launchpad.py", line 644, in test_default_credentials_save_failed_is_to_raise_exception
    launchpadlib_dir=launchpadlib_dir)
  File "/usr/lib/python2.7/unittest/case.py", line 473, in assertRaises
    callableObj(*args, **kwargs)
  File "/usr/lib/python2.7/unittest/case.py", line 116, in __exit__
    "{0} not raised".format(exc_name))
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/launchpadlib/credentials.py'
--- old/src/launchpadlib/credentials.py 2020-03-30 15:39:23 +0000
+++ new/src/launchpadlib/credentials.py 2020-04-08 15:38:53 +0000
@@ -393,7 +393,12 @@ class KeyringCredentialStore(CredentialS
         try:
             keyring.set_password(
                 'launchpadlib', unique_key, serialized.decode('utf-8'))
- 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:
                 self._fallback.save(credentials, unique_key)
             else:
@@ -405,7 +410,12 @@ class KeyringCredentialStore(CredentialS
         try:
             credential_string = keyring.get_password(
                 'launchpadlib', unique_key)
- 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...

Read more...

review: Needs Fixing
lp:~ddstreet/launchpadlib/nosudo updated
181. By Dan Streetman

Apply cjwatson patch to handle older Keyring code that may throw RuntimeError to indicate real error, instead of NoKeyringError

Revision history for this message
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!

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2020-02-04 14:26:36 +0000
+++ src/launchpadlib/credentials.py 2020-04-13 20:43:34 +0000
@@ -41,6 +41,10 @@
41from sys import stdin41from sys import stdin
42import time42import time
43try:43try:
44 from keyring.errors import NoKeyringError
45except ImportError:
46 NoKeyringError = RuntimeError
47try:
44 from urllib.parse import urlencode48 from urllib.parse import urlencode
45except ImportError:49except ImportError:
46 from urllib import urlencode50 from urllib import urlencode
@@ -359,6 +363,12 @@
359363
360 B64MARKER = b"<B64>"364 B64MARKER = b"<B64>"
361365
366 def __init__(self, credential_save_failed=None, fallback=False):
367 super(KeyringCredentialStore, self).__init__(credential_save_failed)
368 self._fallback = None
369 if fallback:
370 self._fallback = MemoryCredentialStore(credential_save_failed)
371
362 @staticmethod372 @staticmethod
363 def _ensure_keyring_imported():373 def _ensure_keyring_imported():
364 """Ensure the keyring module is imported (postponing side effects).374 """Ensure the keyring module is imported (postponing side effects).
@@ -380,14 +390,36 @@
380 # Gnome and KDE, when newlines are included in the password. Avoid390 # Gnome and KDE, when newlines are included in the password. Avoid
381 # this problem by base 64 encoding the serialized value.391 # this problem by base 64 encoding the serialized value.
382 serialized = self.B64MARKER + b64encode(serialized)392 serialized = self.B64MARKER + b64encode(serialized)
383 keyring.set_password(393 try:
384 'launchpadlib', unique_key, serialized.decode('utf-8'))394 keyring.set_password(
395 'launchpadlib', unique_key, serialized.decode('utf-8'))
396 except NoKeyringError as e:
397 # keyring < 21.2.0 raises RuntimeError rather than anything more
398 # specific. Make sure it's the exception we're interested in.
399 if (NoKeyringError == RuntimeError and
400 'No recommended backend was available' not in str(e)):
401 raise
402 if self._fallback:
403 self._fallback.save(credentials, unique_key)
404 else:
405 raise
385406
386 def do_load(self, unique_key):407 def do_load(self, unique_key):
387 """Retrieve credentials from the keyring."""408 """Retrieve credentials from the keyring."""
388 self._ensure_keyring_imported()409 self._ensure_keyring_imported()
389 credential_string = keyring.get_password(410 try:
390 'launchpadlib', unique_key)411 credential_string = keyring.get_password(
412 'launchpadlib', unique_key)
413 except NoKeyringError as e:
414 # keyring < 21.2.0 raises RuntimeError rather than anything more
415 # specific. Make sure it's the exception we're interested in.
416 if (NoKeyringError == RuntimeError and
417 'No recommended backend was available' not in str(e)):
418 raise
419 if self._fallback:
420 return self._fallback.load(unique_key)
421 else:
422 raise
391 if credential_string is not None:423 if credential_string is not None:
392 if isinstance(credential_string, unicode_type):424 if isinstance(credential_string, unicode_type):
393 credential_string = credential_string.encode('utf8')425 credential_string = credential_string.encode('utf8')
@@ -434,6 +466,25 @@
434 return None466 return None
435467
436468
469class MemoryCredentialStore(CredentialStore):
470 """CredentialStore that stores keys only in memory.
471
472 This can be used to provide a CredentialStore instance without
473 actually saving any key to persistent storage.
474 """
475 def __init__(self, credential_save_failed=None):
476 super(MemoryCredentialStore, self).__init__(credential_save_failed)
477 self._credentials = {}
478
479 def do_save(self, credentials, unique_key):
480 """Store the credentials in our dict"""
481 self._credentials[unique_key] = credentials
482
483 def do_load(self, unique_key):
484 """Retrieve the credentials from our dict"""
485 return self._credentials.get(unique_key)
486
487
437class RequestTokenAuthorizationEngine(object):488class RequestTokenAuthorizationEngine(object):
438 """The superclass of all request token authorizers.489 """The superclass of all request token authorizers.
439490
@@ -579,12 +630,75 @@
579 raise NotImplementedError()630 raise NotImplementedError()
580631
581632
582class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):633class AuthorizeRequestTokenWithURL(RequestTokenAuthorizationEngine):
583 """The simplest (and, right now, the only) request token authorizer.634 """Authorize using a URL.
635
636 This authorizer simply shows the URL for the user to open for
637 authorization, and waits until the server responds.
638 """
639
640 WAITING_FOR_USER = (
641 "Please open this authorization page:\n"
642 " (%s)\n"
643 "in your browser. Use your browser to authorize\n"
644 "this program to access Launchpad on your behalf.")
645 WAITING_FOR_LAUNCHPAD = (
646 "Press Enter after authorizing in your browser.")
647
648 def output(self, message):
649 """Display a message.
650
651 By default, prints the message to standard output. The message
652 does not require any user interaction--it's solely
653 informative.
654 """
655 print(message)
656
657 def notify_end_user_authorization_url(self, authorization_url):
658 """Notify the end-user of the URL."""
659 self.output(self.WAITING_FOR_USER % authorization_url)
660
661 def check_end_user_authorization(self, credentials):
662 """Check if the end-user authorized"""
663 try:
664 credentials.exchange_request_token_for_access_token(
665 self.web_root)
666 except HTTPError as e:
667 if e.response.status == 403:
668 # The user decided not to authorize this
669 # application.
670 raise EndUserDeclinedAuthorization(e.content)
671 else:
672 if e.response.status != 401:
673 # There was an error accessing the server.
674 print("Unexpected response from Launchpad:")
675 print(e)
676 # The user has not made a decision yet.
677 raise EndUserNoAuthorization(e.content)
678 return credentials.access_token is not None
679
680 def wait_for_end_user_authorization(self, credentials):
681 """Wait for the end-user to authorize"""
682 self.output(self.WAITING_FOR_LAUNCHPAD)
683 stdin.readline()
684 self.check_end_user_authorization(credentials)
685
686 def make_end_user_authorize_token(self, credentials, request_token):
687 """Have the end-user authorize the token using a URL."""
688 authorization_url = self.authorization_url(request_token)
689 self.notify_end_user_authorization_url(authorization_url)
690 self.wait_for_end_user_authorization(credentials)
691
692
693class AuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithURL):
694 """Authorize using a URL that pops-up automatically in a browser.
584695
585 This authorizer simply opens up the end-user's web browser to a696 This authorizer simply opens up the end-user's web browser to a
586 Launchpad URL and lets the end-user authorize the request token697 Launchpad URL and lets the end-user authorize the request token
587 themselves.698 themselves.
699
700 This is the same as its superclass, except this class also
701 performs the browser automatic opening of the URL.
588 """702 """
589703
590 WAITING_FOR_USER = (704 WAITING_FOR_USER = (
@@ -594,10 +708,10 @@
594 "this program to access Launchpad on your behalf.")708 "this program to access Launchpad on your behalf.")
595 TIMEOUT_MESSAGE = "Press Enter to continue or wait (%d) seconds..."709 TIMEOUT_MESSAGE = "Press Enter to continue or wait (%d) seconds..."
596 TIMEOUT = 5710 TIMEOUT = 5
597 WAITING_FOR_LAUNCHPAD = (
598 "Waiting to hear from Launchpad about your decision...")
599 TERMINAL_BROWSERS = ('www-browser', 'links', 'links2', 'lynx',711 TERMINAL_BROWSERS = ('www-browser', 'links', 'links2', 'lynx',
600 'elinks', 'elinks-lite', 'netrik', 'w3m')712 'elinks', 'elinks-lite', 'netrik', 'w3m')
713 WAITING_FOR_LAUNCHPAD = (
714 "Waiting to hear from Launchpad about your decision...")
601715
602 def __init__(self, service_root, application_name, consumer_name=None,716 def __init__(self, service_root, application_name, consumer_name=None,
603 credential_save_failed=None, allow_access_levels=None):717 credential_save_failed=None, allow_access_levels=None):
@@ -620,20 +734,10 @@
620 service_root, application_name, None,734 service_root, application_name, None,
621 credential_save_failed)735 credential_save_failed)
622736
623 def output(self, message):737 def notify_end_user_authorization_url(self, authorization_url):
624 """Display a message.738 """Notify the end-user of the URL."""
625739 super(AuthorizeRequestTokenWithBrowser,
626 By default, prints the message to standard output. The message740 self).notify_end_user_authorization_url(authorization_url)
627 does not require any user interaction--it's solely
628 informative.
629 """
630 print(message)
631
632 def make_end_user_authorize_token(self, credentials, request_token):
633 """Have the end-user authorize the token in their browser."""
634
635 authorization_url = self.authorization_url(request_token)
636 self.output(self.WAITING_FOR_USER % authorization_url)
637741
638 try:742 try:
639 browser_obj = webbrowser.get()743 browser_obj = webbrowser.get()
@@ -651,28 +755,20 @@
651 if rlist:755 if rlist:
652 stdin.readline()756 stdin.readline()
653757
654 self.output(self.WAITING_FOR_LAUNCHPAD)
655 if browser_obj is not None:758 if browser_obj is not None:
656 webbrowser.open(authorization_url)759 webbrowser.open(authorization_url)
760
761 def wait_for_end_user_authorization(self, credentials):
762 """Wait for the end-user to authorize"""
763 self.output(self.WAITING_FOR_LAUNCHPAD)
657 start_time = time.time()764 start_time = time.time()
658 while credentials.access_token is None:765 while credentials.access_token is None:
659 time.sleep(access_token_poll_time)766 time.sleep(access_token_poll_time)
660 try:767 try:
661 credentials.exchange_request_token_for_access_token(768 if self.check_end_user_authorization(credentials):
662 self.web_root)769 break
663 break770 except EndUserNoAuthorization:
664 except HTTPError as e:771 pass
665 if e.response.status == 403:
666 # The user decided not to authorize this
667 # application.
668 raise EndUserDeclinedAuthorization(e.content)
669 elif e.response.status == 401:
670 # The user has not made a decision yet.
671 pass
672 else:
673 # There was an error accessing the server.
674 print("Unexpected response from Launchpad:")
675 print(e)
676 if time.time() >= start_time + access_token_poll_timeout:772 if time.time() >= start_time + access_token_poll_timeout:
677 raise TokenAuthorizationTimedOut(773 raise TokenAuthorizationTimedOut(
678 "Timed out after %d seconds." % access_token_poll_timeout)774 "Timed out after %d seconds." % access_token_poll_timeout)
@@ -686,11 +782,23 @@
686 pass782 pass
687783
688784
689class EndUserDeclinedAuthorization(TokenAuthorizationException):785class EndUserAuthorizationFailed(TokenAuthorizationException):
690 pass786 """Superclass exception for all failures of end-user authorization"""
691787 pass
692788
693class TokenAuthorizationTimedOut(TokenAuthorizationException):789
790class EndUserDeclinedAuthorization(EndUserAuthorizationFailed):
791 """End-user declined authorization"""
792 pass
793
794
795class EndUserNoAuthorization(EndUserAuthorizationFailed):
796 """End-user did not perform any authorization"""
797 pass
798
799
800class TokenAuthorizationTimedOut(EndUserNoAuthorization):
801 """End-user did not perform any authorization in timeout period"""
694 pass802 pass
695803
696804
697805
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2015-11-17 18:23:24 +0000
+++ src/launchpadlib/launchpad.py 2020-04-13 20:43:34 +0000
@@ -47,8 +47,10 @@
47 AccessToken,47 AccessToken,
48 AnonymousAccessToken,48 AnonymousAccessToken,
49 AuthorizeRequestTokenWithBrowser,49 AuthorizeRequestTokenWithBrowser,
50 AuthorizeRequestTokenWithURL,
50 Consumer,51 Consumer,
51 Credentials,52 Credentials,
53 MemoryCredentialStore,
52 KeyringCredentialStore,54 KeyringCredentialStore,
53 UnencryptedFileCredentialStore,55 UnencryptedFileCredentialStore,
54 )56 )
@@ -213,12 +215,29 @@
213 proxy_info)215 proxy_info)
214216
215 @classmethod217 @classmethod
218 def _is_sudo(cls):
219 return (set(['SUDO_USER', 'SUDO_UID', 'SUDO_GID']) &
220 set(os.environ.keys()))
221
222 @classmethod
216 def authorization_engine_factory(cls, *args):223 def authorization_engine_factory(cls, *args):
224 if cls._is_sudo():
225 # Do not try to open browser window under sudo;
226 # we probably don't have access to the X session,
227 # and some browsers (e.g. chromium) won't run as root
228 # LP: #1825014
229 return AuthorizeRequestTokenWithURL(*args)
217 return AuthorizeRequestTokenWithBrowser(*args)230 return AuthorizeRequestTokenWithBrowser(*args)
218231
219 @classmethod232 @classmethod
220 def credential_store_factory(cls, credential_save_failed):233 def credential_store_factory(cls, credential_save_failed):
221 return KeyringCredentialStore(credential_save_failed)234 if cls._is_sudo():
235 # Do not try to store credentials under sudo;
236 # it can be problematic with shared sudo access,
237 # and we may not have access to the normal keyring provider
238 # LP: #1862948
239 return MemoryCredentialStore(credential_save_failed)
240 return KeyringCredentialStore(credential_save_failed, fallback=True)
222241
223 @classmethod242 @classmethod
224 def login(cls, consumer_name, token_string, access_secret,243 def login(cls, consumer_name, token_string, access_secret,

Subscribers

People subscribed via source and target branches