├── .gitattributes ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── commitlint.config.js ├── eslint.config.mjs ├── lib └── controllers.js ├── library.js ├── package-lock.json ├── package.json ├── plugin.json ├── static ├── lib │ ├── admin.js │ └── main.js └── templates │ └── admin │ └── plugins │ └── session-sharing.tpl ├── test └── index.js └── upgrades └── session_sharing_hash_to_zset.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | 217 | sftp-config.json 218 | node_modules/ 219 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sftp-config.json 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 NodeBB Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Session Sharing for NodeBB 2 | 3 | In a nutshell, this plugin allows you to share sessions between your application and NodeBB. You'll need to set a 4 | special cookie with a common domain, containing a JSON Web Token with user data. If sufficient, this plugin will 5 | handle the rest (user registration/login). 6 | 7 | ## How is this related to SSO? 8 | 9 | Single Sign-On allows a user to log into NodeBB through a third-party service. It is best (and most securely) 10 | achieved via OAuth2 provider, although other alternatives exist. An example of a single sign-on plugin is 11 | [nodebb-plugin-sso-facebook](https://github.com/julianlam/nodebb-plugin-sso-facebook). 12 | 13 | Single sign-on *does not* allow a session to become automatically created if a login is made to another site. 14 | This is the one misconception that people hold when thinking about SSO and session sharing. 15 | 16 | This session sharing plugin will allow NodeBB to automatically log in users (and optionally, log out users) 17 | if the requisite shared cookie is found (more on that below). 18 | 19 | You can use this plugin and single sign-on plugins together, but they won't be seamlessly integrated. 20 | 21 | ## How does this work? 22 | 23 | This plugin checks incoming requests for a **shared cookie** that is saved by your application when a user 24 | logs in. This cookie contains in its value, a specially crafted signed token containing unique identifying 25 | information for that user. 26 | 27 | If the user can be found in NodeBB, that user will be logged in. If not, then a user is created, and that 28 | unique indentifier is saved for future reference. 29 | 30 | ## How can I integrate my site with this plugin? 31 | 32 | When a user logs in, you'll need to save a cookie in their browser session for NodeBB. This cookie needs 33 | to have a couple special attributes: 34 | 35 | * The `HttpOnly` flag should be set for security, otherwise the shared cookie can be read by via AJAX/XHR 36 | * The `domain` should be set to the naked domain. That is, if your site is at `app.example.com`, and your 37 | forum is at `talk.example.com`, the cookie domain should be set to `example.com`. 38 | * The cookie name is up to you, and can be configured in this plugin's settings. *(Default: `token`)* 39 | * The cookie value is a [JSON Web Token](https://jwt.io/). See below. 40 | 41 | ### Generating the JSON Web Token 42 | 43 | A list of compatible libraries can be obtained on the main website for [JSON Web Tokens](https://jwt.io/). 44 | 45 | You'll be encoding a payload that looks something like this... 46 | 47 | ``` json 48 | { 49 | "id": 123, 50 | "username": "foobar" 51 | } 52 | ``` 53 | 54 | ... into this JSON Web Token (using a secret of `secret`)... 55 | 56 | ``` 57 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzLCJ1c2VybmFtZSI6ImZvb2JhciJ9.b45U-9GfCZ203-pMAtIgTbTm0PfKRZwpI_cpugtDWVM 58 | ``` 59 | 60 | **Note**: Don't use `secret` as your secret! 61 | 62 | You are required to pass in at least `id` and `username`. 63 | 64 | You can also add `email`, `firstName`, `lastName`, `picture` to the payload if you'd like. If you specify 65 | `firstName` or `lastName`, `username` is no longer required. These values don't have to match exactly, 66 | you can customise the property names in the plugin settings. 67 | 68 | Additionally, if group syncing is enabled, you can specify `groups` and list groups that the user is in. 69 | They will be joined (or left) automatically based on what is found in the payload. 70 | 71 | **Continuing on...** Encode the payload with a secret of your choice, and configure the plugin by specifying the secret, so 72 | it can properly decode and verify the JWT signature. 73 | 74 | **Note**: In some libraries, the payload is encoded like so: 75 | 76 | ``` json 77 | { 78 | "d": { 79 | "email": "bob@example.com", 80 | "uid": "123", 81 | "username": "cheddar" 82 | }, 83 | "exp": 1454710044, 84 | "iat": 1452118044 85 | } 86 | ``` 87 | 88 | In which case, you can set the "Parent Key" setting in this plugin to `d`. 89 | 90 | ## Security 91 | 92 | Please note that according to the JWT spec, the payload itself is ***not encrypted***, only *signed*. That is, 93 | the Base64 Url Encoded payload is appended to the header. It can be decoded trivially (as base64 is not meant 94 | to be cryptographically secure), so **do not put any private information in the payload**. The header and 95 | payload themselves are *signed* against the secret, and NodeBB will only allow a JWT through if it has not been 96 | tampered with. That is, NodeBB will only continue with a login if the signature can be independently generated 97 | by the received payload and the secret. 98 | 99 | Use secure cookies transmitted via HTTPS if at all possible. 100 | 101 | ## Testing 102 | 103 | If you need to generate a fake token for testing, you can `GET /debug/session` while NodeBB is in development 104 | mode. NodeBB will then log in or create a user called "testUser", with the email "testUser@example.org". 105 | 106 | **Warning**: If you've configured the plugin to "revalidate" instead of "trust" (normally the default), you 107 | might accidentally lock yourself out of the administrative account as you won't have a proper cookie to 108 | authenticate with. To reset the plugin settings, delete the "settings:session-sharing" hash/document in 109 | your data store. In a pinch, running `./nodebb reset -p nodebb-plugin-session-sharing` will work to disable 110 | the plugin so you can log back in. 111 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['@commitlint/config-angular'], 5 | rules: { 6 | 'header-max-length': [1, 'always', 72], 7 | 'type-enum': [ 8 | 2, 9 | 'always', 10 | [ 11 | 'breaking', 12 | 'build', 13 | 'chore', 14 | 'ci', 15 | 'docs', 16 | 'feat', 17 | 'fix', 18 | 'perf', 19 | 'refactor', 20 | 'revert', 21 | 'style', 22 | 'test', 23 | ], 24 | ], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import serverConfig from 'eslint-config-nodebb'; 4 | import publicConfig from 'eslint-config-nodebb/public'; 5 | 6 | export default [ 7 | ...publicConfig, 8 | ...serverConfig, 9 | ]; 10 | 11 | -------------------------------------------------------------------------------- /lib/controllers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const groups = require.main.require('./src/groups'); 4 | 5 | const Controllers = {}; 6 | 7 | Controllers.renderAdminPage = async (req, res) => { 8 | const groupData = await groups.getGroupsFromSet('groups:visible:createtime', 0, -1); 9 | res.render('admin/plugins/session-sharing', { 10 | title: 'Session Sharing', 11 | groups: groupData, 12 | }); 13 | }; 14 | 15 | Controllers.retrieveUser = async (req, res) => { 16 | const main = module.parent.exports; 17 | const remoteId = req.query.id; 18 | 19 | if (!remoteId) { 20 | return res.status(400).json({ 21 | error: 'no-id-supplied', 22 | }); 23 | } 24 | 25 | try { 26 | const userObj = await main.getUser(remoteId); 27 | 28 | if (!userObj) { 29 | return res.sendStatus(404); 30 | } 31 | 32 | return res.status(200).json(userObj); 33 | } catch (error) { 34 | return res.status(500).json({ 35 | error: error.message, 36 | }); 37 | } 38 | }; 39 | 40 | Controllers.process = async (req, res) => { 41 | const main = module.parent.exports; 42 | 43 | if (!req.body || !req.body.token) { 44 | return res.status(400).json({ 45 | error: 'no-token-provided', 46 | }); 47 | } 48 | 49 | try { 50 | const uid = await main.process(req.body.token); 51 | 52 | return res.status(200).json({ 53 | uid, 54 | }); 55 | } catch (error) { 56 | return res.status(500).json({ 57 | error: error.message, 58 | }); 59 | } 60 | }; 61 | 62 | module.exports = Controllers; 63 | -------------------------------------------------------------------------------- /library.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const winston = module.parent.require('winston'); 4 | const nconf = module.parent.require('nconf'); 5 | 6 | const util = require('util'); 7 | 8 | const _ = require('lodash'); 9 | const jwt = require('jsonwebtoken'); 10 | 11 | const meta = require.main.require('./src/meta'); 12 | const user = require.main.require('./src/user'); 13 | const groups = require.main.require('./src/groups'); 14 | const SocketPlugins = require.main.require('./src/socket.io/plugins'); 15 | const db = require.main.require('./src/database'); 16 | const plugins = require.main.require('./src/plugins'); 17 | const routeHelpers = require.main.require('./src/routes/helpers'); 18 | 19 | const controllers = require('./lib/controllers'); 20 | const nbbAuthController = require.main.require('./src/controllers/authentication'); 21 | const logoutAsync = util.promisify((req, callback) => req.logout(callback)); 22 | 23 | /* all the user profile fields that can be passed to user.updateProfile */ 24 | const profileFields = [ 25 | 'username', 26 | 'email', 27 | 'fullname', 28 | 'website', 29 | 'location', 30 | 'groupTitle', 31 | 'birthday', 32 | 'signature', 33 | 'aboutme', 34 | ]; 35 | const payloadKeys = profileFields.concat([ 36 | 'id', // the uniq identifier of that account 37 | 'firstName', // for backwards compatibillity 38 | 'lastName', // dto. 39 | 'picture', 40 | 'groups', 41 | ]); 42 | 43 | const plugin = { 44 | ready: false, 45 | settings: { 46 | name: 'appId', 47 | cookieName: 'token', 48 | cookieDomain: undefined, 49 | secret: '', 50 | behaviour: 'trust', 51 | adminRevalidate: 'off', 52 | noRegistration: 'off', 53 | payloadParent: undefined, 54 | allowBannedUsers: false, 55 | }, 56 | }; 57 | 58 | payloadKeys.forEach(function (key) { 59 | plugin.settings['payload:' + key] = key; 60 | }); 61 | 62 | plugin.defaults = Object.freeze({ ...plugin.settings }); 63 | 64 | plugin.init = async (params) => { 65 | const { router } = params; 66 | 67 | routeHelpers.setupAdminPageRoute(router, '/admin/plugins/session-sharing', controllers.renderAdminPage); 68 | 69 | router.get('/api/session-sharing/lookup', controllers.retrieveUser); 70 | router.post('/api/session-sharing/user', controllers.process); 71 | 72 | if (process.env.NODE_ENV === 'development') { 73 | router.get('/debug/session', plugin.generate); 74 | } 75 | 76 | await plugin.reloadSettings(); 77 | }; 78 | 79 | plugin.appendConfig = async (config) => { 80 | config.sessionSharing = { 81 | logoutRedirect: plugin.settings.logoutRedirect, 82 | loginOverride: plugin.settings.loginOverride, 83 | registerOverride: plugin.settings.registerOverride, 84 | editOverride: plugin.settings.editOverride, 85 | hostWhitelist: plugin.settings.hostWhitelist, 86 | }; 87 | 88 | return config; 89 | }; 90 | 91 | /* Websocket Listeners */ 92 | 93 | SocketPlugins.sessionSharing = {}; 94 | 95 | SocketPlugins.sessionSharing.showUserIds = async (socket, data) => { 96 | // Retrieve the hash and find matches 97 | const { uids } = data; 98 | 99 | if (!uids.length) { 100 | throw new Error('no-uids-supplied'); 101 | } 102 | 103 | return Promise.all( 104 | uids.map(async uid => db.getSortedSetRangeByScore(plugin.settings.name + ':uid', 0, -1, uid, uid)) 105 | ); 106 | }; 107 | 108 | SocketPlugins.sessionSharing.findUserByRemoteId = async (socket, data) => { 109 | if (!data.remoteId) { 110 | throw new Error('no-remote-id-supplied'); 111 | } 112 | 113 | return plugin.getUser(data.remoteId); 114 | }; 115 | 116 | /* End Websocket Listeners */ 117 | 118 | /* 119 | * Given a remoteId, show user data 120 | */ 121 | plugin.getUser = async (remoteId) => { 122 | const uid = await db.sortedSetScore(plugin.settings.name + ':uid', remoteId); 123 | 124 | if (!uid) { 125 | return; 126 | } 127 | 128 | return user.getUserFields(uid, ['username', 'userslug', 'picture']); 129 | }; 130 | 131 | plugin.process = async (token) => { 132 | const payload = await jwt.verify(token, plugin.settings.secret); 133 | const userData = await plugin.normalizePayload(payload); 134 | const [uid, isNewUser] = await plugin.findOrCreateUser(userData); 135 | await plugin.updateUserProfile(uid, userData, isNewUser); 136 | await plugin.updateUserGroups(uid, userData); 137 | await plugin.verifyUser(token, uid, isNewUser); 138 | return uid; 139 | }; 140 | 141 | plugin.normalizePayload = async (payload) => { 142 | const userData = {}; 143 | 144 | if (plugin.settings.payloadParent) { 145 | payload = payload[plugin.settings.payloadParent]; 146 | } 147 | 148 | if (typeof payload !== 'object') { 149 | winston.warn('[session-sharing] the payload is not an object', payload); 150 | throw new Error('payload-invalid'); 151 | } 152 | 153 | payloadKeys.forEach(function (key) { 154 | const propName = plugin.settings['payload:' + key]; 155 | if (payload.hasOwnProperty(propName)) { 156 | userData[key] = payload[propName]; 157 | } 158 | }); 159 | 160 | if (!userData.hasOwnProperty('id')) { 161 | winston.warn('[session-sharing] No user id was given in payload'); 162 | throw new Error('payload-invalid'); 163 | } 164 | const setFullname = userData.hasOwnProperty('fullname') || userData.hasOwnProperty('firstName') || userData.hasOwnProperty('lastName'); 165 | if (setFullname) { 166 | userData.fullname = (userData.fullname || [userData.firstName, userData.lastName].join(' ')).trim(); 167 | } 168 | 169 | if (!userData.username) { 170 | userData.username = userData.fullname; 171 | } 172 | 173 | /* strip username from illegal characters */ 174 | userData.username = userData.username.trim().replace(/[^'"\s\-.*0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+/, '-'); 175 | 176 | if (!userData.username) { 177 | winston.warn('[session-sharing] No valid username could be determined'); 178 | throw new Error('payload-invalid'); 179 | } 180 | 181 | if (userData.hasOwnProperty('groups') && !Array.isArray(userData.groups)) { 182 | winston.warn('[session-sharing] Array expected for `groups` in JWT payload. Ignoring.'); 183 | delete userData.groups; 184 | } 185 | 186 | winston.verbose('[session-sharing] Payload verified'); 187 | const data = await plugins.hooks.fire('filter:sessionSharing.normalizePayload', { 188 | payload: payload, 189 | userData: userData, 190 | }); 191 | 192 | return data.userData; 193 | }; 194 | 195 | plugin.verifyUser = async (token, uid, isNewUser) => { 196 | await plugins.hooks.fire('static:sessionSharing.verifyUser', { 197 | uid: uid, 198 | isNewUser: isNewUser, 199 | token: token, 200 | }); 201 | 202 | // Check ban state of user 203 | const isBanned = await user.bans.isBanned(uid); 204 | 205 | // Reject if banned and settings dont allow banned users to login 206 | if (isBanned && !plugin.settings.allowBannedUsers) { 207 | throw new Error('banned'); 208 | } 209 | }; 210 | 211 | plugin.findOrCreateUser = async (userData) => { 212 | const { id } = userData; 213 | let isNewUser = false; 214 | let userId = null; 215 | let queries = [db.sortedSetScore(plugin.settings.name + ':uid', userData.id)]; 216 | 217 | if (userData.email && userData.email.length) { 218 | queries = [...queries, db.sortedSetScore('email:uid', userData.email)]; 219 | } 220 | 221 | let [uid, mergeUid] = await Promise.all(queries); 222 | uid = parseInt(uid, 10); 223 | mergeUid = parseInt(mergeUid, 10); 224 | 225 | /* check if found something to work with */ 226 | if (uid && !isNaN(uid)) { 227 | try { 228 | /* check if the user with the given id actually exists */ 229 | const exists = await user.exists(uid); 230 | 231 | if (exists) { 232 | userId = uid; 233 | } else { 234 | /* reference is outdated, user got deleted */ 235 | await db.sortedSetRemove(plugin.settings.name + ':uid', id); 236 | } 237 | } catch (error) { 238 | /* ignore errors, but assume the user doesn't exist */ 239 | winston.warn('[session-sharing] Error while testing user existance', error); 240 | } 241 | } 242 | 243 | if (!userId && mergeUid && !isNaN(mergeUid)) { 244 | winston.info('[session-sharing] Found user via their email, associating this id (' + id + ') with their NodeBB account'); 245 | await db.sortedSetAdd(plugin.settings.name + ':uid', mergeUid, id); 246 | userId = mergeUid; 247 | } 248 | 249 | /* create the user from payload if necessary */ 250 | winston.debug('createUser?', !userId); 251 | if (!userId) { 252 | if (plugin.settings.noRegistration === 'on') { 253 | throw new Error('no-match'); 254 | } 255 | 256 | userId = await plugin.createUser(userData); 257 | isNewUser = true; 258 | } 259 | 260 | return [userId, isNewUser]; 261 | }; 262 | 263 | plugin.updateUserProfile = async (uid, userData, isNewUser) => { 264 | winston.debug('consider updateProfile?', isNewUser || plugin.settings.updateProfile === 'on'); 265 | let userObj = {}; 266 | 267 | /* even update the profile on a new account, since some fields are not initialized by NodeBB */ 268 | if (!isNewUser && plugin.settings.updateProfile !== 'on') { 269 | return; 270 | } 271 | 272 | const existingFields = await user.getUserFields(uid, profileFields); 273 | const obj = profileFields.reduce((result, field) => { 274 | if (typeof userData[field] !== 'undefined' && existingFields[field] !== userData[field]) { 275 | result[field] = userData[field]; 276 | } 277 | 278 | return result; 279 | }, {}); 280 | 281 | if (Object.keys(obj).length) { 282 | winston.debug('[session-sharing] Updating profile fields:', obj); 283 | obj.uid = uid; 284 | try { 285 | const { trustPayloadEmail } = await meta.settings.get('session-sharing'); 286 | let email = ''; 287 | if (trustPayloadEmail === 'on') { 288 | email = obj.email; 289 | delete obj.email; 290 | } 291 | userObj = await user.updateProfile(uid, obj); 292 | if (trustPayloadEmail && email) { 293 | await user.setUserField(uid, 'email', email); 294 | await user.email.confirmByUid(uid, 0); 295 | } 296 | 297 | // If it errors out, not that big of a deal, continue anyway. 298 | if (!userObj) { 299 | userObj = existingFields; 300 | } 301 | } catch (error) { 302 | winston.warn('[session-sharing] Unable to update profile information for uid: ' + uid + '(' + error.message + ')'); 303 | } 304 | } 305 | 306 | if (userData.picture) { 307 | await db.setObjectField('user:' + uid, 'picture', userData.picture); 308 | } 309 | }; 310 | 311 | plugin.updateUserGroups = async (uid, userData) => { 312 | if (!userData.groups || !Array.isArray(userData.groups)) { 313 | return; 314 | } 315 | 316 | // Retrieve user groups 317 | let [userGroups] = await groups.getUserGroupsFromSet('groups:createtime', [uid]); 318 | // Normalize user group data to just group names 319 | userGroups = userGroups.map(groupObj => groupObj.name); 320 | 321 | // Build join and leave arrays 322 | let join = userData.groups.filter(name => !userGroups.includes(name)); 323 | if (plugin.settings.syncGroupList === 'on') { 324 | join = join.filter(group => plugin.settings.syncGroups.includes(group)); 325 | } 326 | 327 | let leave = userGroups.filter((name) => { 328 | // `registered-users` is always a joined group 329 | if (name === 'registered-users') { 330 | return false; 331 | } 332 | 333 | return !userData.groups.includes(name); 334 | }); 335 | if (plugin.settings.syncGroupList === 'on') { 336 | leave = leave.filter(group => plugin.settings.syncGroups.includes(group)); 337 | } 338 | 339 | await executeJoinLeave(uid, join, leave); 340 | }; 341 | 342 | async function executeJoinLeave(uid, join, leave) { 343 | await Promise.all([ 344 | (async () => { 345 | if (plugin.settings.syncGroupJoin !== 'on') { 346 | return; 347 | } 348 | 349 | await Promise.all(join.map(name => groups.join(name, uid))); 350 | })(), 351 | (async () => { 352 | if (plugin.settings.syncGroupLeave !== 'on') { 353 | return; 354 | } 355 | 356 | await Promise.all(leave.map(name => groups.leave(name, uid))); 357 | })(), 358 | ]); 359 | } 360 | 361 | plugin.createUser = async (userData) => { 362 | winston.verbose('[session-sharing] No user found, creating a new user for this login'); 363 | 364 | const uid = await user.create(_.pick(userData, profileFields)); 365 | await db.sortedSetAdd(plugin.settings.name + ':uid', uid, userData.id); 366 | return uid; 367 | }; 368 | 369 | plugin.addMiddleware = async function ({ req, res }) { 370 | const { hostWhitelist, guestRedirect, editOverride, loginOverride, registerOverride } = await meta.settings.get('session-sharing'); 371 | 372 | if (hostWhitelist) { 373 | const hosts = hostWhitelist.split(',') || [hostWhitelist]; 374 | let whitelisted = false; 375 | for (const host of hosts) { 376 | if (req.headers.host.includes(host)) { 377 | whitelisted = true; 378 | break; 379 | } 380 | } 381 | 382 | if (!whitelisted) { 383 | return; 384 | } 385 | } 386 | 387 | function handleGuest(req, res) { 388 | if (guestRedirect && !req.originalUrl.startsWith(nconf.get('relative_path') + '/login?local=1')) { 389 | // If a guest redirect is specified, follow it 390 | res.redirect(guestRedirect.replace('%1', encodeURIComponent(req.protocol + '://' + req.get('host') + req.originalUrl))); 391 | } else if (res.locals.fullRefresh === true) { 392 | res.redirect(nconf.get('relative_path') + req.url); 393 | } 394 | } 395 | 396 | // Only respond to page loads by guests, not api or asset calls 397 | const hasSession = req.hasOwnProperty('user') && req.user.hasOwnProperty('uid') && parseInt(req.user.uid, 10) > 0; 398 | const hasLoginLock = req.session.hasOwnProperty('loginLock'); 399 | 400 | if ( 401 | !plugin.ready || // plugin not ready 402 | (plugin.settings.behaviour === 'trust' && hasSession) || // user logged in + "trust" behaviour 403 | ((plugin.settings.behaviour === 'revalidate' || plugin.settings.behaviour === 'update') && hasLoginLock) || 404 | req.originalUrl.startsWith(nconf.get('relative_path') + '/api') // api routes 405 | ) { 406 | // Let requests through under "update" or "revalidate" behaviour only if they're logging in for the first time 407 | delete req.session.loginLock; // remove login lock for "update" or "revalidate" logins 408 | 409 | return; 410 | } 411 | 412 | if (editOverride && hasSession && req.originalUrl.match(/\/user\/.*\/edit(\/\w+)?$/)) { 413 | return res.redirect(editOverride.replace('%1', encodeURIComponent(req.protocol + '://' + req.get('host') + req.originalUrl))); 414 | } 415 | if (loginOverride && req.originalUrl.match(/\/login$/)) { 416 | return res.redirect(loginOverride.replace('%1', encodeURIComponent(req.protocol + '://' + req.get('host') + req.originalUrl))); 417 | } 418 | if (registerOverride && req.originalUrl.match(/\/register$/)) { 419 | return res.redirect(registerOverride.replace('%1', encodeURIComponent(req.protocol + '://' + req.get('host') + req.originalUrl))); 420 | } 421 | 422 | // Hook into ip blacklist functionality in core 423 | try { 424 | await meta.blacklist.test(req.ip); 425 | } catch (error) { 426 | if (hasSession) { 427 | await logoutAsync(req); 428 | res.locals.fullRefresh = true; 429 | } 430 | 431 | await plugin.cleanup({ res: res }); 432 | return handleGuest.call(null, req, res); 433 | } 434 | 435 | if (Object.keys(req.cookies).length && 436 | req.cookies.hasOwnProperty(plugin.settings.cookieName) && 437 | req.cookies[plugin.settings.cookieName].length) { 438 | try { 439 | const uid = await plugin.process(req.cookies[plugin.settings.cookieName]); 440 | if (uid === req.uid) { 441 | winston.verbose(`[session-sharing] Re-validated login for uid ${uid}, path ${req.originalUrl}`); 442 | return; 443 | } 444 | 445 | winston.verbose('[session-sharing] Processing login for uid ' + uid + ', path ' + req.originalUrl); 446 | await nbbAuthController.doLogin(req, uid); 447 | 448 | req.session.loginLock = true; 449 | const url = req.session.returnTo || req.originalUrl.replace(nconf.get('relative_path'), ''); 450 | delete req.session.returnTo; 451 | res.redirect(nconf.get('relative_path') + url); 452 | } catch (error) { 453 | let handleAsGuest = false; 454 | 455 | switch (error.message) { 456 | case 'payload-invalid': 457 | winston.warn('[session-sharing] The passed-in payload was invalid and could not be processed'); 458 | break; 459 | case 'no-match': 460 | winston.info('[session-sharing] Payload valid, but local account not found. Assuming guest.'); 461 | handleAsGuest = true; 462 | break; 463 | default: 464 | winston.warn('[session-sharing] Error encountered while parsing token: ' + error.message); 465 | break; 466 | } 467 | 468 | const data = await plugins.hooks.fire('filter:sessionSharing.error', { 469 | error, 470 | res: res, 471 | settings: plugin.settings, 472 | handleAsGuest: handleAsGuest, 473 | }); 474 | 475 | if (data.handleAsGuest) { 476 | return handleGuest.call(error, req, res); 477 | } 478 | 479 | throw error; 480 | } 481 | } else if (hasSession) { 482 | // Has login session but no cookie, can assume "revalidate" behaviour 483 | const isAdmin = await user.isAdministrator(req.user.uid); 484 | 485 | if (plugin.settings.behaviour !== 'update' && (plugin.settings.adminRevalidate === 'on' || !isAdmin)) { 486 | winston.verbose(`[session-sharing] Found login session but no cookie, logging out user (was uid ${req.uid})`); 487 | await logoutAsync(req); 488 | res.locals.fullRefresh = true; 489 | return handleGuest(req, res); 490 | } 491 | } else { 492 | return handleGuest.call(null, req, res); 493 | } 494 | }; 495 | 496 | plugin.cleanup = async (data) => { 497 | if (plugin.settings.cookieDomain) { 498 | winston.verbose('[session-sharing] Clearing cookie'); 499 | data.res.clearCookie(plugin.settings.cookieName, { 500 | domain: plugin.settings.cookieDomain, 501 | path: '/', 502 | }); 503 | } 504 | 505 | data.res.clearCookie('nbb_token', { 506 | domain: plugin.settings.cookieDomain, 507 | path: '/', 508 | }); 509 | 510 | return true; 511 | }; 512 | 513 | plugin.generate = function (req, res) { 514 | if (!plugin.ready) { 515 | return res.sendStatus(404); 516 | } 517 | 518 | let payload = {}; 519 | payload[plugin.settings['payload:id']] = 1; 520 | payload[plugin.settings['payload:username']] = 'testUser'; 521 | payload[plugin.settings['payload:email']] = 'testUser@example.org'; 522 | payload[plugin.settings['payload:firstName']] = 'Test'; 523 | payload[plugin.settings['payload:lastName']] = 'User'; 524 | payload[plugin.settings['payload:location']] = 'Testlocation'; 525 | payload[plugin.settings['payload:birthday']] = '04/01/1981'; 526 | payload[plugin.settings['payload:website']] = 'nodebb.org'; 527 | payload[plugin.settings['payload:aboutme']] = 'I am just testing'; 528 | payload[plugin.settings['payload:signature']] = 'T User'; 529 | payload[plugin.settings['payload:groupTitle']] = 'TestUsers'; 530 | payload[plugin.settings['payload:groups']] = ['test-group']; 531 | 532 | if (plugin.settings.payloadParent || plugin.settings['payload:parent']) { 533 | const parentKey = plugin.settings.payloadParent || plugin.settings['payload:parent']; 534 | const newPayload = {}; 535 | newPayload[parentKey] = payload; 536 | payload = newPayload; 537 | } 538 | 539 | const token = jwt.sign(payload, plugin.settings.secret); 540 | res.cookie(plugin.settings.cookieName, token, { 541 | maxAge: 1000 * 60 * 60 * 24 * 21, 542 | httpOnly: true, 543 | domain: plugin.settings.cookieDomain, 544 | }); 545 | 546 | res.sendStatus(200); 547 | }; 548 | 549 | plugin.addAdminNavigation = async (header) => { 550 | header.plugins.push({ 551 | route: '/plugins/session-sharing', 552 | icon: 'fa-user-secret', 553 | name: 'Session Sharing', 554 | }); 555 | 556 | return header; 557 | }; 558 | 559 | plugin.reloadSettings = async (data) => { 560 | // If data argument is truthy, then it is the action hook from core 561 | if (data && data.plugin !== 'session-sharing') { 562 | return; 563 | } 564 | 565 | const settings = await meta.settings.get('session-sharing'); 566 | if (!settings.hasOwnProperty('secret') || !settings.secret.length) { 567 | winston.error('[session-sharing] JWT Secret not found, session sharing disabled.'); 568 | return; 569 | } 570 | 571 | // If "payload:parent" is found, but payloadParent is not, update the latter and delete the former 572 | if (!settings.payloadParent && settings['payload:parent']) { 573 | winston.verbose('[session-sharing] Migrating payload:parent to payloadParent'); 574 | settings.payloadParent = settings['payload:parent']; 575 | await db.setObjectField('settings:session-sharing', 'payloadParent', settings.payloadParent); 576 | await db.deleteObjectField('settings:session-sharing', 'payload:parent'); 577 | } 578 | 579 | if (!settings['payload:username'] && !settings['payload:firstName'] && !settings['payload:lastName'] && !settings['payload:fullname']) { 580 | settings['payload:username'] = 'username'; 581 | } 582 | 583 | winston.info('[session-sharing] Settings OK'); 584 | plugin.settings = _.defaults(_.pickBy(settings, Boolean), plugin.defaults); 585 | plugin.ready = true; 586 | }; 587 | 588 | plugin.appendTemplate = async (data) => { 589 | if (!data.req.session || !data.req.session.sessionSharing || !data.req.session.sessionSharing.banned) { 590 | return data; 591 | } 592 | 593 | const info = await user.getLatestBanInfo(data.req.session.sessionSharing.uid); 594 | 595 | data.templateData.sessionSharingBan = { 596 | ban: info, 597 | banned: true, 598 | }; 599 | 600 | delete data.req.session.sessionSharing; 601 | return data; 602 | }; 603 | 604 | plugin.saveReverseToken = async ({ req, userData: data }) => { 605 | if (!plugin.ready || !data || plugin.settings.reverseToken !== 'on') { 606 | return; // no reverse token if secret not set 607 | } 608 | 609 | const { res } = req; 610 | const userData = await user.getUserFields(data.uid, ['uid', 'username', 'picture', 'reputation', 'postcount', 'banned']); 611 | userData.groups = (await groups.getUserGroups([data.uid])).pop(); 612 | const token = jwt.sign(userData, plugin.settings.secret); 613 | 614 | res.cookie('nbb_token', token, { 615 | maxAge: meta.getSessionTTLSeconds() * 1000, 616 | httpOnly: true, 617 | domain: plugin.settings.cookieDomain, 618 | }); 619 | 620 | winston.info(`[plugins/session-sharing] Saving reverse cookie for uid ${userData.uid}, session: ${req.session.id}`); 621 | }; 622 | 623 | module.exports = plugin; 624 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodebb-plugin-session-sharing", 3 | "version": "7.2.3", 4 | "description": "Allows login sessions from your app to persist in NodeBB", 5 | "main": "library.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/julianlam/nodebb-plugin-session-sharing" 9 | }, 10 | "scripts": { 11 | "lint": "eslint ." 12 | }, 13 | "keywords": [ 14 | "nodebb", 15 | "plugin" 16 | ], 17 | "author": { 18 | "name": "Julian Lam", 19 | "email": "julian@nodebb.org" 20 | }, 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/julianlam/nodebb-plugin-session-sharing/issues" 24 | }, 25 | "readmeFilename": "README.md", 26 | "nbbpm": { 27 | "compatibility": "^3.2.0 || ^4.x" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "lint-staged", 32 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 33 | } 34 | }, 35 | "lint-staged": { 36 | "*.js": [ 37 | "eslint --fix", 38 | "git add" 39 | ] 40 | }, 41 | "dependencies": { 42 | "@commitlint/cli": "^9.1.2", 43 | "async": "^3", 44 | "jsonwebtoken": "^8.5.1", 45 | "lint-staged": "^10.0.9", 46 | "lodash": "^4.17.14" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^9.1.2", 50 | "@commitlint/config-angular": "^7.1.2", 51 | "eslint": "9.26.0", 52 | "eslint-config-nodebb": "^1.1.4", 53 | "husky": "^2.4.0", 54 | "lint-staged": "^10.0.9", 55 | "request": "^2.88.2", 56 | "request-promise-native": "^1.0.9" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nodebb-plugin-session-sharing", 3 | "url": "https://github.com/julianlam/nodebb-plugin-session-sharing", 4 | "library": "./library.js", 5 | "hooks": [ 6 | { "hook": "response:router.page", "method": "addMiddleware" }, 7 | { "hook": "static:app.load", "method": "init" }, 8 | { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }, 9 | { "hook": "static:user.loggedOut", "method": "cleanup" }, 10 | { "hook": "filter:config.get", "method": "appendConfig" }, 11 | { "hook": "filter:middleware.render", "method": "appendTemplate" }, 12 | { "hook": "action:settings.set", "method": "reloadSettings" }, 13 | { "hook": "action:login.continue", "method": "saveReverseToken" } 14 | ], 15 | "scripts": [ 16 | "static/lib/main.js" 17 | ], 18 | "modules": { 19 | "../admin/plugins/session-sharing.js": "./static/lib/admin.js" 20 | }, 21 | "upgrades": [ 22 | "upgrades/session_sharing_hash_to_zset.js" 23 | ], 24 | "templates": "static/templates" 25 | } 26 | -------------------------------------------------------------------------------- /static/lib/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | define('admin/plugins/session-sharing', ['settings'], function (Settings) { 4 | var ACP = {}; 5 | 6 | ACP.init = function () { 7 | Settings.load('session-sharing', $('.session-sharing-settings')); 8 | 9 | $('#save').on('click', function () { 10 | Settings.save('session-sharing', $('.session-sharing-settings')); 11 | }); 12 | 13 | $('#search').on('keyup', ACP.showUserId); 14 | $('#remote_search').on('keyup', ACP.findUserByRemoteId); 15 | }; 16 | 17 | ACP.showUserId = function () { 18 | if (ACP._searchDelay) { 19 | clearTimeout(ACP._searchDelay); 20 | delete ACP._searchDelay; 21 | } 22 | 23 | var element = $(this); 24 | 25 | ACP._searchDelay = setTimeout(function () { 26 | delete ACP._searchDelay; 27 | 28 | const resultEl = $('#result'); 29 | if (!element.val()) { 30 | return resultEl.text(''); 31 | } 32 | 33 | var qs = decodeURIComponent($.param({ 34 | query: element.val(), 35 | })); 36 | 37 | $.get(config.relative_path + '/api/admin/manage/users?' + qs) 38 | .then(function (results) { 39 | if (results.users.length) { 40 | socket.emit('plugins.sessionSharing.showUserIds', { 41 | uids: results.users.map(function (user) { 42 | return user.uid; 43 | }), 44 | }, function (err, remoteIds) { 45 | if (err) { 46 | resultEl.text('We encountered an error while servicing this request:' + err.message); 47 | } else { 48 | resultEl.empty(); 49 | results.users.forEach(function (userObj, idx) { 50 | resultEl.append('

