oauth.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. # Copyright 2009-2018 Canonical Ltd.
  2. # This file is part of lazr.restfulclient.
  3. #
  4. # lazr.restfulclient is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Lesser General Public License as
  6. # published by the Free Software Foundation, either version 3 of the
  7. # License, or (at your option) any later version.
  8. #
  9. # lazr.restfulclient is distributed in the hope that it will be useful, but
  10. # WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  12. # Lesser General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Lesser General Public
  15. # License along with lazr.restfulclient. If not, see
  16. # <http://www.gnu.org/licenses/>.
  17. """OAuth classes for use with lazr.restfulclient."""
  18. try:
  19. # Python 3, SafeConfigParser was renamed to just ConfigParser.
  20. from configparser import ConfigParser as SafeConfigParser
  21. except ImportError:
  22. from ConfigParser import SafeConfigParser
  23. import os
  24. import platform
  25. import socket
  26. import stat
  27. import six
  28. from oauthlib import oauth1
  29. from six.moves.urllib.parse import parse_qs, urlencode
  30. from lazr.restfulclient.authorize import HttpAuthorizer
  31. from lazr.restfulclient.errors import CredentialsFileError
  32. __metaclass__ = type
  33. __all__ = [
  34. "AccessToken",
  35. "Consumer",
  36. "OAuthAuthorizer",
  37. "SystemWideConsumer",
  38. ]
  39. CREDENTIALS_FILE_VERSION = "1"
  40. # For compatibility, Consumer and AccessToken are defined using terminology
  41. # from the older oauth library rather than the newer oauthlib.
  42. class Consumer:
  43. """An OAuth consumer (application)."""
  44. def __init__(self, key, secret="", application_name=None):
  45. """Initialize
  46. :param key: The OAuth consumer key
  47. :param secret: The OAuth consumer secret. Don't use this. It's
  48. a misfeature, and lazr.restful doesn't expect it.
  49. :param application_name: An application name, if different
  50. from the consumer key. If present, this will be used in
  51. the User-Agent header.
  52. """
  53. self.key = key
  54. self.secret = secret
  55. self.application_name = application_name
  56. class AccessToken:
  57. """An OAuth access token."""
  58. def __init__(self, key, secret="", context=None):
  59. self.key = key
  60. self.secret = secret
  61. self.context = context
  62. def to_string(self):
  63. return urlencode(
  64. [
  65. ("oauth_token_secret", self.secret),
  66. ("oauth_token", self.key),
  67. ]
  68. )
  69. __str__ = to_string
  70. @classmethod
  71. def from_string(cls, s):
  72. params = parse_qs(s, keep_blank_values=False)
  73. key = params["oauth_token"][0]
  74. secret = params["oauth_token_secret"][0]
  75. return cls(key, secret)
  76. class TruthyString(six.text_type):
  77. """A Unicode string which is always true."""
  78. def __bool__(self):
  79. return True
  80. __nonzero__ = __bool__
  81. class SystemWideConsumer(Consumer):
  82. """A consumer associated with the logged-in user rather than an app.
  83. This can be used to share a single OAuth token among multiple
  84. desktop applications. The OAuth consumer key will be derived from
  85. system information (platform and hostname).
  86. """
  87. KEY_FORMAT = "System-wide: %s (%s)"
  88. def __init__(self, application_name, secret=""):
  89. """Constructor.
  90. :param application_name: An application name. This will be
  91. used in the User-Agent header.
  92. :param secret: The OAuth consumer secret. Don't use this. It's
  93. a misfeature, and lazr.restful doesn't expect it.
  94. """
  95. super(SystemWideConsumer, self).__init__(
  96. self.consumer_key, secret, application_name
  97. )
  98. @property
  99. def consumer_key(self):
  100. """The system-wide OAuth consumer key for this computer.
  101. This key identifies the platform and the computer's
  102. hostname. It does not identify the active user.
  103. """
  104. try:
  105. import distro
  106. distname = distro.name()
  107. except Exception:
  108. # This can happen due to various kinds of failures with the data
  109. # sources used by the distro module.
  110. distname = ""
  111. if distname == "":
  112. distname = platform.system() # (eg. "Windows")
  113. return self.KEY_FORMAT % (distname, socket.gethostname())
  114. class OAuthAuthorizer(HttpAuthorizer):
  115. """A client that signs every outgoing request with OAuth credentials."""
  116. def __init__(
  117. self,
  118. consumer_name=None,
  119. consumer_secret="",
  120. access_token=None,
  121. oauth_realm="OAuth",
  122. application_name=None,
  123. ):
  124. self.consumer = None
  125. if consumer_name is not None:
  126. self.consumer = Consumer(
  127. consumer_name, consumer_secret, application_name
  128. )
  129. self.access_token = access_token
  130. self.oauth_realm = oauth_realm
  131. @property
  132. def user_agent_params(self):
  133. """Any information necessary to identify this user agent.
  134. In this case, the OAuth consumer name.
  135. """
  136. params = {}
  137. if self.consumer is None:
  138. return params
  139. params["oauth_consumer"] = self.consumer.key
  140. if self.consumer.application_name is not None:
  141. params["application"] = self.consumer.application_name
  142. return params
  143. def load(self, readable_file):
  144. """Load credentials from a file-like object.
  145. This overrides the consumer and access token given in the constructor
  146. and replaces them with the values read from the file.
  147. :param readable_file: A file-like object to read the credentials from
  148. :type readable_file: Any object supporting the file-like `read()`
  149. method
  150. """
  151. # Attempt to load the access token from the file.
  152. parser = SafeConfigParser()
  153. reader = getattr(parser, "read_file", parser.readfp)
  154. reader(readable_file)
  155. # Check the version number and extract the access token and
  156. # secret. Then convert these to the appropriate instances.
  157. if not parser.has_section(CREDENTIALS_FILE_VERSION):
  158. raise CredentialsFileError(
  159. "No configuration for version %s" % CREDENTIALS_FILE_VERSION
  160. )
  161. consumer_key = parser.get(CREDENTIALS_FILE_VERSION, "consumer_key")
  162. consumer_secret = parser.get(
  163. CREDENTIALS_FILE_VERSION, "consumer_secret"
  164. )
  165. self.consumer = Consumer(consumer_key, consumer_secret)
  166. access_token = parser.get(CREDENTIALS_FILE_VERSION, "access_token")
  167. access_secret = parser.get(CREDENTIALS_FILE_VERSION, "access_secret")
  168. self.access_token = AccessToken(access_token, access_secret)
  169. @classmethod
  170. def load_from_path(cls, path):
  171. """Convenience method for loading credentials from a file.
  172. Open the file, create the Credentials and load from the file,
  173. and finally close the file and return the newly created
  174. Credentials instance.
  175. :param path: In which file the credential file should be saved.
  176. :type path: string
  177. :return: The loaded Credentials instance.
  178. :rtype: `Credentials`
  179. """
  180. credentials = cls()
  181. credentials_file = open(path, "r")
  182. credentials.load(credentials_file)
  183. credentials_file.close()
  184. return credentials
  185. def save(self, writable_file):
  186. """Write the credentials to the file-like object.
  187. :param writable_file: A file-like object to write the credentials to
  188. :type writable_file: Any object supporting the file-like `write()`
  189. method
  190. :raise CredentialsFileError: when there is either no consumer or no
  191. access token
  192. """
  193. if self.consumer is None:
  194. raise CredentialsFileError("No consumer")
  195. if self.access_token is None:
  196. raise CredentialsFileError("No access token")
  197. parser = SafeConfigParser()
  198. parser.add_section(CREDENTIALS_FILE_VERSION)
  199. parser.set(CREDENTIALS_FILE_VERSION, "consumer_key", self.consumer.key)
  200. parser.set(
  201. CREDENTIALS_FILE_VERSION, "consumer_secret", self.consumer.secret
  202. )
  203. parser.set(
  204. CREDENTIALS_FILE_VERSION, "access_token", self.access_token.key
  205. )
  206. parser.set(
  207. CREDENTIALS_FILE_VERSION, "access_secret", self.access_token.secret
  208. )
  209. parser.write(writable_file)
  210. def save_to_path(self, path):
  211. """Convenience method for saving credentials to a file.
  212. Create the file, call self.save(), and close the
  213. file. Existing files are overwritten. The resulting file will
  214. be readable and writable only by the user.
  215. :param path: In which file the credential file should be saved.
  216. :type path: string
  217. """
  218. credentials_file = os.fdopen(
  219. os.open(
  220. path,
  221. (os.O_CREAT | os.O_TRUNC | os.O_WRONLY),
  222. (stat.S_IREAD | stat.S_IWRITE),
  223. ),
  224. "w",
  225. )
  226. self.save(credentials_file)
  227. credentials_file.close()
  228. def authorizeRequest(self, absolute_uri, method, body, headers):
  229. """Sign a request with OAuth credentials."""
  230. client = oauth1.Client(
  231. self.consumer.key,
  232. client_secret=self.consumer.secret,
  233. resource_owner_key=TruthyString(self.access_token.key or ""),
  234. resource_owner_secret=self.access_token.secret,
  235. signature_method=oauth1.SIGNATURE_PLAINTEXT,
  236. realm=self.oauth_realm,
  237. )
  238. # The older oauth library (which may still be used on the server)
  239. # requires the oauth_token parameter to be present and will fail
  240. # authentication if it isn't. This hack forces it to be present
  241. # even if its value is the empty string.
  242. client.resource_owner_key = TruthyString(client.resource_owner_key)
  243. _, signed_headers, _ = client.sign(absolute_uri)
  244. for key, value in signed_headers.items():
  245. # client.sign returns Unicode headers; convert these to native
  246. # strings.
  247. if six.PY2:
  248. key = key.encode("UTF-8")
  249. value = value.encode("UTF-8")
  250. headers[key] = value