nodebb-plugin-session-sharing 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -97,25 +97,14 @@ SocketPlugins.sessionSharing.showUserIds = async (socket, data) => {
97
97
  throw new Error('no-uids-supplied');
98
98
  }
99
99
 
100
- return Promise.all(uids.map(async (uid) => db.getSortedSetRangeByScore(plugin.settings.name + ':uid', 0, -1, uid, uid)));
101
- };
102
-
103
- SocketPlugins.sessionSharing.showUserIds = async (socket, data) => {
104
- // Retrieve the hash and find matches
105
- const { uids } = data;
106
-
107
- if (!uids.length) {
108
- throw new Error('no-uids-supplied');
109
- }
110
-
111
- return Promise.all(uids.map(async (uid) => db.getSortedSetRangeByScore(plugin.settings.name + ':uid', 0, -1, uid, uid)));
100
+ return Promise.all(uids.map(async uid => db.getSortedSetRangeByScore(plugin.settings.name + ':uid', 0, -1, uid, uid)));
112
101
  };
113
102
 
114
103
  SocketPlugins.sessionSharing.findUserByRemoteId = async (socket, data) => {
115
104
  if (!data.remoteId) {
116
105
  throw new Error('no-remote-id-supplied');
117
106
  }
118
-
107
+
119
108
  return plugin.getUser(data.remoteId);
120
109
  };
121
110
 
@@ -202,7 +191,7 @@ plugin.verifyUser = async (token, uid, isNewUser) => {
202
191
  isNewUser: isNewUser,
203
192
  token: token,
204
193
  });
205
-
194
+
206
195
  // Check ban state of user
207
196
  const isBanned = await user.bans.isBanned(uid);
208
197
 
