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}); } } }, 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']) 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); 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. 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 }; 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 }; 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) => {