Username: ' + userObj.username + '
NodeBB uid: ' + userObj.uid + '
Remote id: ' + (remoteIds[idx] || 'Not Found')); 51 | }); 52 | } 53 | }); 54 | } else { 55 | resultEl.text('No users matched your query'); 56 | } 57 | }) 58 | .fail(function (err) { 59 | $('#result').text('We encountered an error while servicing this request:' + err.message); 60 | }); 61 | }, 500); 62 | }; 63 | 64 | ACP.findUserByRemoteId = function () { 65 | if (ACP._searchDelay) { 66 | clearTimeout(ACP._searchDelay); 67 | delete ACP._searchDelay; 68 | } 69 | 70 | var element = $(this); 71 | 72 | ACP._searchDelay = setTimeout(function () { 73 | delete ACP._searchDelay; 74 | 75 | if (!element.val()) { 76 | return $('#local_result').text(''); 77 | } 78 | 79 | socket.emit('plugins.sessionSharing.findUserByRemoteId', { 80 | remoteId: element.val(), 81 | }, function (err, results) { 82 | if (!err && results) { 83 | $('#local_result').html( 84 | '

' + 87 | '
'); 88 | } else { 89 | $('#local_result').text('No users were found associated with that remote ID'); 90 | } 91 | }); 92 | }, 500); 93 | }; 94 | 95 | return ACP; 96 | }); 97 | -------------------------------------------------------------------------------- /static/lib/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | $(document).ready(function () { 4 | if (config.sessionSharing && config.sessionSharing.hostWhitelist) { 5 | var hosts = config.sessionSharing.hostWhitelist.split(',') || [config.sessionSharing.hostWhitelist]; 6 | var whitelisted = false; 7 | for (var host of hosts) { 8 | if (window && window.location && window.location.host && window.location.host.includes(host)) { 9 | whitelisted = true; 10 | break; 11 | } 12 | } 13 | 14 | if (!whitelisted) { 15 | console.log('[session-sharing] host not whitelisted', window && window.location && window.location.host); 16 | return; 17 | } 18 | } 19 | 20 | function isEditUrl(url) { 21 | return url.match(/^user\/.*\/edit(\/\w+)?$/); 22 | } 23 | 24 | $(window).on('action:app.loggedOut', function (evt, data) { 25 | if (config.sessionSharing.logoutRedirect) { 26 | data.next = config.sessionSharing.logoutRedirect; 27 | } 28 | }); 29 | 30 | $(window).on('action:ajaxify.end', function (e, data) { 31 | if (config.sessionSharing.editOverride) { 32 | if (isEditUrl(data.url)) { 33 | $('#content').html(''); 34 | redirect(config.sessionSharing.editOverride, e); 35 | } 36 | 37 | $('a[href^="/user/"][href$="/edit"]').off('click').on('click', redirectHandler(config.sessionSharing.editOverride)); 38 | } 39 | 40 | if (config.sessionSharing.registerOverride) { 41 | if (data.url === 'register') { 42 | $('#content').html(''); 43 | redirect(config.sessionSharing.registerOverride, e); 44 | } 45 | 46 | $('a[href="/register"]').off('click').on('click', redirectHandler(config.sessionSharing.registerOverride)); 47 | } 48 | 49 | if (config.sessionSharing.loginOverride) { 50 | const params = utils.params(); 51 | if (data.url === 'login' && params && !params.local) { 52 | $('#content').html(''); 53 | redirect(config.sessionSharing.loginOverride, e); 54 | } 55 | 56 | $('a[href="/login"]').off('click').on('click', redirectHandler(config.sessionSharing.loginOverride)); 57 | } 58 | 59 | if (ajaxify.data.sessionSharingBan) { 60 | bootbox.alert({ 61 | title: '[[error:user-banned]]', 62 | message: ajaxify.data.sessionSharingBan.ban.expiry > 0 ? 63 | '[[error:user-banned-reason-until, ' + ajaxify.data.sessionSharingBan.ban.expiry_readable + ', ' + ajaxify.data.sessionSharingBan.ban.reason + ']]' : 64 | '[[error:user-banned-reason, ' + ajaxify.data.sessionSharingBan.ban.reason + ']]', 65 | }); 66 | } 67 | 68 | window.localStorage.setItem('sessionSharingLastUrl', window.location.href); 69 | }); 70 | 71 | $(window).on('action:ajaxify.start', function (e, data) { 72 | if (config.sessionSharing.editOverride && isEditUrl(data.url)) { 73 | data.url = null; 74 | redirect(config.sessionSharing.editOverride, e); 75 | } 76 | 77 | if (config.sessionSharing.registerOverride && data.url.startsWith('register')) { 78 | data.url = null; 79 | redirect(config.sessionSharing.registerOverride, e); 80 | } 81 | const params = utils.params(); 82 | if (config.sessionSharing.loginOverride && data.url.startsWith('login') && params && !params.local) { 83 | data.url = null; 84 | redirect(config.sessionSharing.loginOverride, e); 85 | } 86 | 87 | window.localStorage.setItem('sessionSharingLastUrl', window.location.href); 88 | }); 89 | 90 | function redirectHandler(url) { 91 | return function (e) { 92 | redirect(url, e); 93 | }; 94 | } 95 | 96 | function redirect(url, e) { 97 | e.preventDefault(); 98 | e.stopPropagation(); 99 | 100 | const lastUrl = window.localStorage.getItem('sessionSharingLastUrl'); 101 | try { 102 | if (!lastUrl) { 103 | throw new Error('lastUrl is missing in localStorage'); 104 | } 105 | url = url.replace('%1', encodeURIComponent(lastUrl)); 106 | } catch (e) { 107 | const origin = window.location.origin; 108 | console.log('[session-sharing] cannot replace %1 with ' + lastUrl + ' using origin ' + origin + ' instead', e); 109 | url = url.replace('%1', encodeURIComponent(origin)); 110 | } 111 | 112 | console.log('[session-sharing] redirecting to: ' + url); 113 | window.location.href = url; 114 | } 115 | }); 116 | -------------------------------------------------------------------------------- /static/templates/admin/plugins/session-sharing.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
7 |
8 |
General
9 | 10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |

