services/common/hawkclient.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     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/. */
     5 "use strict";
     7 /*
     8  * HAWK is an HTTP authentication scheme using a message authentication code
     9  * (MAC) algorithm to provide partial HTTP request cryptographic verification.
    10  *
    11  * For details, see: https://github.com/hueniverse/hawk
    12  *
    13  * With HAWK, it is essential that the clocks on clients and server not have an
    14  * absolute delta of greater than one minute, as the HAWK protocol uses
    15  * timestamps to reduce the possibility of replay attacks.  However, it is
    16  * likely that some clients' clocks will be more than a little off, especially
    17  * in mobile devices, which would break HAWK-based services (like sync and
    18  * firefox accounts) for those clients.
    19  *
    20  * This library provides a stateful HAWK client that calculates (roughly) the
    21  * clock delta on the client vs the server.  The library provides an interface
    22  * for deriving HAWK credentials and making HAWK-authenticated REST requests to
    23  * a single remote server.  Therefore, callers who want to interact with
    24  * multiple HAWK services should instantiate one HawkClient per service.
    25  */
    27 this.EXPORTED_SYMBOLS = ["HawkClient"];
    29 const {interfaces: Ci, utils: Cu} = Components;
    31 Cu.import("resource://gre/modules/FxAccountsCommon.js");
    32 Cu.import("resource://services-common/utils.js");
    33 Cu.import("resource://services-crypto/utils.js");
    34 Cu.import("resource://services-common/hawkrequest.js");
    35 Cu.import("resource://services-common/observers.js");
    36 Cu.import("resource://gre/modules/Promise.jsm");
    38 /*
    39  * A general purpose client for making HAWK authenticated requests to a single
    40  * host.  Keeps track of the clock offset between the client and the host for
    41  * computation of the timestamp in the HAWK Authorization header.
    42  *
    43  * Clients should create one HawkClient object per each server they wish to
    44  * interact with.
    45  *
    46  * @param host
    47  *        The url of the host
    48  */
    49 this.HawkClient = function(host) {
    50   this.host = host;
    52   // Clock offset in milliseconds between our client's clock and the date
    53   // reported in responses from our host.
    54   this._localtimeOffsetMsec = 0;
    55 }
    57 this.HawkClient.prototype = {
    59   /*
    60    * Construct an error message for a response.  Private.
    61    *
    62    * @param restResponse
    63    *        A RESTResponse object from a RESTRequest
    64    *
    65    * @param errorString
    66    *        A string describing the error
    67    */
    68   _constructError: function(restResponse, errorString) {
    69     let errorObj = {
    70       error: errorString,
    71       message: restResponse.statusText,
    72       code: restResponse.status,
    73       errno: restResponse.status
    74     };
    75     let retryAfter = restResponse.headers && restResponse.headers["retry-after"];
    76     retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
    77     if (retryAfter) {
    78       errorObj.retryAfter = retryAfter;
    79       // and notify observers of the retry interval
    80       if (this.observerPrefix) {
    81         Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
    82       }
    83     }
    84     return errorObj;
    85   },
    87   /*
    88    *
    89    * Update clock offset by determining difference from date gives in the (RFC
    90    * 1123) Date header of a server response.  Because HAWK tolerates a window
    91    * of one minute of clock skew (so two minutes total since the skew can be
    92    * positive or negative), the simple method of calculating offset here is
    93    * probably good enough.  We keep the value in milliseconds to make life
    94    * easier, even though the value will not have millisecond accuracy.
    95    *
    96    * @param dateString
    97    *        An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
    98    *
    99    * For HAWK clock skew and replay protection, see
   100    * https://github.com/hueniverse/hawk#replay-protection
   101    */
   102   _updateClockOffset: function(dateString) {
   103     try {
   104       let serverDateMsec = Date.parse(dateString);
   105       this._localtimeOffsetMsec = serverDateMsec - this.now();
   106       log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec);
   107     } catch(err) {
   108       log.warn("Bad date header in server response: " + dateString);
   109     }
   110   },
   112   /*
   113    * Get the current clock offset in milliseconds.
   114    *
   115    * The offset is the number of milliseconds that must be added to the client
   116    * clock to make it equal to the server clock.  For example, if the client is
   117    * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
   118    */
   119   get localtimeOffsetMsec() {
   120     return this._localtimeOffsetMsec;
   121   },
   123   /*
   124    * return current time in milliseconds
   125    */
   126   now: function() {
   127     return Date.now();
   128   },
   130   /* A general method for sending raw RESTRequest calls authorized using HAWK
   131    *
   132    * @param path
   133    *        API endpoint path
   134    * @param method
   135    *        The HTTP request method
   136    * @param credentials
   137    *        Hawk credentials
   138    * @param payloadObj
   139    *        An object that can be encodable as JSON as the payload of the
   140    *        request
   141    * @return Promise
   142    *        Returns a promise that resolves to the text response of the API call,
   143    *        or is rejected with an error.  If the server response can be parsed
   144    *        as JSON and contains an 'error' property, the promise will be
   145    *        rejected with this JSON-parsed response.
   146    */
   147   request: function(path, method, credentials=null, payloadObj={}, retryOK=true) {
   148     method = method.toLowerCase();
   150     let deferred = Promise.defer();
   151     let uri = this.host + path;
   152     let self = this;
   154     function _onComplete(error) {
   155       let restResponse = this.response;
   156       let status = restResponse.status;
   158       log.debug("(Response) " + path + ": code: " + status +
   159                 " - Status text: " + restResponse.statusText);
   160       if (logPII) {
   161         log.debug("Response text: " + restResponse.body);
   162       }
   164       // All responses may have backoff headers, which are a server-side safety
   165       // valve to allow slowing down clients without hurting performance.
   166       self._maybeNotifyBackoff(restResponse, "x-weave-backoff");
   167       self._maybeNotifyBackoff(restResponse, "x-backoff");
   169       if (error) {
   170         // When things really blow up, reconstruct an error object that follows
   171         // the general format of the server on error responses.
   172         return deferred.reject(self._constructError(restResponse, error));
   173       }
   175       self._updateClockOffset(restResponse.headers["date"]);
   177       if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) {
   178         // Retry once if we were rejected due to a bad timestamp.
   179         // Clock offset is adjusted already in the top of this function.
   180         log.debug("Received 401 for " + path + ": retrying");
   181         return deferred.resolve(
   182             self.request(path, method, credentials, payloadObj, false));
   183       }
   185       // If the server returned a json error message, use it in the rejection
   186       // of the promise.
   187       //
   188       // In the case of a 401, in which we are probably being rejected for a
   189       // bad timestamp, retry exactly once, during which time clock offset will
   190       // be adjusted.
   192       let jsonResponse = {};
   193       try {
   194         jsonResponse = JSON.parse(restResponse.body);
   195       } catch(notJSON) {}
   197       let okResponse = (200 <= status && status < 300);
   198       if (!okResponse || jsonResponse.error) {
   199         if (jsonResponse.error) {
   200           return deferred.reject(jsonResponse);
   201         }
   202         return deferred.reject(self._constructError(restResponse, "Request failed"));
   203       }
   204       // It's up to the caller to know how to decode the response.
   205       // We just return the raw text.
   206       deferred.resolve(this.response.body);
   207     };
   209     function onComplete(error) {
   210       try {
   211         // |this| is the RESTRequest object and we need to ensure _onComplete
   212         // gets the same one.
   213         _onComplete.call(this, error);
   214       } catch (ex) {
   215         log.error("Unhandled exception processing response:" +
   216                   CommonUtils.exceptionStr(ex));
   217         deferred.reject(ex);
   218       }
   219     }
   221     let extra = {
   222       now: this.now(),
   223       localtimeOffsetMsec: this.localtimeOffsetMsec,
   224     };
   226     let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
   227     if (method == "post" || method == "put") {
   228       request[method](payloadObj, onComplete);
   229     } else {
   230       request[method](onComplete);
   231     }
   233     return deferred.promise;
   234   },
   236   /*
   237    * The prefix used for all notifications sent by this module.  This
   238    * allows the handler of notifications to be sure they are handling
   239    * notifications for the service they expect.
   240    *
   241    * If not set, no notifications will be sent.
   242    */
   243   observerPrefix: null,
   245   // Given an optional header value, notify that a backoff has been requested.
   246   _maybeNotifyBackoff: function (response, headerName) {
   247     if (!this.observerPrefix || !response.headers) {
   248       return;
   249     }
   250     let headerVal = response.headers[headerName];
   251     if (!headerVal) {
   252       return;
   253     }
   254     let backoffInterval;
   255     try {
   256       backoffInterval = parseInt(headerVal, 10);
   257     } catch (ex) {
   258       log.error("hawkclient response had invalid backoff value in '" +
   259                 headerName + "' header: " + headerVal);
   260       return;
   261     }
   262     Observers.notify(this.observerPrefix + ":backoff:interval", backoffInterval);
   263   },
   265   // override points for testing.
   266   newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) {
   267     return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
   268   },
   269 }

mercurial