From d2f94dbb8892dabbf93dc25e37407d7692cbc702 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:05:48 +0200 Subject: [PATCH 1/7] Refactor JWT token verification --- lib/www/server/api/middleware/user/login.js | 25 ++++++---------- lib/www/server/lib/jwt.js | 33 +++++++++++++++++++-- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/lib/www/server/api/middleware/user/login.js b/lib/www/server/api/middleware/user/login.js index aea9aa5..6da6561 100644 --- a/lib/www/server/api/middleware/user/login.js +++ b/lib/www/server/api/middleware/user/login.js @@ -1,28 +1,21 @@ -const crypto = require('crypto'); const cfg = require('../../../lib/config'); const jwt = require('../../../lib/jwt'); async function login (req, res, next) { if (req.body) { const {user, password} = req.body; - if (user && password) { - const hash = crypto - .pbkdf2Sync(password, 'Dougal'+user, 10712, 48, 'sha512') - .toString('base64'); - for (const credentials of cfg._("global.users.login.user") || []) { - if (credentials.name == user && credentials.hash == hash) { - const payload = Object.assign({}, credentials); - delete payload.hash; - jwt.issue(payload, req, res); - res.status(204).send(); - next(); - return; - } - } + const payload = jwt.checkValidCredentials({user, password}); + if (payload) { + jwt.issue(payload, req, res); + res.status(204).send(); + next(); + return; + } else { next({status: 401, message: "Unauthorised"}); } + } else { + next({status: 400, message: "Bad request"}); } - next({status: 400, message: "Bad request"}); } module.exports = login; diff --git a/lib/www/server/lib/jwt.js b/lib/www/server/lib/jwt.js index 1824507..423cac8 100644 --- a/lib/www/server/lib/jwt.js +++ b/lib/www/server/lib/jwt.js @@ -1,9 +1,37 @@ -const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const JWT = require('jsonwebtoken'); const cfg = require('./config'); + +function checkValidCredentials ({user, password, jwt}) { + if (jwt) { + try { + const decoded = JWT.verify(jwt, cfg.jwt.secret, {maxAge: "1d"}); + delete decoded.iat; + delete decoded.exp; + return decoded; + } catch (err) { + console.warn("Failed to verify credentials for", jwt); + console.warn(err); + return; // Invalid JWT + } + } else if (user && password) { + const hash = crypto + .pbkdf2Sync(password, 'Dougal'+user, 10712, 48, 'sha512') + .toString('base64'); + for (const credentials of cfg._("global.users.login.user") || []) { + if (credentials.name == user && credentials.hash == hash) { + const payload = {...credentials}; + delete payload.hash; + return payload; + } + } + } +} + function issue (payload, req, res) { - const token = jwt.sign(payload, cfg.jwt.secret, cfg.jwt.options); + const token = JWT.sign(payload, cfg.jwt.secret, cfg.jwt.options); if (req) { req.user = payload; @@ -17,5 +45,6 @@ function issue (payload, req, res) { } module.exports = { + checkValidCredentials, issue }; From a9270157ea160bcfadd914a2703d368feaf83777 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:06:35 +0200 Subject: [PATCH 2/7] Process JWT messages over WebSockets --- lib/www/server/ws/index.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/www/server/ws/index.js b/lib/www/server/ws/index.js index c35e7e1..b1d0934 100644 --- a/lib/www/server/ws/index.js +++ b/lib/www/server/ws/index.js @@ -2,6 +2,7 @@ const ws = require('ws'); const URL = require('url'); const { listen } = require('../lib/db/notify'); const channels = require('../lib/db/channels'); +const jwt = require('../lib/jwt'); function start (server, pingInterval=30000) { @@ -9,7 +10,31 @@ function start (server, pingInterval=30000) { wsServer.on('connection', socket => { socket.alive = true; socket.on('pong', function () { this.alive = true; }) - socket.on('message', message => console.log(message)); + socket.on('message', message => { + // console.log("Websocket message:"); + // console.log(message); + try { + const payload = JSON.parse(message); + if (payload?.jwt) { + // console.log("Refresh JWT token", payload); + const decoded = jwt.checkValidCredentials({jwt: payload.jwt}); + // console.log("Decoded", decoded); + if (decoded) { + delete decoded.exp; + const token = jwt.issue(decoded); + socket.send(JSON.stringify({ + channel: ".jwt", + payload: { + token + } + })); + } + } + } catch (err) { + console.warn("Websocket message decoding failed", err); + } + + }); }); server.on('upgrade', (request, socket, head) => { From 7bd2319cd78af5438497f5dc27c63866695438c5 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:13:14 +0200 Subject: [PATCH 3/7] Allow setting credentials directly via the Vuex store. Until now, credentials were set indirectly by reading the browser's cookie store. This change allows us to receive credentials via other mechanisms, notably WebSockets. --- .../client/source/src/store/modules/user/actions.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/www/client/source/src/store/modules/user/actions.js b/lib/www/client/source/src/store/modules/user/actions.js index 2790136..38d0cff 100644 --- a/lib/www/client/source/src/store/modules/user/actions.js +++ b/lib/www/client/source/src/store/modules/user/actions.js @@ -11,7 +11,7 @@ async function login ({commit, dispatch}, loginRequest) { } const res = await dispatch('api', [url, init]); if (res && res.ok) { - await dispatch('setCredentials', true); + await dispatch('setCredentials', {force: true}); await dispatch('loadUserPreferences'); } } @@ -34,12 +34,12 @@ function cookieChanged (cookie) { return browserCookie != cookie; } -function setCredentials ({state, commit, getters, dispatch}, force = false) { - if (cookieChanged(state.cookie) || force) { +function setCredentials ({state, commit, getters, dispatch}, {force, token} = {}) { + if (token || force || cookieChanged(state.cookie)) { try { const cookie = browserCookie(); - const decoded = cookie ? jwt_decode(cookie.split("=")[1]) : null; - commit('setCookie', cookie); + const decoded = (token ?? cookie) ? jwt_decode(token ?? cookie.split("=")[1]) : null; + commit('setCookie', (cookie ?? (token && ("JWT="+token))) || undefined); commit('setUser', decoded); } catch (err) { if (err.name == "InvalidTokenError") { @@ -54,7 +54,7 @@ function setCredentials ({state, commit, getters, dispatch}, force = false) { /** * Save user preferences to localStorage and store. - * + * * User preferences are identified by a key that gets * prefixed with the user name and role. The value can * be anything that JSON.stringify can parse. From ea8ea1242922632fb380ec2952964547fc04ba07 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:14:55 +0200 Subject: [PATCH 4/7] Add JWT Vuex getter --- lib/www/client/source/src/store/modules/user/getters.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/www/client/source/src/store/modules/user/getters.js b/lib/www/client/source/src/store/modules/user/getters.js index ab1f4bd..caf3b96 100644 --- a/lib/www/client/source/src/store/modules/user/getters.js +++ b/lib/www/client/source/src/store/modules/user/getters.js @@ -3,6 +3,12 @@ function user (state) { return state.user; } +function jwt (state) { + if (state.cookie?.startsWith("JWT=")) { + return state.cookie.substring(4); + } +} + function writeaccess (state) { return state.user && ["user", "admin"].includes(state.user.role); } @@ -15,4 +21,4 @@ function preferences (state) { return state.preferences; } -export default { user, writeaccess, adminaccess, preferences }; +export default { user, jwt, writeaccess, adminaccess, preferences }; From 76a90df768dcf337e633f4f5b556f49cd0adcb25 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:15:26 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Send=20"Authorization:=20Bearer=20=E2=80=A6?= =?UTF-8?q?"=20on=20API=20requests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need this because we might have more recent credentials than those in the cookie store. --- .../source/src/store/modules/api/actions.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/www/client/source/src/store/modules/api/actions.js b/lib/www/client/source/src/store/modules/api/actions.js index 6be81d9..459f308 100644 --- a/lib/www/client/source/src/store/modules/api/actions.js +++ b/lib/www/client/source/src/store/modules/api/actions.js @@ -1,5 +1,5 @@ -async function api ({state, commit, dispatch}, [resource, init = {}, cb]) { +async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb]) { try { commit("queueRequest"); if (init && init.hasOwnProperty("body")) { @@ -9,9 +9,16 @@ async function api ({state, commit, dispatch}, [resource, init = {}, cb]) { "Content-Type": "application/json" } } - if (typeof init.body != "string") { - init.body = JSON.stringify(init.body); - } + } + if (!init.headers) { + init.headers = {}; + } + // We also send Authorization: Bearer … + if (getters.jwt) { + init.headers["Authorization"] = "Bearer "+getters.jwt; + } + if (typeof init.body != "string") { + init.body = JSON.stringify(init.body); } const url = /^https?:\/\//i.test(resource) ? resource : (state.apiUrl + resource); const res = await fetch(url, init); From c6b99563d93abb16ce1377e8500d6e19f7f35122 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:19:00 +0200 Subject: [PATCH 6/7] Send a request for new credentials at regular intervals. Every five minutes, a message is sent via WebSocket to ask the server for a refreshed JWT token. --- lib/www/client/source/src/main.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/www/client/source/src/main.js b/lib/www/client/source/src/main.js index fc4527c..d796281 100644 --- a/lib/www/client/source/src/main.js +++ b/lib/www/client/source/src/main.js @@ -7,6 +7,8 @@ import vueDebounce from 'vue-debounce' import { mapMutations } from 'vuex'; import { markdown, markdownInline } from './lib/markdown'; import { geometryAsString } from './lib/utils'; +import { mapGetters } from 'vuex'; + Vue.config.productionTip = false @@ -14,7 +16,7 @@ Vue.use(vueDebounce); Vue.filter('markdown', markdown); Vue.filter('markdownInline', markdownInline); -Vue.filter('position', (str, item, opts) => +Vue.filter('position', (str, item, opts) => str .replace(/@POS(ITION)?@/g, geometryAsString(item, opts) || "(position unknown)") .replace(/@DMS@/g, geometryAsString(item, {...opts, dms:true}) || "(position unknown)") @@ -32,10 +34,16 @@ new Vue({ user: null, wsUrl: "/ws", - ws: null + ws: null, + wsCredentialsCheckInterval: 300*1000, // ms + wsCredentialsCheckTimer: null } }, + computed: { + ...mapGetters(["jwt"]) + }, + methods: { markdown (value) { @@ -85,6 +93,17 @@ new Vue({ this.setServerConnectionState(false); }); + if (this.wsCredentialsCheckTimer) { + clearInterval(this.wsCredentialsCheckTimer); + this.wsCredentialsCheckTimer = null; + } + + this.wsCredentialsCheckTimer = setInterval( () => { + this.ws.send(JSON.stringify({ + jwt: this.jwt + })); + }, this.wsCredentialsCheckInterval); + }, ...mapMutations(['setServerEvent', 'setServerConnectionState']) From 2fb1c5fdcc95d3d920b455c5372e560c96386d87 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Wed, 1 May 2024 10:20:09 +0200 Subject: [PATCH 7/7] Process incoming JWT WebSocket messages --- lib/www/client/source/src/App.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/www/client/source/src/App.vue b/lib/www/client/source/src/App.vue index b5bb6d1..96b18ba 100644 --- a/lib/www/client/source/src/App.vue +++ b/lib/www/client/source/src/App.vue @@ -82,6 +82,8 @@ export default { if (event.channel == "project" && event.payload?.schema == "public") { // Projects changed in some way or another await this.refreshProjects(); + } else if (event.channel == ".jwt" && event.payload?.token) { + await this.setCredentials({token: event.payload?.token}); } } },