@@ -217,15 +206,15 @@ plugin.findOrCreateUser = async (userData) => {
217
206
  let isNewUser = false;
218
207
  let userId = null;
219
208
  let queries = [db.sortedSetScore(plugin.settings.name + ':uid', userData.id)];
220
-
209
+
221
210
  if (userData.email && userData.email.length) {
222
211
  queries = [...queries, db.sortedSetScore('email:uid', userData.email)];
223
212
  }
224
-
213
+
225
214
  let [uid, mergeUid] = await Promise.all(queries);
226
215
  uid = parseInt(uid, 10);
227
216
  mergeUid = parseInt(mergeUid, 10);
228
-
217
+
229
218
  /* check if found something to work with */
230
219
  if (uid && !isNaN(uid)) {
231
220
  try {
@@ -243,13 +232,13 @@ plugin.findOrCreateUser = async (userData) => {
243
232
  winston.warn('[session-sharing] Error while testing user existance', error);
244
233
  }
245
234
  }
246
-
235
+
247
236
  if (!userId && mergeUid && !isNaN(mergeUid)) {
248
237
  winston.info('[session-sharing] Found user via their email, associating this id (' + id + ') with their NodeBB account');
249
238
  await db.sortedSetAdd(plugin.settings.name + ':uid', mergeUid, id);
250
239
  userId = mergeUid;
251
240
  }
252
-
241
+
253
242
  /* create the user from payload if necessary */
254
243
  winston.debug('createUser?', !userId);
255
244
  if (!userId) {
@@ -281,7 +270,7 @@ plugin.updateUserProfile = async (uid, userData, isNewUser) => {
281
270
 
282
271
  return result;
283
272
  }, {});
284
-
273
+
285
274
  if (Object.keys(obj).length) {
286
275
  winston.debug('[session-sharing] Updating profile fields:', obj);
287
276
  obj.uid = uid;
@@ -296,13 +285,13 @@ plugin.updateUserProfile = async (uid, userData, isNewUser) => {
296
285
  winston.warn('[session-sharing] Unable to update profile information for uid: ' + uid + '(' + error.message + ')');
297
286
  }
298
287
  }
299
-
288
+
300
289
  if (userData.picture) {
301
290
  await db.setObjectField('user:' + uid, 'picture', userData.picture);
302
291
  }
303
292
  };
304
293
 
305
- plugin.updateUserGroups = async (uid, userData, isNewUser) => {
294
+ plugin.updateUserGroups = async (uid, userData) => {
306
295
  if (!userData.groups || !Array.isArray(userData.groups)) {
307
296
  return;
308
297
  }
@@ -310,14 +299,14 @@ plugin.updateUserGroups = async (uid, userData, isNewUser) => {
310
299
  // Retrieve user groups
311
300
  let [userGroups] = await groups.getUserGroupsFromSet('groups:createtime', [uid]);
312
301
  // Normalize user group data to just group names
313
- userGroups = userGroups.map((groupObj) => groupObj.name);
314
-
302
+ userGroups = userGroups.map(groupObj => groupObj.name);
303
+
315
304
  // Build join and leave arrays
316
- let join = userData.groups.filter((name) => !userGroups.includes(name));
305
+ let join = userData.groups.filter(name => !userGroups.includes(name));
317
306
  if (plugin.settings.syncGroupList === 'on') {
318
- join = join.filter((group) => plugin.settings.syncGroups.includes(group));
307
+ join = join.filter(group => plugin.settings.syncGroups.includes(group));
319
308
  }
320
-
309
+
321
310
  let leave = userGroups.filter((name) => {
322
311
  // `registered-users` is always a joined group
323
312
  if (name === 'registered-users') {
@@ -327,7 +316,7 @@ plugin.updateUserGroups = async (uid, userData, isNewUser) => {
327
316
  return !userData.groups.includes(name);
328
317
  });
329
318
  if (plugin.settings.syncGroupList === 'on') {
330
- leave = leave.filter((group) => plugin.settings.syncGroups.includes(group));
319
+ leave = leave.filter(group => plugin.settings.syncGroups.includes(group));
331
320
  }
332
321
 
333
322
  await executeJoinLeave(uid, join, leave);
@@ -340,27 +329,27 @@ async function executeJoinLeave(uid, join, leave) {
340
329
  return;
341
330
  }
342
331
 
343
- await Promise.all(join.map((name) => groups.join(name, uid)));
332
+ await Promise.all(join.map(name => groups.join(name, uid)));
344
333
  })(),
345
334
  (async () => {
346
335
  if (plugin.settings.syncGroupLeave !== 'on') {
347
336
  return;
348
337
  }
349
338
 
350
- await Promise.all(leave.map((name) => groups.leave(name, uid)));
339
+ await Promise.all(leave.map(name => groups.leave(name, uid)));
351
340
  })(),
352
341
  ]);
353
342
  }
354
343
 
355
344
  plugin.createUser = async (userData) => {
356
345
  winston.verbose('[session-sharing] No user found, creating a new user for this login');
357
-
346
+
358
347
  const uid = await user.create(_.pick(userData, profileFields));
359
348
  await db.sortedSetAdd(plugin.settings.name + ':uid', uid, userData.id);
360
349
  return uid;
361
350
  };
362
351
 
363
- plugin.addMiddleware = async function (req, res, next) {
352
+ plugin.addMiddleware = async function ({ req, res }) {
364
353
  const { hostWhitelist, guestRedirect, editOverride, loginOverride, registerOverride } = await meta.settings.get('session-sharing');
365
354
 
366
355
  if (hostWhitelist) {
@@ -374,18 +363,16 @@ plugin.addMiddleware = async function (req, res, next) {
374
363
  }
375
364
 
376
365
  if (!whitelisted) {
377
- return next();
366
+ return;
378
367
  }
379
368
  }
380
369
 
381
- function handleGuest(req, res, next) {
370
+ function handleGuest(req, res) {
382
371
  if (guestRedirect && !req.originalUrl.startsWith(nconf.get('relative_path') + '/login?local=1')) {
383
372
  // If a guest redirect is specified, follow it
384
373
  res.redirect(guestRedirect.replace('%1', encodeURIComponent(req.protocol + '://' + req.get('host') + req.originalUrl)));
385
374
  } else if (res.locals.fullRefresh === true) {
386
375
  res.redirect(nconf.get('relative_path') + req.url);
387
- } else {
388
- next();
389
376
  }
390
377
  }
391
378
 
@@ -402,7 +389,7 @@ plugin.addMiddleware = async function (req, res, next) {
402
389
  // Let requests through under "update" or "revalidate" behaviour only if they're logging in for the first time
403
390
  delete req.session.loginLock; // remove login lock for "update" or "revalidate" logins
404
391
 
405
- return next();
392
+ return;
406
393
  }
407
394
 
408
395
  if (editOverride && hasSession && req.originalUrl.match(/\/user\/.*\/edit$/)) {
@@ -425,28 +412,28 @@ plugin.addMiddleware = async function (req, res, next) {
425
412
  }
426
413
 
427
414
  await plugin.cleanup({ res: res });
428
- return handleGuest.call(null, req, res, next);
415
+ return handleGuest.call(null, req, res);
429
416
  }
430
-
417
+
431
418
  if (Object.keys(req.cookies).length && req.cookies.hasOwnProperty(plugin.settings.cookieName) && req.cookies[plugin.settings.cookieName].length) {
432
419
  try {
433
420
  const uid = await plugin.process(req.cookies[plugin.settings.cookieName]);
434
421
  winston.verbose('[session-sharing] Processing login for uid ' + uid + ', path ' + req.originalUrl);
435
422
  req.uid = uid;
436
-
423
+
437
424
  if (plugin.settings.behaviour === 'revalidate') {
438
425
  res.locals.reroll = false; // disable session rerolling in core
439
426
  }
440
-
427
+
441
428
  await nbbAuthController.doLogin(req, uid);
442
-
429
+
443
430
  req.session.loginLock = true;
444
431
  const url = req.session.returnTo || req.originalUrl.replace(nconf.get('relative_path'), '');
445
432
  delete req.session.returnTo;
446
433
  res.redirect(nconf.get('relative_path') + url);
447
434
  } catch (error) {
448
435
  let handleAsGuest = false;
449
-
436
+
450
437
  switch (error.message) {
451
438
  case 'payload-invalid':
452
439
  winston.warn('[session-sharing] The passed-in payload was invalid and could not be processed');
@@ -468,29 +455,22 @@ plugin.addMiddleware = async function (req, res, next) {
468
455
  });
469
456
 
470
457
  if (data.handleAsGuest) {
471
- return handleGuest.call(error, req, res, next);
458
+ return handleGuest.call(error, req, res);
472
459
  }
473
460
 
474
- return next(error);
461
+ throw error;
475
462
  }
476
463
  } else if (hasSession) {
477
- try {
478
- // Has login session but no cookie, can assume "revalidate" behaviour
479
- const isAdmin = await user.isAdministrator(req.user.uid);
480
-
481
- if (plugin.settings.behaviour !== 'update' && (plugin.settings.adminRevalidate === 'on' || !isAdmin)) {
482
- req.logout();
483
- res.locals.fullRefresh = true;
484
- return handleGuest(req, res, next);
485
- }
464
+ // Has login session but no cookie, can assume "revalidate" behaviour
465
+ const isAdmin = await user.isAdministrator(req.user.uid);
486
466
 
487
- // Admins can bypass
488
- return next();
489
- } catch (error) {
490
- return next(error);
467
+ if (plugin.settings.behaviour !== 'update' && (plugin.settings.adminRevalidate === 'on' || !isAdmin)) {
468
+ req.logout();
469
+ res.locals.fullRefresh = true;
470
+ return handleGuest(req, res);
491
471
  }
492
472
  } else {
493
- return handleGuest.call(null, req, res, next);
473
+ return handleGuest.call(null, req, res);
494
474
  }
495
475
  };
496
476
 
@@ -503,11 +483,21 @@ plugin.cleanup = async (data) => {
503
483
  path: '/',
504
484
  });
505
485
  }
506
-
486
+
487
+ data.res.clearCookie('nbb_token', {
488
+ domain: plugin.settings.cookieDomain,
489
+ expires: new Date(),
490
+ path: '/',
491
+ });
492
+
507
493
  return true;
508
494
  };
509
495
 
510
496
  plugin.generate = function (req, res) {
497
+ if (!plugin.ready) {
498
+ return res.sendStatus(404);
499
+ }
500
+
511
501
  let payload = {};
512
502
  payload[plugin.settings['payload:id']] = 1;
513
503
  payload[plugin.settings['payload:username']] = 'testUser';
@@ -594,4 +584,22 @@ plugin.appendTemplate = async (data) => {
594
584
  return data;
595
585
  };
596
586
 
587
+ plugin.saveReverseToken = async ({ req, userData: data }) => {
588
+ if (!plugin.ready || plugin.settings.reverseToken !== 'on') {
589
+ return; // no reverse token if secret not set
590
+ }
591
+
592
+ const res = req.res;
593
+ const userData = await user.getUserFields(data.uid, ['uid', 'username', 'picture', 'reputation', 'postcount', 'banned']);
594
+ const token = jwt.sign(userData, plugin.settings.secret);
595
+
596
+ res.cookie('nbb_token', token, {
597
+ maxAge: meta.getSessionTTLSeconds() * 1000,
598
+ httpOnly: !req.secure,
599
+ domain: plugin.settings.cookieDomain,
600
+ });
601
+
602
+ winston.info(`[plugins/session-sharing] Saving reverse cookie for uid ${userData.uid}, session: ${req.session.id}`);
603
+ };
604
+
597
605
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-session-sharing",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Allows login sessions from your app to persist in NodeBB",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "@commitlint/cli": "^9.1.2",
40
40
  "async": "^2",
41
41
  "eslint": "^7.7.0",
42
- "jsonwebtoken": "5.5.4",
42
+ "jsonwebtoken": "^8.5.1",
43
43
  "lint-staged": "^10.0.9",
44
44
  "lodash": "^4.17.14"
45
45
  },
@@ -50,6 +50,8 @@
50
50
  "eslint-config-airbnb-base": "^12.1.0",
51
51
  "eslint-plugin-import": "^2.8.0",
52
52
  "husky": "^2.4.0",
53
- "lint-staged": "^10.0.9"
53
+ "lint-staged": "^10.0.9",
54
+ "request": "^2.88.2",
55
+ "request-promise-native": "^1.0.9"
54
56
  }
