credentials.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. # Copyright 2008 Canonical Ltd.
  2. # This file is part of launchpadlib.
  3. #
  4. # launchpadlib is free software: you can redistribute it and/or modify it
  5. # under the terms of the GNU Lesser General Public License as published by the
  6. # Free Software Foundation, version 3 of the License.
  7. #
  8. # launchpadlib is distributed in the hope that it will be useful, but WITHOUT
  9. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  10. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
  11. # for more details.
  12. #
  13. # You should have received a copy of the GNU Lesser General Public License
  14. # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
  15. from __future__ import print_function
  16. """launchpadlib credentials and authentication support."""
  17. __metaclass__ = type
  18. __all__ = [
  19. "AccessToken",
  20. "AnonymousAccessToken",
  21. "AuthorizeRequestTokenWithBrowser",
  22. "CredentialStore",
  23. "RequestTokenAuthorizationEngine",
  24. "Consumer",
  25. "Credentials",
  26. ]
  27. try:
  28. from cStringIO import StringIO
  29. except ImportError:
  30. from io import StringIO
  31. import httplib2
  32. import json
  33. import os
  34. from select import select
  35. import stat
  36. from sys import stdin
  37. import time
  38. try:
  39. from urllib.parse import urlencode
  40. except ImportError:
  41. from urllib import urlencode
  42. try:
  43. from urllib.parse import urljoin
  44. except ImportError:
  45. from urlparse import urljoin
  46. import webbrowser
  47. from base64 import (
  48. b64decode,
  49. b64encode,
  50. )
  51. from six.moves.urllib.parse import parse_qs
  52. if bytes is str:
  53. # Python 2
  54. unicode_type = unicode # noqa: F821
  55. else:
  56. unicode_type = str
  57. from lazr.restfulclient.errors import HTTPError
  58. from lazr.restfulclient.authorize.oauth import (
  59. AccessToken as _AccessToken,
  60. Consumer,
  61. OAuthAuthorizer,
  62. SystemWideConsumer, # Not used directly, just re-imported into here.
  63. )
  64. from launchpadlib import uris
  65. request_token_page = "+request-token"
  66. access_token_page = "+access-token"
  67. authorize_token_page = "+authorize-token"
  68. access_token_poll_time = 1
  69. access_token_poll_timeout = 15 * 60
  70. EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
  71. def _ssl_certificate_validation_disabled():
  72. """Whether the user has disabled SSL certificate connection.
  73. Some testing servers have broken certificates. Rather than raising an
  74. error, we allow an environment variable,
  75. ``LP_DISABLE_SSL_CERTIFICATE_VALIDATION`` to disable the check.
  76. """
  77. # XXX: Copied from lazr/restfulclient/_browser.py. Once it appears in a
  78. # released version of lazr.restfulclient, depend on that new version and
  79. # delete this copy.
  80. return bool(os.environ.get("LP_DISABLE_SSL_CERTIFICATE_VALIDATION", False))
  81. def _http_post(url, headers, params):
  82. """POST to ``url`` with ``headers`` and a body of urlencoded ``params``.
  83. Wraps it up to make sure we avoid the SSL certificate validation if our
  84. environment tells us to. Also, raises an error on non-200 statuses.
  85. """
  86. cert_disabled = _ssl_certificate_validation_disabled()
  87. response, content = httplib2.Http(
  88. disable_ssl_certificate_validation=cert_disabled
  89. ).request(url, method="POST", headers=headers, body=urlencode(params))
  90. if response.status != 200:
  91. raise HTTPError(response, content)
  92. return response, content
  93. class Credentials(OAuthAuthorizer):
  94. """Standard credentials storage and usage class.
  95. :ivar consumer: The consumer (application)
  96. :type consumer: `Consumer`
  97. :ivar access_token: Access information on behalf of the user
  98. :type access_token: `AccessToken`
  99. """
  100. _request_token = None
  101. URI_TOKEN_FORMAT = "uri"
  102. DICT_TOKEN_FORMAT = "dict"
  103. ITEM_SEPARATOR = "<BR>"
  104. NEWLINE = "\n"
  105. def serialize(self):
  106. """Turn this object into a string.
  107. This should probably be moved into OAuthAuthorizer.
  108. """
  109. sio = StringIO()
  110. self.save(sio)
  111. serialized = sio.getvalue()
  112. if isinstance(serialized, unicode_type):
  113. serialized = serialized.encode("utf-8")
  114. return serialized
  115. @classmethod
  116. def from_string(cls, value):
  117. """Create a `Credentials` object from a serialized string.
  118. This should probably be moved into OAuthAuthorizer.
  119. """
  120. credentials = cls()
  121. if not isinstance(value, unicode_type):
  122. value = value.decode("utf-8")
  123. credentials.load(StringIO(value))
  124. return credentials
  125. def get_request_token(
  126. self,
  127. context=None,
  128. web_root=uris.STAGING_WEB_ROOT,
  129. token_format=URI_TOKEN_FORMAT,
  130. ):
  131. """Request an OAuth token to Launchpad.
  132. Also store the token in self._request_token.
  133. This method must not be called on an object with no consumer
  134. specified or if an access token has already been obtained.
  135. :param context: The context of this token, that is, its scope of
  136. validity within Launchpad.
  137. :param web_root: The URL of the website on which the token
  138. should be requested.
  139. :token_format: How the token should be
  140. presented. URI_TOKEN_FORMAT means just return the URL to
  141. the page that authorizes the token. DICT_TOKEN_FORMAT
  142. means return a dictionary describing the token
  143. and the site's authentication policy.
  144. :return: If token_format is URI_TOKEN_FORMAT, the URL for the
  145. user to authorize the `AccessToken` provided by
  146. Launchpad. If token_format is DICT_TOKEN_FORMAT, a dict of
  147. information about the new access token.
  148. """
  149. assert self.consumer is not None, "Consumer not specified."
  150. assert self.access_token is None, "Access token already obtained."
  151. web_root = uris.lookup_web_root(web_root)
  152. params = dict(
  153. oauth_consumer_key=self.consumer.key,
  154. oauth_signature_method="PLAINTEXT",
  155. oauth_signature="&",
  156. )
  157. url = web_root + request_token_page
  158. headers = {"Referer": web_root}
  159. if token_format == self.DICT_TOKEN_FORMAT:
  160. headers["Accept"] = "application/json"
  161. response, content = _http_post(url, headers, params)
  162. if isinstance(content, bytes):
  163. content = content.decode("utf-8")
  164. if token_format == self.DICT_TOKEN_FORMAT:
  165. params = json.loads(content)
  166. if context is not None:
  167. params["lp.context"] = context
  168. self._request_token = AccessToken.from_params(params)
  169. return params
  170. else:
  171. self._request_token = AccessToken.from_string(content)
  172. url = "%s%s?oauth_token=%s" % (
  173. web_root,
  174. authorize_token_page,
  175. self._request_token.key,
  176. )
  177. if context is not None:
  178. self._request_token.context = context
  179. url += "&lp.context=%s" % context
  180. return url
  181. def exchange_request_token_for_access_token(
  182. self, web_root=uris.STAGING_WEB_ROOT
  183. ):
  184. """Exchange the previously obtained request token for an access token.
  185. This method must not be called unless get_request_token() has been
  186. called and completed successfully.
  187. The access token will be stored as self.access_token.
  188. :param web_root: The base URL of the website that granted the
  189. request token.
  190. """
  191. assert (
  192. self._request_token is not None
  193. ), "get_request_token() doesn't seem to have been called."
  194. web_root = uris.lookup_web_root(web_root)
  195. params = dict(
  196. oauth_consumer_key=self.consumer.key,
  197. oauth_signature_method="PLAINTEXT",
  198. oauth_token=self._request_token.key,
  199. oauth_signature="&%s" % self._request_token.secret,
  200. )
  201. url = web_root + access_token_page
  202. headers = {"Referer": web_root}
  203. response, content = _http_post(url, headers, params)
  204. self.access_token = AccessToken.from_string(content)
  205. class AccessToken(_AccessToken):
  206. """An OAuth access token."""
  207. @classmethod
  208. def from_params(cls, params):
  209. """Create and return a new `AccessToken` from the given dict."""
  210. key = params["oauth_token"]
  211. secret = params["oauth_token_secret"]
  212. context = params.get("lp.context")
  213. return cls(key, secret, context)
  214. @classmethod
  215. def from_string(cls, query_string):
  216. """Create and return a new `AccessToken` from the given string."""
  217. if not isinstance(query_string, unicode_type):
  218. query_string = query_string.decode("utf-8")
  219. params = parse_qs(query_string, keep_blank_values=False)
  220. key = params["oauth_token"]
  221. assert len(key) == 1, "Query string must have exactly one oauth_token."
  222. key = key[0]
  223. secret = params["oauth_token_secret"]
  224. assert len(secret) == 1, "Query string must have exactly one secret."
  225. secret = secret[0]
  226. context = params.get("lp.context")
  227. if context is not None:
  228. assert (
  229. len(context) == 1
  230. ), "Query string must have exactly one context"
  231. context = context[0]
  232. return cls(key, secret, context)
  233. class AnonymousAccessToken(_AccessToken):
  234. """An OAuth access token that doesn't authenticate anybody.
  235. This token can be used for anonymous access.
  236. """
  237. def __init__(self):
  238. super(AnonymousAccessToken, self).__init__("", "")
  239. class CredentialStore(object):
  240. """Store OAuth credentials locally.
  241. This is a generic superclass. To implement a specific way of
  242. storing credentials locally you'll need to subclass this class,
  243. and implement `do_save` and `do_load`.
  244. """
  245. def __init__(self, credential_save_failed=None):
  246. """Constructor.
  247. :param credential_save_failed: A callback to be invoked if the
  248. save to local storage fails. You should never invoke this
  249. callback yourself! Instead, you should raise an exception
  250. from do_save().
  251. """
  252. self.credential_save_failed = credential_save_failed
  253. def save(self, credentials, unique_consumer_id):
  254. """Save the credentials and invoke the callback on failure.
  255. Do not override this method when subclassing. Override
  256. do_save() instead.
  257. """
  258. try:
  259. self.do_save(credentials, unique_consumer_id)
  260. except EXPLOSIVE_ERRORS:
  261. raise
  262. except Exception as e:
  263. if self.credential_save_failed is None:
  264. raise e
  265. self.credential_save_failed()
  266. return credentials
  267. def do_save(self, credentials, unique_consumer_id):
  268. """Store newly-authorized credentials locally for later use.
  269. :param credentials: A Credentials object to save.
  270. :param unique_consumer_id: A string uniquely identifying an
  271. OAuth consumer on a Launchpad instance.
  272. """
  273. raise NotImplementedError()
  274. def load(self, unique_key):
  275. """Retrieve credentials from a local store.
  276. This method is the inverse of `save`.
  277. There's no special behavior in this method--it just calls
  278. `do_load`. There _is_ special behavior in `save`, and this
  279. way, developers can remember to implement `do_save` and
  280. `do_load`, not `do_save` and `load`.
  281. :param unique_key: A string uniquely identifying an OAuth consumer
  282. on a Launchpad instance.
  283. :return: A `Credentials` object if one is found in the local
  284. store, and None otherise.
  285. """
  286. return self.do_load(unique_key)
  287. def do_load(self, unique_key):
  288. """Retrieve credentials from a local store.
  289. This method is the inverse of `do_save`.
  290. :param unique_key: A string uniquely identifying an OAuth consumer
  291. on a Launchpad instance.
  292. :return: A `Credentials` object if one is found in the local
  293. store, and None otherise.
  294. """
  295. raise NotImplementedError()
  296. class KeyringCredentialStore(CredentialStore):
  297. """Store credentials in the GNOME keyring or KDE wallet.
  298. This is a good solution for desktop applications and interactive
  299. scripts. It doesn't work for non-interactive scripts, or for
  300. integrating third-party websites into Launchpad.
  301. """
  302. B64MARKER = b"<B64>"
  303. def __init__(self, credential_save_failed=None, fallback=False):
  304. super(KeyringCredentialStore, self).__init__(credential_save_failed)
  305. self._fallback = None
  306. if fallback:
  307. self._fallback = MemoryCredentialStore(credential_save_failed)
  308. @staticmethod
  309. def _ensure_keyring_imported():
  310. """Ensure the keyring module is imported (postponing side effects).
  311. The keyring module initializes the environment-dependent backend at
  312. import time (nasty). We want to avoid that initialization because it
  313. may do things like prompt the user to unlock their password store
  314. (e.g., KWallet).
  315. """
  316. if "keyring" not in globals():
  317. global keyring
  318. import keyring
  319. if "NoKeyringError" not in globals():
  320. global NoKeyringError
  321. try:
  322. from keyring.errors import NoKeyringError
  323. except ImportError:
  324. NoKeyringError = RuntimeError
  325. def do_save(self, credentials, unique_key):
  326. """Store newly-authorized credentials in the keyring."""
  327. self._ensure_keyring_imported()
  328. serialized = credentials.serialize()
  329. # Some users have reported problems with corrupted keyrings, both in
  330. # Gnome and KDE, when newlines are included in the password. Avoid
  331. # this problem by base 64 encoding the serialized value.
  332. serialized = self.B64MARKER + b64encode(serialized)
  333. try:
  334. keyring.set_password(
  335. "launchpadlib", unique_key, serialized.decode("utf-8")
  336. )
  337. except NoKeyringError as e:
  338. # keyring < 21.2.0 raises RuntimeError rather than anything more
  339. # specific. Make sure it's the exception we're interested in.
  340. if (
  341. NoKeyringError == RuntimeError
  342. and "No recommended backend was available" not in str(e)
  343. ):
  344. raise
  345. if self._fallback:
  346. self._fallback.save(credentials, unique_key)
  347. else:
  348. raise
  349. def do_load(self, unique_key):
  350. """Retrieve credentials from the keyring."""
  351. self._ensure_keyring_imported()
  352. try:
  353. credential_string = keyring.get_password(
  354. "launchpadlib", unique_key
  355. )
  356. except NoKeyringError as e:
  357. # keyring < 21.2.0 raises RuntimeError rather than anything more
  358. # specific. Make sure it's the exception we're interested in.
  359. if (
  360. NoKeyringError == RuntimeError
  361. and "No recommended backend was available" not in str(e)
  362. ):
  363. raise
  364. if self._fallback:
  365. return self._fallback.load(unique_key)
  366. else:
  367. raise
  368. if credential_string is not None:
  369. if isinstance(credential_string, unicode_type):
  370. credential_string = credential_string.encode("utf8")
  371. if credential_string.startswith(self.B64MARKER):
  372. try:
  373. credential_string = b64decode(
  374. credential_string[len(self.B64MARKER) :]
  375. )
  376. except TypeError:
  377. # The credential_string should be base 64 but cannot be
  378. # decoded.
  379. return None
  380. try:
  381. credentials = Credentials.from_string(credential_string)
  382. return credentials
  383. except Exception:
  384. # If any error occurs at this point the most reasonable thing
  385. # to do is return no credentials, which will require
  386. # re-authorization but the user will be able to proceed.
  387. return None
  388. return None
  389. class UnencryptedFileCredentialStore(CredentialStore):
  390. """Store credentials unencrypted in a file on disk.
  391. This is a good solution for scripts that need to run without any
  392. user interaction.
  393. """
  394. def __init__(self, filename, credential_save_failed=None):
  395. super(UnencryptedFileCredentialStore, self).__init__(
  396. credential_save_failed
  397. )
  398. self.filename = filename
  399. def do_save(self, credentials, unique_key):
  400. """Save the credentials to disk."""
  401. credentials.save_to_path(self.filename)
  402. def do_load(self, unique_key):
  403. """Load the credentials from disk."""
  404. if (
  405. os.path.exists(self.filename)
  406. and not os.stat(self.filename)[stat.ST_SIZE] == 0
  407. ):
  408. return Credentials.load_from_path(self.filename)
  409. return None
  410. class MemoryCredentialStore(CredentialStore):
  411. """CredentialStore that stores keys only in memory.
  412. This can be used to provide a CredentialStore instance without
  413. actually saving any key to persistent storage.
  414. """
  415. def __init__(self, credential_save_failed=None):
  416. super(MemoryCredentialStore, self).__init__(credential_save_failed)
  417. self._credentials = {}
  418. def do_save(self, credentials, unique_key):
  419. """Store the credentials in our dict"""
  420. self._credentials[unique_key] = credentials
  421. def do_load(self, unique_key):
  422. """Retrieve the credentials from our dict"""
  423. return self._credentials.get(unique_key)
  424. class RequestTokenAuthorizationEngine(object):
  425. """The superclass of all request token authorizers.
  426. This base class does not implement request token authorization,
  427. since that varies depending on how you want the end-user to
  428. authorize a request token. You'll need to subclass this class and
  429. implement `make_end_user_authorize_token`.
  430. """
  431. UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"
  432. def __init__(
  433. self,
  434. service_root,
  435. application_name=None,
  436. consumer_name=None,
  437. allow_access_levels=None,
  438. ):
  439. """Base class initialization.
  440. :param service_root: The root of the Launchpad instance being
  441. used.
  442. :param application_name: The name of the application that
  443. wants to use launchpadlib. This is used in conjunction
  444. with a desktop-wide integration.
  445. If you specify this argument, your values for
  446. consumer_name and allow_access_levels are ignored.
  447. :param consumer_name: The OAuth consumer name, for an
  448. application that wants its own point of integration into
  449. Launchpad. In almost all cases, you want to specify
  450. application_name instead and do a desktop-wide
  451. integration. The exception is when you're integrating a
  452. third-party website into Launchpad.
  453. :param allow_access_levels: A list of the Launchpad access
  454. levels to present to the user. ('READ_PUBLIC' and so on.)
  455. Your value for this argument will be ignored during a
  456. desktop-wide integration.
  457. :type allow_access_levels: A list of strings.
  458. """
  459. self.service_root = uris.lookup_service_root(service_root)
  460. self.web_root = uris.web_root_for_service_root(service_root)
  461. if application_name is None and consumer_name is None:
  462. raise ValueError(
  463. "You must provide either application_name or consumer_name."
  464. )
  465. if application_name is not None and consumer_name is not None:
  466. raise ValueError(
  467. "You must provide only one of application_name and "
  468. "consumer_name. (You provided %r and %r.)"
  469. % (application_name, consumer_name)
  470. )
  471. if consumer_name is None:
  472. # System-wide integration. Create a system-wide consumer
  473. # and identify the application using a separate
  474. # application name.
  475. allow_access_levels = ["DESKTOP_INTEGRATION"]
  476. consumer = SystemWideConsumer(application_name)
  477. else:
  478. # Application-specific integration. Use the provided
  479. # consumer name to create a consumer automatically.
  480. consumer = Consumer(consumer_name)
  481. application_name = consumer_name
  482. self.consumer = consumer
  483. self.application_name = application_name
  484. self.allow_access_levels = allow_access_levels or []
  485. @property
  486. def unique_consumer_id(self):
  487. """Return a string identifying this consumer on this host."""
  488. return self.consumer.key + "@" + self.service_root
  489. def authorization_url(self, request_token):
  490. """Return the authorization URL for a request token.
  491. This is the URL the end-user must visit to authorize the
  492. token. How exactly does this happen? That depends on the
  493. subclass implementation.
  494. """
  495. page = "%s?oauth_token=%s" % (authorize_token_page, request_token)
  496. allow_permission = "&allow_permission="
  497. if len(self.allow_access_levels) > 0:
  498. page += allow_permission + allow_permission.join(
  499. self.allow_access_levels
  500. )
  501. return urljoin(self.web_root, page)
  502. def __call__(self, credentials, credential_store):
  503. """Authorize a token and associate it with the given credentials.
  504. If the credential store runs into a problem storing the
  505. credential locally, the `credential_save_failed` callback will
  506. be invoked. The callback will not be invoked if there's a
  507. problem authorizing the credentials.
  508. :param credentials: A `Credentials` object. If the end-user
  509. authorizes these credentials, this object will have its
  510. .access_token property set.
  511. :param credential_store: A `CredentialStore` object. If the
  512. end-user authorizes the credentials, they will be
  513. persisted locally using this object.
  514. :return: If the credentials are successfully authorized, the
  515. return value is the `Credentials` object originally passed
  516. in. Otherwise the return value is None.
  517. """
  518. request_token_string = self.get_request_token(credentials)
  519. # Hand off control to the end-user.
  520. self.make_end_user_authorize_token(credentials, request_token_string)
  521. if credentials.access_token is None:
  522. # The end-user refused to authorize the application.
  523. return None
  524. # save() invokes the callback on failure.
  525. credential_store.save(credentials, self.unique_consumer_id)
  526. return credentials
  527. def get_request_token(self, credentials):
  528. """Get a new request token from the server.
  529. :param return: The request token.
  530. """
  531. authorization_json = credentials.get_request_token(
  532. web_root=self.web_root, token_format=Credentials.DICT_TOKEN_FORMAT
  533. )
  534. return authorization_json["oauth_token"]
  535. def make_end_user_authorize_token(self, credentials, request_token):
  536. """Authorize the given request token using the given credentials.
  537. Your subclass must implement this method: it has no default
  538. implementation.
  539. Because an access token may expire or be revoked in the middle
  540. of a session, this method may be called at arbitrary points in
  541. a launchpadlib session, or even multiple times during a single
  542. session (with a different request token each time).
  543. In most cases, however, this method will be called at the
  544. beginning of a launchpadlib session, or not at all.
  545. """
  546. raise NotImplementedError()
  547. class AuthorizeRequestTokenWithURL(RequestTokenAuthorizationEngine):
  548. """Authorize using a URL.
  549. This authorizer simply shows the URL for the user to open for
  550. authorization, and waits until the server responds.
  551. """
  552. WAITING_FOR_USER = (
  553. "Please open this authorization page:\n"
  554. " (%s)\n"
  555. "in your browser. Use your browser to authorize\n"
  556. "this program to access Launchpad on your behalf."
  557. )
  558. WAITING_FOR_LAUNCHPAD = "Press Enter after authorizing in your browser."
  559. def output(self, message):
  560. """Display a message.
  561. By default, prints the message to standard output. The message
  562. does not require any user interaction--it's solely
  563. informative.
  564. """
  565. print(message)
  566. def notify_end_user_authorization_url(self, authorization_url):
  567. """Notify the end-user of the URL."""
  568. self.output(self.WAITING_FOR_USER % authorization_url)
  569. def check_end_user_authorization(self, credentials):
  570. """Check if the end-user authorized"""
  571. try:
  572. credentials.exchange_request_token_for_access_token(self.web_root)
  573. except HTTPError as e:
  574. if e.response.status == 403:
  575. # The user decided not to authorize this
  576. # application.
  577. raise EndUserDeclinedAuthorization(e.content)
  578. else:
  579. if e.response.status != 401:
  580. # There was an error accessing the server.
  581. print("Unexpected response from Launchpad:")
  582. print(e)
  583. # The user has not made a decision yet.
  584. raise EndUserNoAuthorization(e.content)
  585. return credentials.access_token is not None
  586. def wait_for_end_user_authorization(self, credentials):
  587. """Wait for the end-user to authorize"""
  588. self.output(self.WAITING_FOR_LAUNCHPAD)
  589. stdin.readline()
  590. self.check_end_user_authorization(credentials)
  591. def make_end_user_authorize_token(self, credentials, request_token):
  592. """Have the end-user authorize the token using a URL."""
  593. authorization_url = self.authorization_url(request_token)
  594. self.notify_end_user_authorization_url(authorization_url)
  595. self.wait_for_end_user_authorization(credentials)
  596. class AuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithURL):
  597. """Authorize using a URL that pops-up automatically in a browser.
  598. This authorizer simply opens up the end-user's web browser to a
  599. Launchpad URL and lets the end-user authorize the request token
  600. themselves.
  601. This is the same as its superclass, except this class also
  602. performs the browser automatic opening of the URL.
  603. """
  604. WAITING_FOR_USER = (
  605. "The authorization page:\n"
  606. " (%s)\n"
  607. "should be opening in your browser. Use your browser to authorize\n"
  608. "this program to access Launchpad on your behalf."
  609. )
  610. TIMEOUT_MESSAGE = "Press Enter to continue or wait (%d) seconds..."
  611. TIMEOUT = 5
  612. TERMINAL_BROWSERS = (
  613. "www-browser",
  614. "links",
  615. "links2",
  616. "lynx",
  617. "elinks",
  618. "elinks-lite",
  619. "netrik",
  620. "w3m",
  621. )
  622. WAITING_FOR_LAUNCHPAD = (
  623. "Waiting to hear from Launchpad about your decision..."
  624. )
  625. def __init__(
  626. self,
  627. service_root,
  628. application_name,
  629. consumer_name=None,
  630. credential_save_failed=None,
  631. allow_access_levels=None,
  632. ):
  633. """Constructor.
  634. :param service_root: See `RequestTokenAuthorizationEngine`.
  635. :param application_name: See `RequestTokenAuthorizationEngine`.
  636. :param consumer_name: The value of this argument is
  637. ignored. If we have the capability to open the end-user's
  638. web browser, we must be running on the end-user's computer,
  639. so we should do a full desktop integration.
  640. :param credential_save_failed: See `RequestTokenAuthorizationEngine`.
  641. :param allow_access_levels: The value of this argument is
  642. ignored, for the same reason as consumer_name.
  643. """
  644. # It doesn't look like we're doing anything here, but we
  645. # are discarding the passed-in values for consumer_name and
  646. # allow_access_levels.
  647. super(AuthorizeRequestTokenWithBrowser, self).__init__(
  648. service_root, application_name, None, credential_save_failed
  649. )
  650. def notify_end_user_authorization_url(self, authorization_url):
  651. """Notify the end-user of the URL."""
  652. super(
  653. AuthorizeRequestTokenWithBrowser, self
  654. ).notify_end_user_authorization_url(authorization_url)
  655. try:
  656. browser_obj = webbrowser.get()
  657. browser = getattr(browser_obj, "basename", None)
  658. console_browser = browser in self.TERMINAL_BROWSERS
  659. except webbrowser.Error:
  660. browser_obj = None
  661. console_browser = False
  662. if console_browser:
  663. self.output(self.TIMEOUT_MESSAGE % self.TIMEOUT)
  664. # Wait a little time before attempting to launch browser,
  665. # give users the chance to press a key to skip it anyway.
  666. rlist, _, _ = select([stdin], [], [], self.TIMEOUT)
  667. if rlist:
  668. stdin.readline()
  669. if browser_obj is not None:
  670. webbrowser.open(authorization_url)
  671. def wait_for_end_user_authorization(self, credentials):
  672. """Wait for the end-user to authorize"""
  673. self.output(self.WAITING_FOR_LAUNCHPAD)
  674. start_time = time.time()
  675. while credentials.access_token is None:
  676. time.sleep(access_token_poll_time)
  677. try:
  678. if self.check_end_user_authorization(credentials):
  679. break
  680. except EndUserNoAuthorization:
  681. pass
  682. if time.time() >= start_time + access_token_poll_timeout:
  683. raise TokenAuthorizationTimedOut(
  684. "Timed out after %d seconds." % access_token_poll_timeout
  685. )
  686. class TokenAuthorizationException(Exception):
  687. pass
  688. class RequestTokenAlreadyAuthorized(TokenAuthorizationException):
  689. pass
  690. class EndUserAuthorizationFailed(TokenAuthorizationException):
  691. """Superclass exception for all failures of end-user authorization"""
  692. pass
  693. class EndUserDeclinedAuthorization(EndUserAuthorizationFailed):
  694. """End-user declined authorization"""
  695. pass
  696. class EndUserNoAuthorization(EndUserAuthorizationFailed):
  697. """End-user did not perform any authorization"""
  698. pass
  699. class TokenAuthorizationTimedOut(EndUserNoAuthorization):
  700. """End-user did not perform any authorization in timeout period"""
  701. pass
  702. class ClientError(TokenAuthorizationException):
  703. pass
  704. class ServerError(TokenAuthorizationException):
  705. pass
  706. class NoLaunchpadAccount(TokenAuthorizationException):
  707. pass
  708. class TooManyAuthenticationFailures(TokenAuthorizationException):
  709. pass