Fix user authentication.

* Use X-JWT header for sending authentication info
  both from server to client and from client to server.
* Send token in body of login response.
* Also use Set-Cookie: JWT=… so that calls that are
  not issued directly by Dougal (e.g. Deck.gl layers
  with a URL `data` property) work without having to
  jump through hoops.

Closes #321
This commit is contained in:
D. Berge
2025-08-06 10:21:37 +02:00
parent 17b9d60715
commit be5c6f1fa3
9 changed files with 107 additions and 66 deletions

View File

@@ -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'])
},

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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 };

View File

@@ -1,5 +1,5 @@
const state = () => ({
cookie: null,
token: localStorage?.getItem("jwt") ?? null,
user: null,
preferences: {}
});

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
};