22 | Specifying the common cookie domain here will allow NodeBB to delete the common cookie when a user 23 | logs out of NodeBB. If not set (default), then the user will simply be logged in again as their 24 | common cookie still exists. This may actually be what you want. 25 |

26 |
27 |
28 | 29 | 30 |

31 | This value is the secret key you used to encode your JSON Web Token. NodeBB needs the same secret 32 | otherwise the JWT cannot be properly decoded. 33 |

34 |
35 |
36 | 37 | 38 |

39 | If set, session-sharing plugin works only on whitelisted domains. Separate with commas, for example: "localhost,test.domain.com". 40 |

41 |
42 |
43 | 44 |
45 |
Session Handling
46 | 47 |
48 | 49 | 54 |
55 | 56 |
57 | 58 | 61 |

62 | Administrators are exempt from the revalidate behaviour because a 63 | misconfiguration could lock them out of the admin panel. Enable this option to force 64 | administrators to also undergo cookie revalidation, and thereby increasing security. 65 |

66 |

67 | This option is disabled by default to allow for smoother setup. 68 |

69 |
70 |
71 | 72 | 75 |

76 | By default, an unrecognized user id found in a payload cookie will have a local NodeBB account automatically created for it. If enabled, 77 | that cookie will not resolve into a session and that client will remain a guest. 78 |

