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

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

mercurial