Skip to content

Commit fd5eb2b

Browse files
author
Shiranuit
authored
Secure WebSocket connection with cookie authentication (#621)
This PR adds the support of cookie when using the websocket protocol. To do that, when Kuzzle cookieAuth option is true, Kuzzle will call the method enableCookieSupport from the given protocol. When called, this method will throw if outside the browser or if cookie are not supported by the protocol, otherwise this will change how the protocol behave. For the HTTP protocol it's simple, when enableCookieSupport is called, the protocol will be changing if the request are made with withCredentials set to true or false depending if he should be able to receive cookies. For the Websocket protocol, this is a bit more complex, when enableCookieSupport is called, the protocol, will be creating a instance of the HTTP Protocol, with the same option (host, port, ssl, ...) as the websocket protocol, after that, when a request auth:login, auth:logout or auth:refreshToken is made, the protocol will use the HTTP Protocol instead of the websocket client to make the request. [ex: auth:login request is made -> websocket closes the connection -> then send the request with the http protocol -> when a response is received it reopens the connection -> then resolve the request]
1 parent 7b7e19e commit fd5eb2b

File tree

11 files changed

+455
-116
lines changed

11 files changed

+455
-116
lines changed

doc/7/core-classes/kuzzle/constructor/index.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@ It can be one of the following available protocols:
3636

3737
Kuzzle SDK instance options.
3838

39-
| Property | Type<br/>(default) | Description |
40-
| ---------------------- | -------------------------------- | ------------------------------------------------------------------------ |
41-
| `autoQueue` | <pre>boolean</pre><br/>(`false`) | Automatically queue all requests during offline mode |
42-
| `autoReplay` | <pre>boolean</pre><br/>(`false`) | Automatically replay queued requests on a `reconnected` event |
43-
| `autoResubscribe` | <pre>boolean</pre><br/>(`true`) | Automatically renew all subscriptions on a `reconnected` event |
44-
| `cookieAuth` | <pre>boolean</pre><br/>(`false`) | Uses cookie to store token |
45-
| `eventTimeout` | <pre>number</pre><br/>(`200`) | Time (in ms) during which a similar event is ignored |
46-
| `deprecationWarning` | <pre>boolean</pre><br />(`true`) | Show deprecation warning in development (hidden either way in production)|
47-
| `offlineMode` | <pre>string</pre><br/>(`manual`) | Offline mode configuration. Can be `manual` or `auto` |
48-
| `queueTTL` | <pre>number</pre><br/>(`120000`) | Time a queued request is kept during offline mode, in milliseconds |
49-
| `queueMaxSize` | <pre>number</pre><br/>(`500`) | Number of maximum requests kept during offline mode |
50-
| `replayInterval` | <pre>number</pre><br/>(`10`) | Delay between each replayed requests, in milliseconds |
51-
| `tokenExpiredInterval` | <pre>number</pre><br/>(`1000`) | Time (in ms) during which a TokenExpired event is ignored |
52-
| `volatile` | <pre>object</pre><br/>(`{}`) | Common volatile data, will be sent to all future requests |
39+
| Property | Type<br/>(default) | Description |
40+
| ---------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------- |
41+
| `autoQueue` | <pre>boolean</pre><br/>(`false`) | Automatically queue all requests during offline mode |
42+
| `autoReplay` | <pre>boolean</pre><br/>(`false`) | Automatically replay queued requests on a `reconnected` event |
43+
| `autoResubscribe` | <pre>boolean</pre><br/>(`true`) | Automatically renew all subscriptions on a `reconnected` event |
44+
| `cookieAuth` | <pre>boolean</pre><br/>(`false`) | Uses cookie to store token, this option set `offlineMode` to `auto` and `autoResubscribe` to `true` |
45+
| `deprecationWarning` | <pre>boolean</pre><br />(`true`) | Show deprecation warning in development (hidden either way in production) |
46+
| `eventTimeout` | <pre>number</pre><br/>(`200`) | Time (in ms) during which a similar event is ignored |
47+
| `offlineMode` | <pre>string</pre><br/>(`manual`) | Offline mode configuration. Can be `manual` or `auto` |
48+
| `queueTTL` | <pre>number</pre><br/>(`120000`) | Time a queued request is kept during offline mode, in milliseconds |
49+
| `queueMaxSize` | <pre>number</pre><br/>(`500`) | Number of maximum requests kept during offline mode |
50+
| `replayInterval` | <pre>number</pre><br/>(`10`) | Delay between each replayed requests, in milliseconds |
51+
| `tokenExpiredInterval` | <pre>number</pre><br/>(`1000`) | Time (in ms) during which a TokenExpired event is ignored |
52+
| `volatile` | <pre>object</pre><br/>(`{}`) | Common volatile data, will be sent to all future requests |
5353

5454
## Return
5555

doc/7/protocols/websocket/introduction/index.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,28 @@ This application-level PING has been especially added for web browsers, which do
2323
When run in a browser, our Javascript SDK uses that feature for its keep-alive mechanism: a message will periodically be sent to Kuzzle in the form `"{"p":1}"` through websocket.
2424
That message will call a response from Kuzzle in the form `"{"p":2}"` for the SDK to keep the connection alive.
2525

26+
### Cookie Authentication
27+
28+
Kuzzle supports cookie authentications, meaning that when using this SDK in a browser, you can ask Kuzzle to return authentication tokens in secure cookies, handled by browsers. This means that, when using that option, browser clients will never have access to said tokens, preventing a few common attacks.
29+
The support for cookie authentication can be enabled, using the [cookieAuth](/sdk/js/7/core-classes/kuzzle/constructor) option at the SDK initialization.
30+
31+
When you enable the [cookieAuth](/sdk/js/7/core-classes/kuzzle/constructor) option, it changes the way the websocket protocol behaves when you're sending requests that should otherwise return authentication tokens in their response payload.
32+
33+
When a request susceptible of changing an authentication cookie is about to be sent, the WebSocket Protocol send it using the [HTTP Protocol](/sdk/js/7/protocols/http/introduction) instead, to allow browsers to apply the received cookie.
34+
35+
If a new cookie is received from Kuzzle that way, the WebSocket connection is automatically renewed.
36+
37+
::: info
38+
Cookies can only be applied to WebSocket connections during the connection handshake (upgrade from HTTP to WebSocket), and they stay valid as long as the connection is active, and as long as the cookie hasn't expired.
39+
:::
40+
41+
![websocket cookie authentication](./websocket-cookie-authentication.png)
42+
43+
Here is a list of controller's actions that are affected by this behavior, when the [cookieAuth](/sdk/js/7/core-classes/kuzzle/constructor) option is enabled:
44+
- [auth:login](/sdk/js/7/controllers/auth/login)
45+
- [auth:logout](/sdk/js/7/controllers/auth/logout)
46+
- [auth:refreshToken](/sdk/js/7/controllers/auth/refresh-token)
47+
48+
::: info
49+
The behaviors described above are automatically handled by the SDK, you do not need to implement this yourself.
50+
:::
86.3 KB
Loading

src/Kuzzle.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,17 @@ export class Kuzzle extends KuzzleEventEmitter {
170170
* If set to `auto`, the `autoQueue` and `autoReplay` are also set to `true`
171171
*/
172172
offlineMode?: 'auto';
173-
/**
174-
* Show deprecation warning in development mode (hidden either way in production)
175-
* Default: `true`
176-
*/
177-
deprecationWarning?: boolean;
178173
/**
179174
* If `true` uses cookie to store token
180175
* Only supported in a browser
181176
* Default: `false`
182177
*/
183178
cookieAuth?: boolean;
179+
/**
180+
* Show deprecation warning in development mode (hidden either way in production)
181+
* Default: `true`
182+
*/
183+
deprecationWarning?: boolean;
184184
} = {}
185185
) {
186186
super();
@@ -225,14 +225,37 @@ export class Kuzzle extends KuzzleEventEmitter {
225225
? options.volatile
226226
: {};
227227

228-
this.deprecationHandler = new Deprecation(
229-
typeof options.deprecationWarning === 'boolean' ? options.deprecationWarning : true
230-
);
231-
232228
this._cookieAuthentication = typeof options.cookieAuth === 'boolean'
233229
? options.cookieAuth
234230
: false;
235231

232+
if (this._cookieAuthentication) {
233+
this.protocol.enableCookieSupport();
234+
let autoQueueState;
235+
let autoReplayState;
236+
let autoResbuscribeState;
237+
238+
this.protocol.addListener('websocketRenewalStart', () => {
239+
autoQueueState = this.autoQueue;
240+
autoReplayState = this.autoReplay;
241+
autoResbuscribeState = this.autoResubscribe;
242+
243+
this.autoQueue = true;
244+
this.autoReplay = true;
245+
this.autoResubscribe = true;
246+
});
247+
248+
this.protocol.addListener('websocketRenewalDone', () => {
249+
this.autoQueue = autoQueueState;
250+
this.autoReplay = autoReplayState;
251+
this.autoResubscribe = autoResbuscribeState;
252+
});
253+
}
254+
255+
this.deprecationHandler = new Deprecation(
256+
typeof options.deprecationWarning === 'boolean' ? options.deprecationWarning : true
257+
);
258+
236259
if (this._cookieAuthentication && typeof XMLHttpRequest === 'undefined') {
237260
throw new Error('Support for cookie authentication with cookieAuth option is not supported outside a browser');
238261
}

src/protocols/Http.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export default class HttpProtocol extends KuzzleAbstractProtocol {
113113
return Promise.resolve();
114114
}
115115

116-
return this._sendHttpRequest('GET', '/_publicApi')
116+
return this._sendHttpRequest({method: 'GET', path: '/_publicApi'})
117117
.then(({ result, error }) => {
118118
if (! error) {
119119
this._routes = this._constructRoutes(result);
@@ -133,7 +133,7 @@ export default class HttpProtocol extends KuzzleAbstractProtocol {
133133
} else if (error.status === 404) {
134134
// fallback to server:info route
135135
// server:publicApi is only available since Kuzzle 1.9.0
136-
return this._sendHttpRequest('GET', '/')
136+
return this._sendHttpRequest({method: 'GET', path: '/'})
137137
.then(({ result: res, error: err }) => {
138138
if (! err) {
139139
this._routes = this._constructRoutes(res.serverInfo.kuzzle.api.routes);
@@ -173,12 +173,23 @@ export default class HttpProtocol extends KuzzleAbstractProtocol {
173173
}
174174

175175
/**
176-
* Sends a payload to the connected server
176+
* Enable cookie authentication support at protocol level
177+
*/
178+
enableCookieSupport () {
179+
if (typeof XMLHttpRequest === 'undefined') {
180+
throw new Error('Support for cookie cannot be enabled outside of a browser');
181+
}
182+
183+
super.enableCookieSupport();
184+
}
185+
186+
/**
187+
* Preprocess and format the request
177188
*
178189
* @param {Object} data
179190
* @returns {Promise<any>}
180191
*/
181-
send (request: RequestPayload, options: JSONObject = {}) {
192+
formatRequest (request: RequestPayload, options: JSONObject = {}) {
182193
const route = this.routes[request.controller]
183194
&& this.routes[request.controller][request.action];
184195

@@ -281,12 +292,30 @@ export default class HttpProtocol extends KuzzleAbstractProtocol {
281292
url += '?' + queryString.join('&');
282293
}
283294

284-
this._sendHttpRequest(method, url, payload)
285-
.then(response => this.emit(payload.requestId, response))
286-
.catch(error => this.emit(payload.requestId, {error}));
295+
return {
296+
method,
297+
path: url,
298+
payload
299+
};
300+
}
301+
302+
/**
303+
* Sends a payload to the connected server
304+
*
305+
* @param {Object} data
306+
* @returns {Promise<any>}
307+
*/
308+
send (request: RequestPayload, options: JSONObject = {}) {
309+
const formattedRequest = this.formatRequest(request, options);
310+
311+
if (formattedRequest) {
312+
this._sendHttpRequest(formattedRequest)
313+
.then(response => this.emit(formattedRequest.payload.requestId, response))
314+
.catch(error => this.emit(formattedRequest.payload.requestId, {error}));
315+
}
287316
}
288317

289-
_sendHttpRequest (method, path, payload: any = {}) {
318+
_sendHttpRequest ({method, path, payload = {headers: undefined, body:undefined}}) {
290319
if (typeof XMLHttpRequest === 'undefined') {
291320
// NodeJS implementation, using http.request:
292321

@@ -331,7 +360,7 @@ export default class HttpProtocol extends KuzzleAbstractProtocol {
331360
xhr.open(method, url);
332361

333362
// Authorize the reception of cookies
334-
xhr.withCredentials = true;
363+
xhr.withCredentials = this.cookieSupport;
335364

336365
for (const header of Object.keys(payload.headers || {})) {
337366
xhr.setRequestHeader(header, payload.headers[header]);

src/protocols/WebSocket.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { KuzzleError } from '../KuzzleError';
44
import { BaseProtocolRealtime } from './abstract/Realtime';
55
import { JSONObject } from '../types';
66
import { RequestPayload } from '../types/RequestPayload';
7+
import HttpProtocol from './Http';
78

89
/**
910
* WebSocket protocol used to connect to a Kuzzle server.
@@ -18,6 +19,7 @@ export default class WebSocketProtocol extends BaseProtocolRealtime {
1819
private pingIntervalId: ReturnType<typeof setInterval>;
1920
private _pingInterval: number;
2021
private _pongTimeout: number;
22+
private _httpProtocol: HttpProtocol;
2123

2224
/**
2325
* @param host Kuzzle server hostname or IP
@@ -209,15 +211,66 @@ export default class WebSocketProtocol extends BaseProtocolRealtime {
209211
});
210212
}
211213

214+
enableCookieSupport () {
215+
if (typeof XMLHttpRequest === 'undefined') {
216+
throw new Error('Support for cookie cannot be enabled outside of a browser');
217+
}
218+
219+
super.enableCookieSupport();
220+
this._httpProtocol = new HttpProtocol(
221+
this.host,
222+
{
223+
port: this.port,
224+
ssl: this.ssl,
225+
}
226+
);
227+
this._httpProtocol.enableCookieSupport();
228+
}
229+
212230
/**
213231
* Sends a payload to the connected server
214232
*
215233
* @param {Object} payload
216234
*/
217-
send (request: RequestPayload) {
218-
if (this.client && this.client.readyState === this.client.OPEN) {
235+
send (request: RequestPayload, options: JSONObject = {}) {
236+
if (!this.client || this.client.readyState !== this.client.OPEN) {
237+
return;
238+
}
239+
240+
if (! this.cookieSupport
241+
|| request.controller !== 'auth'
242+
|| ( request.action !== 'login'
243+
&& request.action !== 'logout'
244+
&& request.action !== 'refreshToken')
245+
) {
219246
this.client.send(JSON.stringify(request));
247+
return;
248+
}
249+
250+
const formattedRequest = this._httpProtocol.formatRequest(request, options);
251+
252+
if (!formattedRequest) {
253+
return;
254+
}
255+
256+
this.emit('websocketRenewalStart'); // Notify that the websocket is going to renew his connection with Kuzzle
257+
if (this.client) {
258+
this.client.close();
220259
}
260+
this.client = null;
261+
this.clientDisconnected(); // Simulate a disconnection, this will enable offline queue and trigger realtime subscriptions backup
262+
263+
this._httpProtocol._sendHttpRequest(formattedRequest)
264+
.then(response => {
265+
// Reconnection
266+
return this.connect()
267+
.then(() => {
268+
this.emit(formattedRequest.payload.requestId, response);
269+
this.emit('websocketRenewalDone'); // Notify that the websocket has finished renewing his connection with Kuzzle
270+
});
271+
})
272+
.catch(error => this.emit(formattedRequest.payload.requestId, {error}));
273+
221274
}
222275

223276
/**

src/protocols/abstract/Base.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export abstract class KuzzleAbstractProtocol extends KuzzleEventEmitter {
1313
private _name: string;
1414
private _port: number;
1515
private _ssl: boolean;
16+
private _cookieSupport: boolean;
1617

1718
public id: string;
1819

@@ -46,6 +47,7 @@ export abstract class KuzzleAbstractProtocol extends KuzzleEventEmitter {
4647

4748
this.id = uuidv4();
4849
this.state = 'offline';
50+
this._cookieSupport = false;
4951

5052
Object.keys(options).forEach(opt => {
5153
if ( Object.prototype.hasOwnProperty.call(this, opt)
@@ -91,14 +93,29 @@ export abstract class KuzzleAbstractProtocol extends KuzzleEventEmitter {
9193
return this.state === 'connected';
9294
}
9395

96+
/**
97+
* `true` if cookie authentication is enabled
98+
*/
99+
get cookieSupport () {
100+
return this._cookieSupport;
101+
}
102+
94103
get pendingRequests () {
95104
return this._pendingRequests;
96105
}
97-
106+
98107
abstract connect (): Promise<any>
99-
108+
100109
abstract send (request: RequestPayload, options: JSONObject): void
101110

111+
112+
/**
113+
* Called when we want to enable http cookie support
114+
*/
115+
enableCookieSupport () {
116+
this._cookieSupport = true;
117+
}
118+
102119
/**
103120
* Called when the client's connection is established
104121
*/

test/kuzzle/constructor.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,19 @@ describe('Kuzzle constructor', () => {
155155
);
156156
}).not.throw();
157157
});
158+
159+
it('should call protocol.enableCookieSupport', () => {
160+
/* eslint-disable no-native-reassign */
161+
/* eslint-disable no-global-assign */
162+
XMLHttpRequest = () => {}; // Faking being in a browser, otherwise kuzzle will throw that cookie are not supported
163+
const kuzzle = new Kuzzle(protocolMock, {
164+
cookieAuth: true,
165+
});
166+
167+
should(kuzzle.cookieAuthentication).be.true();
168+
should(protocolMock.enableCookieSupport).be.calledOnce();
169+
/* eslint-disable no-native-reassign */
170+
/* eslint-disable no-global-assign */
171+
XMLHttpRequest = undefined; // Reset it to undefined
172+
});
158173
});

test/mocks/protocol.mock.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ProtocolMock extends KuzzleEventEmitter {
1414
this.connectCalled = false;
1515

1616
this.close = sinon.stub();
17+
this.enableCookieSupport = sinon.stub().returns();
1718
this.query = sinon.stub().resolves();
1819
this.playQueue = sinon.stub();
1920
this.flushQueue = sinon.stub();
@@ -50,6 +51,7 @@ class ProtocolMock extends KuzzleEventEmitter {
5051
});
5152
}
5253

54+
5355
disconnect () {
5456
this.state = 'offline';
5557
this.emit('disconnect');

0 commit comments

Comments
 (0)