79 |
80 |
81 | 82 | 85 |
86 | Basic information such as username and id are required, while others are optional (first name, last name, etc.). Enable this setting to allow 87 | NodeBB to automatically sync up the local profile with the information provided. 88 |
89 |
90 |
91 | 92 | 95 |

96 | By default banned users arent logged in and an error is thrown. If enabled, banned users are logged in and placed in the banned group. 97 |

98 |
99 |
100 | 101 | 104 |
105 |
106 | 107 | 110 |
111 |
112 | 113 | 116 |
117 | 122 |
123 | 124 | 125 |

126 | If set, once a user logs out from NodeBB, they will be sent to this link. Setting this option may be useful if you'd like to trigger 127 | a session logout in your own application instead. 128 |

129 |
130 |
131 | 132 | 133 |

134 | If set, users clicking the "Login" button will be redirected to this link instead 135 |

136 |
137 |
138 | 139 | 140 |

141 | If set, users clicking the "Register" button will be redirected to this link instead 142 |

143 |
144 |
145 | 146 | 147 |

148 | If set, users clicking the "Edit Profile" button will be redirected to this link instead 149 |

150 |
151 |
152 | 153 |
154 |
Payload Keys
155 |

156 | In general, you should not need to change these values, as you should be adjusting your app's cookie's 157 | JWT payload keys to match the defaults. However if circumstances require you to have different values, 158 | you can change them here. 159 |