55
57
  }
package/plugin.json CHANGED
@@ -3,13 +3,14 @@
3
3
  "url": "https://github.com/NodeBB/nodebb-plugin-session-sharing",
4
4
  "library": "./library.js",
5
5
  "hooks": [
6
- { "hook": "filter:router.page", "method": "addMiddleware" },
6
+ { "hook": "response:router.page", "method": "addMiddleware" },
7
7
  { "hook": "static:app.load", "method": "init" },
8
8
  { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
9
9
  { "hook": "static:user.loggedOut", "method": "cleanup" },
10
10
  { "hook": "filter:config.get", "method": "appendConfig" },
11
11
  { "hook": "filter:middleware.render", "method": "appendTemplate" },
12
- { "hook": "action:settings.set", "method": "reloadSettings" }
12
+ { "hook": "action:settings.set", "method": "reloadSettings" },
13
+ { "hook": "action:login.continue", "method": "saveReverseToken" }
13
14
  ],
14
15
  "less": [
15
16
  "static/style.less"
@@ -55,7 +55,7 @@
55
55
  <span class="mdl-switch__label"><strong>Apply revalidation rules to administrators as well</strong></span>
56
56
  </label>
57
57
  <p class="help-block">
58
- Administrators are exempt from the <code>revalidate</code> behaviour because a
58
+ Administrators are exempt from the <code>revalidate</code> behaviour because a
59
59
  misconfiguration could lock them out of the admin panel. Enable this option to force
60
60
  administrators to also undergo cookie revalidation, and thereby increasing security.
61
61
  </p>
@@ -242,6 +242,25 @@
242
242
  </div>
243
243
  </div>
244
244
  </div>
245
+ <div class="row">
246
+ <div class="col-sm-2 col-xs-12 settings-header">Reverse Token</div>
247
+ <div class="col-sm-10 col-xs-12">
248
+ <div class="form-group">
249
+ <div class="checkbox">
250
+ <label for="reverseToken" class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
251
+ <input type="checkbox" class="mdl-switch__input" id="reverseToken" name="reverseToken">
252
+ <span class="mdl-switch__label"><strong>Enabled</strong></span>
253
+ </label>
254
+ </div>
255
+ <p class="help-block">
256
+ If enabled, NodeBB will save a cookie called <code>nbb_token</code> that can be read by other sites on the same domain. It will allow other sites to authenticate a user session based on NodeBB login state.
257
+ </p>
258
+ <p class="help-block">
259
+ Similar to what is accepted by the session-sharing plugin, the reverse token is a signed <a href="//jwt.io">JWT</a> containing the logged in user's username and uid. Guests will not have an `nbb_token`.
260
+ </p>
261
+ </div>
262
+ </div>
263
+ </div>
245
264
  </div>
246
265
  <div class="col-md-3">
247
266
  <div class="panel panel-default">
package/test/index.js ADDED
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ /* globals describe, it */
4
+
5
+ // MUST BE RUN WITH ENV VAR "TEST_ENV=development"
6
+ // Don't forget to add this plugin to `test_plugins` array in `config.json`
7
+
8
+ const assert = require('assert');
9
+ const url = require('url');
10
+ const util = require('util');
11
+ const request = require('request-promise-native');
12
+
13
+ // eslint-disable-next-line no-unused-vars
14
+ const db = require.main.require('./test/mocks/databasemock');
15
+
16
+ const nconf = require.main.require('nconf');
17
+ const meta = require.main.require('./src/meta');
18
+
19
+ describe('nodebb-plugin-session-sharing', () => {
20
+ const userJar = request.jar();
21
+ const anonJar = request.jar();
22
+ const { protocol, hostname } = url.parse(nconf.get('url'));
23
+
24
+ describe('debug route', () => {
25
+ it('should 404 when no secret is set', async () => {
26
+ const response = await request(`${nconf.get('url')}/debug/session`, {
27
+ jar: userJar,
28
+ resolveWithFullResponse: true,
29
+ simple: false,
30
+ });
31
+
32
+ assert.strictEqual(response.statusCode, 404);
33
+
34
+ await meta.settings.setOne('session-sharing', 'secret', 's3cr37c47');
35
+ });
36
+
37
+ it('should generate a valid session when called', async () => {
38
+ const body = await request(`${nconf.get('url')}/debug/session`, {
39
+ jar: userJar,
40
+ });
41
+ assert.strictEqual(body, 'OK');
42
+
43
+ const cookies = userJar.getCookies(`${protocol}//${hostname}/`);
44
+ assert(cookies);
45
+ assert(cookies.some(cookie => cookie.key === 'token'));
46
+ });
47
+ });
48
+
49
+ describe('token processing middleware', () => {
50
+ it('should automatically log in a user with a valid token', async () => {
51
+ const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
52
+ const response = await request(`${nconf.get('url')}`, {
53
+ resolveWithFullResponse: true,
54
+ jar: userJar,
55
+ });
56
+ assert.strictEqual(response.statusCode, 200);
57
+
58
+ const cookies = userJar.getCookies(`${protocol}//${hostname}/`);
59
+ assert(cookies);
60
+
61
+ const sidCookie = cookies.find(cookie => cookie.key === 'express.sid');
62
+ assert(sidCookie);
63
+
64
+ let sid = sidCookie.value.match(/s%3A([^.]+)/);
65
+ assert(sid && sid[1]);
66
+ sid = sid[1];
67
+ const sessionObj = await getSession(sid);
68
+
69
+ assert(sessionObj && sessionObj.passport && sessionObj.passport.user);
70
+ assert(parseInt(sessionObj.passport.user, 10) > 0);
71
+ });
72
+
73
+ it('should transparently pass-through as guest without a token', async () => {
74
+ const response = await request(`${nconf.get('url')}`, {
75
+ resolveWithFullResponse: true,
76
+ jar: anonJar,
77
+ });
78
+ assert.strictEqual(response.statusCode, 200);
79
+
80
+ const cookies = anonJar.getCookies(`${protocol}//${hostname}/`);
81
+ assert(cookies.every(cookie => cookie.key !== 'express.sid'));
82
+ });
83
+
84
+ it('should redirect a guest to a specified redirection target if configured', async () => {
85
+ await meta.settings.setOne('session-sharing', 'guestRedirect', 'https://example.org');
86
+ const response = await request(`${nconf.get('url')}`, {
87
+ resolveWithFullResponse: true,
88
+ jar: anonJar,
89
+ followRedirect: false,
90
+ simple: false,
91
+ });
92
+
93
+ assert(response.statusCode, 302);
94
+ assert.strictEqual(response.headers.location, 'https://example.org');
95
+ await meta.settings.setOne('session-sharing', 'guestRedirect', '');
96
+ });
97
+
98
+ it('should maintain the login if behaviour is "revalidate"', async () => {
99
+ const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
100
+ await meta.settings.setOne('session-sharing', 'behaviour', 'revalidate');
101
+
102
+ await request(`${nconf.get('url')}`, {
103
+ resolveWithFullResponse: true,
104
+ jar: userJar, // now with no token
105
+ followRedirect: false,
106
+ simple: false,
107
+ });
108
+
109
+ const cookies = userJar.getCookies(`${protocol}//${hostname}/`);
110
+ assert(cookies);
111
+
112
+ const sidCookie = cookies.find(cookie => cookie.key === 'express.sid');
113
+ assert(sidCookie);
114
+
115
+ let sid = sidCookie.value.match(/s%3A([^.]+)/);
116
+ assert(sid && sid[1]);
117
+ sid = sid[1];
118
+ const sessionObj = await getSession(sid);
119
+ assert(sessionObj && sessionObj.passport && sessionObj.passport.user);
120
+ assert(parseInt(sessionObj.passport.user, 10) > 0);
121
+ });
122
+
123
+ it('should log the user out if behaviour is "revalidate" and the token is gone', async () => {
124
+ const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
125
+
126
+ userJar.setCookie('token=', `${protocol}//${hostname}/`); // remove the token
127
+ await request(`${nconf.get('url')}`, {
128
+ resolveWithFullResponse: true,
129
+ jar: userJar, // now with no token
130
+ followRedirect: false,
131
+ simple: false,
132
+ });
133
+
134
+ const cookies = userJar.getCookies(`${protocol}//${hostname}/`);
135
+ assert(cookies);
136
+
137
+ const sidCookie = cookies.find(cookie => cookie.key === 'express.sid');
138
+ assert(sidCookie);
139
+
140
+ let sid = sidCookie.value.match(/s%3A([^.]+)/);
141
+ assert(sid && sid[1]);
142
+ sid = sid[1];
143
+ const sessionObj = await getSession(sid);
144
+
145
+ assert(sessionObj && (!sessionObj.passport || !sessionObj.passport.uid));
146
+
147
+ // Restore userJar's session again
148
+ await request(`${nconf.get('url')}/debug/session`, {
149
+ jar: userJar,
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('login override', () => {
155
+ it('should redirect a guest to a specified login override if configured', async () => {
156
+ await meta.settings.setOne('session-sharing', 'loginOverride', 'https://example.org/login');
157
+ const response = await request(`${nconf.get('url')}/login`, {
158
+ resolveWithFullResponse: true,
159
+ jar: anonJar,
160
+ followRedirect: false,
161
+ simple: false,
162
+ });
163
+
164
+ assert(response.statusCode, 302);
165
+ assert.strictEqual(response.headers.location, 'https://example.org/login');
166
+ });
167
+ });
168
+
169
+ describe('register override', () => {
170
+ it('should redirect a guest to a specified register override if configured', async () => {
171
+ await meta.settings.setOne('session-sharing', 'registerOverride', 'https://example.org/register');
172
+ const response = await request(`${nconf.get('url')}/register`, {
173
+ resolveWithFullResponse: true,
174
+ jar: anonJar,
175
+ followRedirect: false,
176
+ simple: false,
177
+ });
178
+
179
+ assert(response.statusCode, 302);
180
+ assert.strictEqual(response.headers.location, 'https://example.org/register');
181
+ });
182
+ });
183
+ });