services/common/hawkclient.js

branch
TOR_BUG_9701
changeset 15
b8a032363ba2
equal deleted inserted replaced
-1:000000000000 0:ddaa155f35f4
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
5 "use strict";
6
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 */
26
27 this.EXPORTED_SYMBOLS = ["HawkClient"];
28
29 const {interfaces: Ci, utils: Cu} = Components;
30
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");
37
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;
51
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 }
56
57 this.HawkClient.prototype = {
58
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 },
86
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 },
111
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 },
122
123 /*
124 * return current time in milliseconds
125 */
126 now: function() {
127 return Date.now();
128 },
129
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();
149
150 let deferred = Promise.defer();
151 let uri = this.host + path;
152 let self = this;
153
154 function _onComplete(error) {
155 let restResponse = this.response;
156 let status = restResponse.status;
157
158 log.debug("(Response) " + path + ": code: " + status +
159 " - Status text: " + restResponse.statusText);
160 if (logPII) {
161 log.debug("Response text: " + restResponse.body);
162 }
163
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");
168
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 }
174
175 self._updateClockOffset(restResponse.headers["date"]);
176
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 }
184
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.
191
192 let jsonResponse = {};
193 try {
194 jsonResponse = JSON.parse(restResponse.body);
195 } catch(notJSON) {}
196
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 };
208
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 }
220
221 let extra = {
222 now: this.now(),
223 localtimeOffsetMsec: this.localtimeOffsetMsec,
224 };
225
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 }
232
233 return deferred.promise;
234 },
235
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,
244
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 },
264
265 // override points for testing.
266 newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) {
267 return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
268 },
269 }

mercurial