160 |

161 | Default values are shown as placeholders in the corresponding input fields. 162 |

163 | 164 |
165 | 166 | 167 |
168 |
169 | 170 | 171 |
172 |
173 | 174 | 177 |

178 | If enabled, the email provided will be automatically verified. 179 | Otherwise, the user will be emailed a confirmation link. 180 |

181 |
182 |
183 | 184 | 185 |

186 | The plugin will try to generate this value from the fullname, if no username is given. 187 |

188 |
189 |
190 | 191 | 192 |

193 | The plugin will use a combination of first name and last name, if no fullname is given. If given, the following two fields will be ignored. 194 |

195 |
196 |
197 | 198 | 199 |
200 |
201 | 202 | 203 |
204 |
205 | 206 | 207 |
208 |
209 | 210 | 211 |
212 |
213 | 214 | 215 |
216 |
217 | 218 | 219 |
220 |
221 | 222 | 223 |
224 |
225 | 226 | 227 |
228 |
229 | 230 | 231 |
232 |
233 | 234 | 235 |

236 | If your user data is contained in a subkey inside of the payload data, specify its key here. 237 | Otherwise, this plugin assumes the relevant data is at the root level. 238 |

239 |
240 |
241 | 242 |
243 |
Guest Handling
244 | 245 |
246 | 247 | 248 |

