dialback.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. # This file is part of python-dialback.
  2. #
  3. # python-dialback is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU General Public License as published by
  5. # the Free Software Foundation, either version 3 of the License, or
  6. # (at your option) any later version.
  7. #
  8. # python-dialback is distributed in the hope that it will be useful,
  9. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. # GNU General Public License for more details.
  12. #
  13. # You should have received a copy of the GNU General Public License
  14. # along with python-dialback. If not, see <http://www.gnu.org/licenses/>.
  15. import os
  16. import requests
  17. import datetime
  18. import locale
  19. from dateutil import parser
  20. from dateutil.tz import tzutc
  21. from six.moves.urllib import parse
  22. from .exceptions import DialbackExcpetion, DialbackValidationError
  23. CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"
  24. class DialbackRequest(object):
  25. def __init__(self, headers, url=None, body=None):
  26. self.url = url
  27. self.headers = headers
  28. self.body = body
  29. # Transform the headers to be case insensative - section 4.2 of HTTP 1.1
  30. # states they are, we don't want any mixups.
  31. self.headers = dict(((k.lower(), v) for k, v in self.headers.items()))
  32. # If there is a body parse and extract it.
  33. if self.body is not None:
  34. # decode the body.
  35. self.body = parse.parse_qs(self.body)
  36. def validate_headers(self):
  37. # Check the content type exists (section 4 specifies they MUST)
  38. if "content-type" not in self.headers:
  39. raise DialbackValidationError(
  40. error="Must specify a Content-Type",
  41. section=4
  42. )
  43. # Check the content type is correct
  44. if self.headers["content-type"] != CONTENT_TYPE_FORM_URLENCODED:
  45. err = "Must have the Content-type " + CONTENT_TYPE_FORM_URLENCODED
  46. raise DialbackValidationError(
  47. error=err,
  48. section=4
  49. )
  50. if "date" in self.headers:
  51. self.date = self.headers["date"]
  52. # If there is an Authorization header parse it.
  53. if "authorization" in self.headers:
  54. authorization = self.headers["authorization"][9:].split(", ")
  55. authorization = [bit.split("=") for bit in authorization]
  56. authorization = dict(authorization)
  57. if "token" in authorization:
  58. self.token = authorization["token"][1:-1]
  59. if "webfinger" in authorization:
  60. self.id = authorization["webfinger"][1:-1]
  61. elif "host" in authorization:
  62. self.id = authorization["host"][1:-1]
  63. return True
  64. class DialbackEndpoint(object):
  65. """ Provides a mechanism to validate a dialback request.
  66. You should subclass this and override `validate_unique` to ensure you've not
  67. seen this request before. If you have seen the request before you MUST raise
  68. a DialbackException.
  69. Once you have subclassed this you should instantiate it and provide it with
  70. a dialback request to validate, this request should be an instance of
  71. DialbackRequest.
  72. Example:
  73. >>> dialback_endpoint = MyDialbackEndpoint()
  74. >>> dialback_request = DialbackRequest(
  75. headers=request.headers,
  76. body=request.body
  77. )
  78. >>> if dialback_endpoint.validate_request(dialback_request):
  79. return Response(status_code=200)
  80. else:
  81. return Response(status_code=401)
  82. """
  83. def __init__(self, date_boundry=300):
  84. self.date_boundry = datetime.timedelta(seconds=date_boundry)
  85. def validate_token(self, id, token, date, url):
  86. """ Takes in a DialbackRequest and verifies with the server it's valid.
  87. This quries the server for to verify the token we've been provided is
  88. correct. The server needs to return 200 or 204 if the token is valid,
  89. any other status code is invalid.
  90. """
  91. if id.startswith("http"):
  92. # Host
  93. host = parse.urlparse(id)
  94. hostname = "{scheme}://{hostname}".format(host.scheme, host.netloc)
  95. else:
  96. # Webfinger
  97. username, hostname = id.split("@", 1)
  98. # Put http on the hostname
  99. hostname = "http://{hostname}".format(hostname=hostname)
  100. # Construct the host-meta lookup URL
  101. host_meta_url = "/".join([hostname, ".well-known", "host-meta"])
  102. # Lookup the endpoints.
  103. host_meta = requests.get(host_meta_url)
  104. if host_meta.status_code != 200:
  105. raise DialbackExcpetion("Can not fetch host-meta")
  106. # Parse the result to get the URLs
  107. host_meta = host_meta.json()
  108. # Pull out the dialback lookup endpoint.
  109. dialback_lookup = [
  110. link["href"] for link in host_meta["links"]
  111. if "rel" in link and link["rel"] == "dialback"
  112. ]
  113. if len(dialback_lookup) <= 0:
  114. raise DialbackExcpetion("Dialback endpoint not found in host-meta")
  115. else:
  116. dialback_lookup = dialback_lookup[0]
  117. # Create context to verify.
  118. context = {
  119. "token": token,
  120. "url": url,
  121. "date": date
  122. }
  123. if id.startswith("http"):
  124. context["host"] = id
  125. else:
  126. context["webfinger"] = id
  127. response = requests.post(
  128. dialback_lookup,
  129. data=parse.urlencode(context),
  130. headers={"Content-Type": CONTENT_TYPE_FORM_URLENCODED}
  131. )
  132. if response.status_code not in [200, 204]:
  133. raise DialbackValidationError(
  134. error="Server did not verify the token",
  135. section=(5.1, 5.2)
  136. )
  137. return True
  138. def validate_unique(self, id, token, date, url):
  139. """ Take in a tuple and verify it's unique (i.e. not been seen before)
  140. This takes in these arguments:
  141. id: The host or webfinger
  142. url: The URL the original request was made to
  143. token: The token provided in the original Authorization header
  144. date: The Date header on the original request
  145. The server must verify that it has not seen these four values together
  146. ergo they are a unique tuple. You should return True if it validates
  147. correctly and False if the tuple is not unique.
  148. """
  149. raise NotImplementedError("Need to implement this yourself")
  150. def validate_date(self, date):
  151. """ Takes in the date and checks it's in the specified window
  152. This takes the date and validates that it's within the specified window.
  153. This defaults to 5 minutes (the time specified in the specification in
  154. 5.1 and 5.2). The method will return True if the request is within that
  155. time and raise DialbackValidationError if not.
  156. """
  157. now = datetime.datetime.now(tzutc())
  158. future_limit = now + self.date_boundry
  159. past_limit = now - self.date_boundry
  160. # Parse the date to a datetime instance
  161. date = parser.parse(date)
  162. # Check that it's within the window in the future
  163. if date > future_limit:
  164. raise DialbackValidationError(
  165. error="Date specified is past the accepted date window",
  166. section=(5.1, 5.2)
  167. )
  168. # Check that it's within the window for the past
  169. if date < past_limit:
  170. raise DialbackValidationError(
  171. error="Date specified is before the accepted date window",
  172. section=(5.1, 5.2)
  173. )
  174. # Looks like it's valid
  175. return True
  176. def validate_request(self, request):
  177. """ Validates that the request is valid """
  178. if not isinstance(request, DialbackRequest):
  179. raise DialbackExcpetion("Must be a DialbackRequest instance")
  180. # Check the request
  181. request.validate_headers()
  182. # Verify the date is in range
  183. self.validate_date(request.date)
  184. # Verify the request is unique to avoid reply attacks
  185. self.validate_unique(
  186. request.id,
  187. request.token,
  188. request.date,
  189. request.url
  190. )
  191. # Verify the token is correct
  192. self.validate_token(
  193. request.id,
  194. request.token,
  195. request.date,
  196. request.url
  197. )
  198. # Seems everything past.
  199. return True
  200. class DialbackAuth(requests.auth.AuthBase):
  201. """ Authorization client for python's request library.
  202. This provides an easy to use client that can be used with the request
  203. library used in python. It will generate the authorization header, to use
  204. this you must also provide the dialback authorization endpoint that the
  205. server will call back to to verify this request.
  206. To use this you should instantiate the DialbackAuth class with the webfinger
  207. or host, but not both or neither. You then should get the token to the HTTP
  208. authorization server where it can verify the token is indeed a valid token.
  209. You then just pass this in the "auth" parameter to the request function.
  210. Example:
  211. >>> dialback_auth = DialbackAuth(webfinger="tsyesika@io.tsyesika.se")
  212. >>> # Save the dialback.token somewhere accessable to auth server.
  213. >>> response = requests.post(
  214. "https://io.tsyesika.se/api/client/register",
  215. auth=dialback_auth,
  216. json=json_payload
  217. )
  218. >>>
  219. This would then return with either a 200 OK to tell me the request was
  220. successful or 4xx for confirmation failure or 5xx for a server failure.
  221. It would be prudent to check the status code and handling the error if one
  222. exists.
  223. NB: It is important to note that tokens must be cryptographically generated
  224. this process occurs using the `random_token` method on instantiation to
  225. store the token in the `token` attribute. You should not change this
  226. token without good reason and understanding of consiquence. The token
  227. also should not be reused.
  228. """
  229. def __init__(self, webfinger=None, host=None):
  230. self.webfinger = webfinger
  231. self.host = host
  232. self.token = self.random_token()
  233. if (webfinger and host) or (not (webfinger or host)):
  234. raise DialbackValidationError(
  235. error='MUST include exactly one of "host" or "webfinger"',
  236. section=4
  237. )
  238. def __call__(self, request):
  239. """ Adds dialback authorization to python's request library request """
  240. # Create the headers
  241. headers = {
  242. "Authorization": "Dialback ",
  243. "Content-Type": CONTENT_TYPE_FORM_URLENCODED
  244. }
  245. headers.update(request.headers)
  246. parameters = {}
  247. # Add the webfinger or host
  248. if self.webfinger:
  249. parameters["webfinger"] = '"{0}"'.format(self.webfinger)
  250. elif self.host:
  251. parameters["host"] = '"{0}"'.format(self.host)
  252. # Add a cryptographic token
  253. parameters["token"] = '"{0}"'.format(self.token)
  254. # Convert the header to the key=value, ... format
  255. headers["Authorization"] += self.dialback_encode(parameters)
  256. # Add the current date
  257. headers["Date"] = self.http_datetime()
  258. # Return constructed Authorization header
  259. request.prepare_headers(headers)
  260. return request
  261. def dialback_encode(self, dictionary):
  262. """ Takes a dictionary and encodes it to www-form like encoded form
  263. This takes in a dictionary which it will then encode to a string that
  264. is formatted as "key=value" where it is seperated by a ", ". This is
  265. used in the Authentication header of the dialback authentication
  266. protocol.
  267. """
  268. return ", ".join(["{0}={1}".format(k, v) for k,v in dictionary.items()])
  269. def random_token(self, length=8):
  270. """ Produces a cryptographically secure random base64 encoded string """
  271. return os.urandom(length).encode("base64")[:-2]
  272. def http_datetime(self):
  273. """ Produces a HTTP timestamp formatted as RFC7231
  274. This will return a string value for the current representation of the
  275. date in accordance with what is defined in HTTP Semantics and
  276. Content[RFC7231]. This sets the local as en_US as python by default will
  277. produce the datetime with host machine's locale's translation.
  278. [RFC7231] https://tools.ietf.org/html/rfc7231
  279. """
  280. # Get the current time in UTC
  281. now = datetime.datetime.utcnow()
  282. # Set the local the en_US which HTTP headers expect it in.
  283. locale.setlocale(locale.LC_TIME, 'en_US')
  284. return now.strftime('%a, %d %b %Y %H:%M:%S UTC')