Merge branch '304-refresh-authentication-status-for-connected-users' into 'devel'

Resolve "Refresh authentication status for connected users"

Closes #304

See merge request wgp/dougal/software!56
This commit is contained in:
D. Berge
2024-05-01 08:23:14 +00:00
8 changed files with 113 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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