hawkclient.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. /*
  6. * HAWK is an HTTP authentication scheme using a message authentication code
  7. * (MAC) algorithm to provide partial HTTP request cryptographic verification.
  8. *
  9. * For details, see: https://github.com/hueniverse/hawk
  10. *
  11. * With HAWK, it is essential that the clocks on clients and server not have an
  12. * absolute delta of greater than one minute, as the HAWK protocol uses
  13. * timestamps to reduce the possibility of replay attacks. However, it is
  14. * likely that some clients' clocks will be more than a little off, especially
  15. * in mobile devices, which would break HAWK-based services (like sync and
  16. * firefox accounts) for those clients.
  17. *
  18. * This library provides a stateful HAWK client that calculates (roughly) the
  19. * clock delta on the client vs the server. The library provides an interface
  20. * for deriving HAWK credentials and making HAWK-authenticated REST requests to
  21. * a single remote server. Therefore, callers who want to interact with
  22. * multiple HAWK services should instantiate one HawkClient per service.
  23. */
  24. this.EXPORTED_SYMBOLS = ["HawkClient"];
  25. var {interfaces: Ci, utils: Cu} = Components;
  26. Cu.import("resource://services-crypto/utils.js");
  27. Cu.import("resource://services-common/hawkrequest.js");
  28. Cu.import("resource://services-common/observers.js");
  29. Cu.import("resource://gre/modules/Promise.jsm");
  30. Cu.import("resource://gre/modules/Log.jsm");
  31. Cu.import("resource://gre/modules/XPCOMUtils.jsm");
  32. Cu.import("resource://gre/modules/Services.jsm");
  33. // log.appender.dump should be one of "Fatal", "Error", "Warn", "Info", "Config",
  34. // "Debug", "Trace" or "All". If none is specified, "Error" will be used by
  35. // default.
  36. // Note however that Sync will also add this log to *its* DumpAppender, so
  37. // in a Sync context it shouldn't be necessary to adjust this - however, that
  38. // also means error logs are likely to be dump'd twice but that's OK.
  39. const PREF_LOG_LEVEL = "services.common.hawk.log.appender.dump";
  40. // A pref that can be set so "sensitive" information (eg, personally
  41. // identifiable info, credentials, etc) will be logged.
  42. const PREF_LOG_SENSITIVE_DETAILS = "services.common.hawk.log.sensitive";
  43. XPCOMUtils.defineLazyGetter(this, "log", function() {
  44. let log = Log.repository.getLogger("Hawk");
  45. // We set the log itself to "debug" and set the level from the preference to
  46. // the appender. This allows other things to send the logs to different
  47. // appenders, while still allowing the pref to control what is seen via dump()
  48. log.level = Log.Level.Debug;
  49. let appender = new Log.DumpAppender();
  50. log.addAppender(appender);
  51. appender.level = Log.Level.Error;
  52. try {
  53. let level =
  54. Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING
  55. && Services.prefs.getCharPref(PREF_LOG_LEVEL);
  56. appender.level = Log.Level[level] || Log.Level.Error;
  57. } catch (e) {
  58. log.error(e);
  59. }
  60. return log;
  61. });
  62. // A boolean to indicate if personally identifiable information (or anything
  63. // else sensitive, such as credentials) should be logged.
  64. XPCOMUtils.defineLazyGetter(this, 'logPII', function() {
  65. try {
  66. return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS);
  67. } catch (_) {
  68. return false;
  69. }
  70. });
  71. /*
  72. * A general purpose client for making HAWK authenticated requests to a single
  73. * host. Keeps track of the clock offset between the client and the host for
  74. * computation of the timestamp in the HAWK Authorization header.
  75. *
  76. * Clients should create one HawkClient object per each server they wish to
  77. * interact with.
  78. *
  79. * @param host
  80. * The url of the host
  81. */
  82. this.HawkClient = function(host) {
  83. this.host = host;
  84. // Clock offset in milliseconds between our client's clock and the date
  85. // reported in responses from our host.
  86. this._localtimeOffsetMsec = 0;
  87. }
  88. this.HawkClient.prototype = {
  89. /*
  90. * A boolean for feature detection.
  91. */
  92. willUTF8EncodeRequests: HAWKAuthenticatedRESTRequest.prototype.willUTF8EncodeObjectRequests,
  93. /*
  94. * Construct an error message for a response. Private.
  95. *
  96. * @param restResponse
  97. * A RESTResponse object from a RESTRequest
  98. *
  99. * @param error
  100. * A string or object describing the error
  101. */
  102. _constructError: function(restResponse, error) {
  103. let errorObj = {
  104. error: error,
  105. // This object is likely to be JSON.stringify'd, but neither Error()
  106. // objects nor Components.Exception objects do the right thing there,
  107. // so we add a new element which is simply the .toString() version of
  108. // the error object, so it does appear in JSON'd values.
  109. errorString: error.toString(),
  110. message: restResponse.statusText,
  111. code: restResponse.status,
  112. errno: restResponse.status,
  113. toString() {
  114. return this.code + ": " + this.message;
  115. },
  116. };
  117. let retryAfter = restResponse.headers && restResponse.headers["retry-after"];
  118. retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
  119. if (retryAfter) {
  120. errorObj.retryAfter = retryAfter;
  121. // and notify observers of the retry interval
  122. if (this.observerPrefix) {
  123. Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
  124. }
  125. }
  126. return errorObj;
  127. },
  128. /*
  129. *
  130. * Update clock offset by determining difference from date gives in the (RFC
  131. * 1123) Date header of a server response. Because HAWK tolerates a window
  132. * of one minute of clock skew (so two minutes total since the skew can be
  133. * positive or negative), the simple method of calculating offset here is
  134. * probably good enough. We keep the value in milliseconds to make life
  135. * easier, even though the value will not have millisecond accuracy.
  136. *
  137. * @param dateString
  138. * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
  139. *
  140. * For HAWK clock skew and replay protection, see
  141. * https://github.com/hueniverse/hawk#replay-protection
  142. */
  143. _updateClockOffset: function(dateString) {
  144. try {
  145. let serverDateMsec = Date.parse(dateString);
  146. this._localtimeOffsetMsec = serverDateMsec - this.now();
  147. log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec);
  148. } catch(err) {
  149. log.warn("Bad date header in server response: " + dateString);
  150. }
  151. },
  152. /*
  153. * Get the current clock offset in milliseconds.
  154. *
  155. * The offset is the number of milliseconds that must be added to the client
  156. * clock to make it equal to the server clock. For example, if the client is
  157. * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
  158. */
  159. get localtimeOffsetMsec() {
  160. return this._localtimeOffsetMsec;
  161. },
  162. /*
  163. * return current time in milliseconds
  164. */
  165. now: function() {
  166. return Date.now();
  167. },
  168. /* A general method for sending raw RESTRequest calls authorized using HAWK
  169. *
  170. * @param path
  171. * API endpoint path
  172. * @param method
  173. * The HTTP request method
  174. * @param credentials
  175. * Hawk credentials
  176. * @param payloadObj
  177. * An object that can be encodable as JSON as the payload of the
  178. * request
  179. * @param extraHeaders
  180. * An object with header/value pairs to send with the request.
  181. * @return Promise
  182. * Returns a promise that resolves to the response of the API call,
  183. * or is rejected with an error. If the server response can be parsed
  184. * as JSON and contains an 'error' property, the promise will be
  185. * rejected with this JSON-parsed response.
  186. */
  187. request: function(path, method, credentials=null, payloadObj={}, extraHeaders = {},
  188. retryOK=true) {
  189. method = method.toLowerCase();
  190. let deferred = Promise.defer();
  191. let uri = this.host + path;
  192. let self = this;
  193. function _onComplete(error) {
  194. // |error| can be either a normal caught error or an explicitly created
  195. // Components.Exception() error. Log it now as it might not end up
  196. // correctly in the logs by the time it's passed through _constructError.
  197. if (error) {
  198. log.warn("hawk request error", error);
  199. }
  200. // If there's no response there's nothing else to do.
  201. if (!this.response) {
  202. deferred.reject(error);
  203. return;
  204. }
  205. let restResponse = this.response;
  206. let status = restResponse.status;
  207. log.debug("(Response) " + path + ": code: " + status +
  208. " - Status text: " + restResponse.statusText);
  209. if (logPII) {
  210. log.debug("Response text: " + restResponse.body);
  211. }
  212. // All responses may have backoff headers, which are a server-side safety
  213. // valve to allow slowing down clients without hurting performance.
  214. self._maybeNotifyBackoff(restResponse, "x-weave-backoff");
  215. self._maybeNotifyBackoff(restResponse, "x-backoff");
  216. if (error) {
  217. // When things really blow up, reconstruct an error object that follows
  218. // the general format of the server on error responses.
  219. return deferred.reject(self._constructError(restResponse, error));
  220. }
  221. self._updateClockOffset(restResponse.headers["date"]);
  222. if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) {
  223. // Retry once if we were rejected due to a bad timestamp.
  224. // Clock offset is adjusted already in the top of this function.
  225. log.debug("Received 401 for " + path + ": retrying");
  226. return deferred.resolve(
  227. self.request(path, method, credentials, payloadObj, extraHeaders, false));
  228. }
  229. // If the server returned a json error message, use it in the rejection
  230. // of the promise.
  231. //
  232. // In the case of a 401, in which we are probably being rejected for a
  233. // bad timestamp, retry exactly once, during which time clock offset will
  234. // be adjusted.
  235. let jsonResponse = {};
  236. try {
  237. jsonResponse = JSON.parse(restResponse.body);
  238. } catch(notJSON) {}
  239. let okResponse = (200 <= status && status < 300);
  240. if (!okResponse || jsonResponse.error) {
  241. if (jsonResponse.error) {
  242. return deferred.reject(jsonResponse);
  243. }
  244. return deferred.reject(self._constructError(restResponse, "Request failed"));
  245. }
  246. // It's up to the caller to know how to decode the response.
  247. // We just return the whole response.
  248. deferred.resolve(this.response);
  249. };
  250. function onComplete(error) {
  251. try {
  252. // |this| is the RESTRequest object and we need to ensure _onComplete
  253. // gets the same one.
  254. _onComplete.call(this, error);
  255. } catch (ex) {
  256. log.error("Unhandled exception processing response", ex);
  257. deferred.reject(ex);
  258. }
  259. }
  260. let extra = {
  261. now: this.now(),
  262. localtimeOffsetMsec: this.localtimeOffsetMsec,
  263. headers: extraHeaders
  264. };
  265. let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
  266. try {
  267. if (method == "post" || method == "put" || method == "patch") {
  268. request[method](payloadObj, onComplete);
  269. } else {
  270. request[method](onComplete);
  271. }
  272. } catch (ex) {
  273. log.error("Failed to make hawk request", ex);
  274. deferred.reject(ex);
  275. }
  276. return deferred.promise;
  277. },
  278. /*
  279. * The prefix used for all notifications sent by this module. This
  280. * allows the handler of notifications to be sure they are handling
  281. * notifications for the service they expect.
  282. *
  283. * If not set, no notifications will be sent.
  284. */
  285. observerPrefix: null,
  286. // Given an optional header value, notify that a backoff has been requested.
  287. _maybeNotifyBackoff: function (response, headerName) {
  288. if (!this.observerPrefix || !response.headers) {
  289. return;
  290. }
  291. let headerVal = response.headers[headerName];
  292. if (!headerVal) {
  293. return;
  294. }
  295. let backoffInterval;
  296. try {
  297. backoffInterval = parseInt(headerVal, 10);
  298. } catch (ex) {
  299. log.error("hawkclient response had invalid backoff value in '" +
  300. headerName + "' header: " + headerVal);
  301. return;
  302. }
  303. Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval);
  304. },
  305. // override points for testing.
  306. newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) {
  307. return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
  308. },
  309. }