249 | Blank value disables guest redirection. 250 |

251 |

252 | %1 can be used as a placeholder for the link the user landed on (will be URL encoded) 253 |

254 |
255 |
256 | 257 |
258 |
Reverse Token
259 | 260 |
261 |
262 | 263 | 266 |
267 |

268 | If enabled, NodeBB will save a cookie called nbb_token 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. 269 |

270 |

271 | Similar to what is accepted by the session-sharing plugin, the reverse token is a signed JWT containing the logged in user's username and uid. Guests will not have an `nbb_token`. 272 |

273 |
274 |
275 | 276 |
277 |
Account Search
278 |
279 |
280 |
281 |
User Search
282 | 283 |

284 | Search for a username here to find their associated unique ID. 285 |

286 |

287 |
288 |
289 |
290 |
291 |
292 |
293 |
Remote ID Search
294 | 295 |

296 | Enter a remote ID here to find their NodeBB user profile. 297 |

298 |

299 |
300 |
301 |
302 |
303 |
304 |
305 | 306 | 307 |
308 |
309 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 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 | const db = require.main.require('./test/mocks/databasemock'); 14 | 15 | const nconf = require.main.require('nconf'); 16 | const meta = require.main.require('./src/meta'); 17 | 18 | describe('nodebb-plugin-session-sharing', () => { 19 | const userJar = request.jar(); 20 | const anonJar = request.jar(); 21 | const { protocol, hostname } = url.parse(nconf.get('url')); 22 | 23 | describe('debug route', () => { 24 | it('should 404 when no secret is set', async () => { 25 | const response = await request(`${nconf.get('url')}/debug/session`, { 26 | jar: userJar, 27 | resolveWithFullResponse: true, 28 | simple: false, 29 | }); 30 | 31 | assert.strictEqual(response.statusCode, 404); 32 | 33 | await meta.settings.setOne('session-sharing', 'secret', 's3cr37c47'); 34 | }); 35 | 36 | it('should generate a valid session when called', async () => { 37 | const body = await request(`${nconf.get('url')}/debug/session`, { 38 | jar: userJar, 39 | }); 40 | assert.strictEqual(body, 'OK'); 41 | 42 | const cookies = userJar.getCookies(`${protocol}//${hostname}/`); 43 | assert(cookies); 44 | assert(cookies.some(cookie => cookie.key === 'token')); 45 | }); 46 | }); 47 | 48 | describe('token processing middleware', () => { 49 | it('should automatically log in a user with a valid token', async () => { 50 | const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore)); 51 | const response = await request(`${nconf.get('url')}`, { 52 | resolveWithFullResponse: true, 53 | jar: userJar, 54 | }); 55 | assert.strictEqual(response.statusCode, 200); 56 | 57 | const cookies = userJar.getCookies(`${protocol}//${hostname}/`); 58 | assert(cookies); 59 | 60 | const sidCookie = cookies.find(cookie => cookie.key === 'express.sid'); 61 | assert(sidCookie); 62 | 63 | let sid = sidCookie.value.match(/s%3A([^.]+)/); 64 | assert(sid && sid[1]); 65 | sid = sid[1]; 66 | const sessionObj = await getSession(sid); 67 | 68 | assert(sessionObj && sessionObj.passport && sessionObj.passport.user); 69 | assert(parseInt(sessionObj.passport.user, 10) > 0); 70 | }); 71 | 72 | it('should transparently pass-through as guest without a token', async () => { 73 | const response = await request(`${nconf.get('url')}`, { 74 | resolveWithFullResponse: true, 75 | jar: anonJar, 76 | }); 77 | assert.strictEqual(response.statusCode, 200); 78 | 79 | const cookies = anonJar.getCookies(`${protocol}//${hostname}/`); 80 | assert(cookies.every(cookie => cookie.key !== 'express.sid')); 81 | }); 82 | 83 | it('should redirect a guest to a specified redirection target if configured', async () => { 84 | await meta.settings.setOne('session-sharing', 'guestRedirect', 'https://example.org'); 85 | const response = await request(`${nconf.get('url')}`, { 86 | resolveWithFullResponse: true, 87 | jar: anonJar, 88 | followRedirect: false, 89 | simple: false, 90 | }); 91 | 92 | assert(response.statusCode, 302); 93 | assert.strictEqual(response.headers.location, 'https://example.org'); 94 | await meta.settings.setOne('session-sharing', 'guestRedirect', ''); 95 | }); 96 | 97 | it('should maintain the login if behaviour is "revalidate"', async () => { 98 | const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore)); 99 | await meta.settings.setOne('session-sharing', 'behaviour', 'revalidate'); 100 | 101 | await request(`${nconf.get('url')}`, { 102 | resolveWithFullResponse: true, 103 | jar: userJar, // now with no token 104 | followRedirect: false, 105 | simple: false, 106 | }); 107 | 108 | const cookies = userJar.getCookies(`${protocol}//${hostname}/`); 109 | assert(cookies); 110 | 111 | const sidCookie = cookies.find(cookie => cookie.key === 'express.sid'); 112 | assert(sidCookie); 113 | 114 | let sid = sidCookie.value.match(/s%3A([^.]+)/); 115 | assert(sid && sid[1]); 116 | sid = sid[1]; 117 | const sessionObj = await getSession(sid); 118 | assert(sessionObj && sessionObj.passport && sessionObj.passport.user); 119 | assert(parseInt(sessionObj.passport.user, 10) > 0); 120 | }); 121 | 122 | it('should log the user out if behaviour is "revalidate" and the token is gone', async () => { 123 | const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore)); 124 | 125 | userJar.setCookie('token=', `${protocol}//${hostname}/`); // remove the token 126 | await request(`${nconf.get('url')}`, { 127 | resolveWithFullResponse: true, 128 | jar: userJar, // now with no token 129 | followRedirect: false, 130 | simple: false, 131 | }); 132 | 133 | const cookies = userJar.getCookies(`${protocol}//${hostname}/`); 134 | assert(cookies); 135 | 136 | const sidCookie = cookies.find(cookie => cookie.key === 'express.sid'); 137 | assert(sidCookie); 138 | 139 | let sid = sidCookie.value.match(/s%3A([^.]+)/); 140 | assert(sid && sid[1]); 141 | sid = sid[1]; 142 | const sessionObj = await getSession(sid); 143 | 144 | assert(sessionObj && (!sessionObj.passport || !sessionObj.passport.uid)); 145 | 146 | // Restore userJar's session again 147 | await request(`${nconf.get('url')}/debug/session`, { 148 | jar: userJar, 149 | }); 150 | }); 151 | }); 152 | 153 | describe('login override', () => { 154 | it('should redirect a guest to a specified login override if configured', async () => { 155 | await meta.settings.setOne('session-sharing', 'loginOverride', 'https://example.org/login'); 156 | const response = await request(`${nconf.get('url')}/login`, { 157 | resolveWithFullResponse: true, 158 | jar: anonJar, 159 | followRedirect: false, 160 | simple: false, 161 | }); 162 | 163 | assert(response.statusCode, 302); 164 | assert.strictEqual(response.headers.location, 'https://example.org/login'); 165 | }); 166 | }); 167 | 168 | describe('register override', () => { 169 | it('should redirect a guest to a specified register override if configured', async () => { 170 | await meta.settings.setOne('session-sharing', 'registerOverride', 'https://example.org/register'); 171 | const response = await request(`${nconf.get('url')}/register`, { 172 | resolveWithFullResponse: true, 173 | jar: anonJar, 174 | followRedirect: false, 175 | simple: false, 176 | }); 177 | 178 | assert(response.statusCode, 302); 179 | assert.strictEqual(response.headers.location, 'https://example.org/register'); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /upgrades/session_sharing_hash_to_zset.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const winston = require.main.require('winston'); 4 | 5 | const db = require.main.require('./src/database'); 6 | const batch = require.main.require('./src/batch'); 7 | const meta = require.main.require('./src/meta'); 8 | 9 | module.exports = { 10 | name: 'Convert remote-to-local user ID from hash to sorted set', 11 | timestamp: Date.UTC(2017, 9, 19), 12 | method: async function () { 13 | try { 14 | const progress = this.progress; 15 | // Reload plugin settings and grab appID setting 16 | const settings = await meta.settings.get('session-sharing'); 17 | winston.verbose('getting data'); 18 | 19 | if (!settings.secret) { 20 | // No secret set, skip upgrade as completed. 21 | return; 22 | } 23 | 24 | const pluginKey = (settings.name || 'appId') + ':uid'; 25 | // session-sharing is set up, execute upgrade 26 | const hashData = await db.getObject(pluginKey); 27 | 28 | await db.rename(pluginKey, 'backup:' + pluginKey); 29 | 30 | winston.verbose('constructing array'); 31 | const values = Object.keys(hashData); 32 | 33 | progress.total = values.length; 34 | winston.verbose('saving into db'); 35 | await batch.processArray(values, async (batchValues) => { 36 | progress.incr(batchValues.length); 37 | await db.sortedSetAdd(pluginKey, batchValues.map(v => hashData[v]), batchValues); 38 | }, { 39 | batch: 500, 40 | }); 41 | } catch (err) { 42 | if (err && err.message !== 'WRONGTYPE Operation against a key holding the wrong kind of value') { 43 | throw err; 44 | } 45 | } 46 | }, 47 | }; 48 | --------------------------------------------------------------------------------