diff --git a/lib/www/client/source/src/main.js b/lib/www/client/source/src/main.js index 153c164..631b8f9 100644 --- a/lib/www/client/source/src/main.js +++ b/lib/www/client/source/src/main.js @@ -4,7 +4,7 @@ import router from './router' import store from './store' import vuetify from './plugins/vuetify' import vueDebounce from 'vue-debounce' -import { mapMutations } from 'vuex'; +import { mapMutations, mapActions } from 'vuex'; import { markdown, markdownInline } from './lib/markdown'; import { geometryAsString } from './lib/utils'; import { mapGetters } from 'vuex'; @@ -67,7 +67,14 @@ new Vue({ this.snack = true; }, + sendJwt () { + if (this.jwt) { + this.ws.send(JSON.stringify({ jwt: this.jwt })); + } + }, + initWs () { + if (this.ws) { console.log("WebSocket initWs already called"); return; @@ -77,11 +84,12 @@ new Vue({ this.ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); - this.setServerEvent(msg); + this.processServerEvent(msg); }); this.ws.addEventListener("open", (ev) => { console.log("WebSocket connection open", ev); + this.sendJwt() this.setServerConnectionState(true); }); @@ -107,14 +115,13 @@ new Vue({ } this.wsCredentialsCheckTimer = setInterval( () => { - this.ws.send(JSON.stringify({ - jwt: this.jwt - })); + this.sendJwt(); }, this.wsCredentialsCheckInterval); }, - ...mapMutations(['setServerEvent', 'setServerConnectionState']) + ...mapMutations(['setServerConnectionState']), + ...mapActions(['processServerEvent']) }, 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 c313948..6557b7f 100644 --- a/lib/www/client/source/src/store/modules/api/actions.js +++ b/lib/www/client/source/src/store/modules/api/actions.js @@ -37,6 +37,7 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb } // We also send Authorization: Bearer … if (getters.jwt) { + init.credentials = "include"; init.headers["Authorization"] = "Bearer "+getters.jwt; } if (typeof init.body != "string") { @@ -81,7 +82,9 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb if (res.ok) { if (!isCached) { - await dispatch('setCredentials'); + if (res.headers.has("x-jwt")) { + await dispatch('setCredentials', { response: res }); + } } try { 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 3bc78de..56966f6 100644 --- a/lib/www/client/source/src/store/modules/user/actions.js +++ b/lib/www/client/source/src/store/modules/user/actions.js @@ -1,7 +1,7 @@ import jwt_decode from 'jwt-decode'; import { User } from '@/lib/user'; -async function login ({commit, dispatch}, loginRequest) { +async function login ({ commit, dispatch }, loginRequest) { const url = "/login"; const init = { method: "POST", @@ -9,96 +9,111 @@ async function login ({commit, dispatch}, loginRequest) { "Content-Type": "application/json" }, body: loginRequest + }; + + const callback = async (err, res) => { + if (!err && res) { + const { token } = (await res.json()); + await dispatch('setCredentials', {token}); + } } - const res = await dispatch('api', [url, init]); - if (res && res.ok) { - await dispatch('setCredentials', {force: true}); - await dispatch('loadUserPreferences'); - } + + await dispatch('api', [url, init, callback]); + await dispatch('loadUserPreferences'); } -async function logout ({commit, dispatch}) { - commit('setCookie', null); +async function logout ({ commit, dispatch }) { + commit('setToken', null); commit('setUser', null); - // Should delete JWT cookie await dispatch('api', ["/logout"]); - - // Clear preferences commit('setPreferences', {}); } -function browserCookie (state) { - return document.cookie.split(/; */).find(i => /^JWT=.+/.test(i)); +function setCookie(context, {name, value, expiry, path}) { + if (!name) name = "JWT"; + if (!path) path = "/"; + if (!value) value = ""; + + if (expiry) { + document.cookie = `${name}=${value}; expiry=${(new Date(expiry)).toUTCString()}; path=${path}`; + } else { + document.cookie = `${name}=${value}; path=${path}`; + } } -function cookieChanged (cookie) { - return browserCookie != cookie; -} +function setCredentials({ state, commit, getters, dispatch, rootState }, { force, token, response } = {}) { + try { + let tokenValue = token; -function setCredentials ({state, commit, getters, dispatch, rootState}, {force, token} = {}) { - if (token || force || cookieChanged(state.cookie)) { - try { - const cookie = browserCookie(); - const decoded = (token ?? cookie) ? jwt_decode(token ?? cookie.split("=")[1]) : null; - commit('setCookie', (cookie ?? (token && ("JWT="+token))) || undefined); + if (!tokenValue && response?.headers?.get('x-jwt')) { + tokenValue = response.headers.get('x-jwt'); + } + + if (!tokenValue) { + console.log('No JWT found in token or response'); + return; + } + + if (force || tokenValue !== getters.jwt) { + const decoded = jwt_decode(tokenValue); + commit('setToken', tokenValue); commit('setUser', decoded ? new User(decoded, rootState.api.api) : null); - } catch (err) { - if (err.name == "InvalidTokenError") { - console.warn("Failed to decode", browserCookie()); + + if (tokenValue && decoded) { + if (decoded?.exp) { + dispatch('setCookie', {value: tokenValue, expiry: decoded.exp*1000}); + } else { + dispatch('setCookie', {value: tokenValue}); + } } else { - console.error("setCredentials", err); + // Clear the cookie + dispatch('setCookie', {value: "", expiry: 0}); } + + console.log('Credentials refreshed at', new Date().toISOString()); + } else { + console.log('JWT unchanged, skipping update'); + } + } catch (err) { + console.error('setCredentials error:', err.message, 'token:', token, 'response:', response?.headers?.get('x-jwt')); + if (err.name === 'InvalidTokenError') { + commit('setToken', null); + commit('setUser', null); } } dispatch('loadUserPreferences'); } -/** - * Save user preferences to localStorage and store. - * - * User preferences are identified by a key that gets - * prefixed with the user ID. The value can - * be anything that JSON.stringify can parse. - */ -function saveUserPreference ({state, commit}, [key, value]) { +function saveUserPreference({ state, commit }, [key, value]) { const k = `${state.user?.id}.${key}`; - if (value !== undefined) { localStorage.setItem(k, JSON.stringify(value)); - - const preferences = state.preferences; - preferences[key] = value; + const preferences = { ...state.preferences, [key]: value }; commit('setPreferences', preferences); } else { localStorage.removeItem(k); - - const preferences = state.preferences; + const preferences = { ...state.preferences }; delete preferences[key]; commit('setPreferences', preferences); } } -async function loadUserPreferences ({state, commit}) { - // Get all keys which are of interest to us +async function loadUserPreferences({ state, commit }) { const prefix = `${state.user?.id}`; - const keys = Object.keys(localStorage).filter( k => k.startsWith(prefix) ); - - // Build the preferences object + const keys = Object.keys(localStorage).filter(k => k.startsWith(prefix)); const preferences = {}; - keys.map(str => { + keys.forEach(str => { const value = JSON.parse(localStorage.getItem(str)); const key = str.split(".").slice(2).join("."); preferences[key] = value; }); - - // Commit it commit('setPreferences', preferences); } - export default { login, logout, + setCookie, setCredentials, saveUserPreference, loadUserPreferences 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 e7d5ac3..5611307 100644 --- a/lib/www/client/source/src/store/modules/user/getters.js +++ b/lib/www/client/source/src/store/modules/user/getters.js @@ -4,8 +4,12 @@ function user (state) { } function jwt (state) { - if (state.cookie?.startsWith("JWT=")) { - return state.cookie.substring(4); + return state.token; +} + +function cookie (state) { + if (state.token) { + return "JWT="+token; } } diff --git a/lib/www/client/source/src/store/modules/user/mutations.js b/lib/www/client/source/src/store/modules/user/mutations.js index 47e053d..c4a1f7b 100644 --- a/lib/www/client/source/src/store/modules/user/mutations.js +++ b/lib/www/client/source/src/store/modules/user/mutations.js @@ -1,6 +1,7 @@ -function setCookie (state, cookie) { - state.cookie = cookie; +function setToken (state, token) { + state.token = token; + localStorage?.setItem("jwt", token); } function setUser (state, user) { @@ -11,4 +12,4 @@ function setPreferences (state, preferences) { state.preferences = preferences; } -export default { setCookie, setUser, setPreferences }; +export default { setToken, setUser, setPreferences }; diff --git a/lib/www/client/source/src/store/modules/user/state.js b/lib/www/client/source/src/store/modules/user/state.js index ad7c9f9..d4f2db8 100644 --- a/lib/www/client/source/src/store/modules/user/state.js +++ b/lib/www/client/source/src/store/modules/user/state.js @@ -1,5 +1,5 @@ const state = () => ({ - cookie: null, + token: localStorage?.getItem("jwt") ?? null, user: null, preferences: {} }); diff --git a/lib/www/client/source/src/views/Login.vue b/lib/www/client/source/src/views/Login.vue index 8a03df7..b37c502 100644 --- a/lib/www/client/source/src/views/Login.vue +++ b/lib/www/client/source/src/views/Login.vue @@ -81,6 +81,13 @@ export default { await this.logout(); await this.login(this.credentials); + if (this.user) { + console.log("Login successful"); + // Should trigger auto-refresh over ws as well as authenticating the + // user over ws. + this.$root.sendJwt(); + } + if (this.user && !this.user.autologin) { this.$router.replace("/"); } else { diff --git a/lib/www/server/api/middleware/user/login.js b/lib/www/server/api/middleware/user/login.js index 75ab087..e3aaacf 100644 --- a/lib/www/server/api/middleware/user/login.js +++ b/lib/www/server/api/middleware/user/login.js @@ -6,8 +6,10 @@ async function login (req, res, next) { const {user, password} = req.body; const payload = await jwt.checkValidCredentials({user, password}); if (payload) { - jwt.issue(payload, req, res); - res.status(204).send(); + const token = jwt.issue(payload, req, res); + res.set("X-JWT", token); + res.set("Set-Cookie", `JWT=${token}`); // For good measure + res.status(200).send({token}); next(); return; } else { diff --git a/lib/www/server/lib/jwt.js b/lib/www/server/lib/jwt.js index be52035..4691388 100644 --- a/lib/www/server/lib/jwt.js +++ b/lib/www/server/lib/jwt.js @@ -43,7 +43,8 @@ function issue (payload, req, res) { } if (res) { - res.cookie("JWT", token, {maxAge: cfg.jwt.options.expiresIn*1000 || 0}); + res.set("X-JWT", token); + res.set("Set-Cookie", `JWT=${token}`); // For good measure } return token; @@ -51,5 +52,6 @@ function issue (payload, req, res) { module.exports = { checkValidCredentials, - issue + issue, + decode: JWT.decode };