Compare commits

...

15 Commits

Author SHA1 Message Date
D. Berge
2009d73a2b Fix action registration and unregistration 2025-08-13 17:03:00 +02:00
D. Berge
083ee812de Use cookies for authentication as a last resort.
Fixes #335
2025-08-13 16:54:38 +02:00
D. Berge
84510e8dc9 Add proper logging 2025-08-13 15:42:49 +02:00
D. Berge
7205ec42a8 Fix handler registration.
The way it was being done meant that unregisterHandlers would not
have worked.
2025-08-13 15:42:49 +02:00
D. Berge
73d85ef81f Fix scheduling of token refresh via websocket 2025-08-13 12:58:36 +02:00
D. Berge
6c4dc35461 Fix bad status on preplot lines tab
If there were no raw / final sequences on a line, planned sequences
would not show either.
2025-08-13 12:45:50 +02:00
D. Berge
a5ebff077d Fix authentication middleware erroring on IPv6 2025-08-13 11:50:20 +02:00
D. Berge
2a894692ce Throttle snack notifications 2025-08-12 00:22:09 +02:00
D. Berge
25690eeb52 Fix showSnack in main.js 2025-08-11 23:48:08 +02:00
D. Berge
3f9776b61d Let the user know when we're getting gateway errors 2025-08-11 23:47:25 +02:00
D. Berge
8c81daefc0 Move the two /configuration endpoints next to each other 2025-08-11 22:20:46 +02:00
D. Berge
c173610e87 Simplify middleware 2025-08-11 22:19:51 +02:00
D. Berge
301e5c0731 Set headers only on 304 2025-08-11 22:06:51 +02:00
D. Berge
48d9f45fe0 Clean up debug messages 2025-08-11 22:06:20 +02:00
D. Berge
cd23a78592 Merge branch '190-refactor-map' into 'devel'
Resolve "Refactor map"

Closes #190, #322, #323, #324, #325, #326, and #321

See merge request wgp/dougal/software!25
2025-08-11 13:01:00 +00:00
18 changed files with 227 additions and 141 deletions

View File

@@ -92,18 +92,12 @@ export default {
this.$store.dispatch('registerHandler', {
table: '.jwt',
handler: (context, message) => {
this.handleJWT(context, message);
}
handler: this.handleJWT
});
this.$store.dispatch('registerHandler', {
table: 'project',
handler: (context, message) => {
this.handleProject(context, message);
}
handler: this.handleProject
});
},

View File

@@ -10,7 +10,10 @@
<v-spacer></v-spacer>
<template v-if="isFrontendRemote">
<v-icon v-if="serverConnected" class="mr-6" title="Connected to server via gateway">mdi-cloud-outline</v-icon>
<template v-if="serverConnected">
<v-icon v-if="isGatewayReliable" class="mr-6" title="Connected to server via gateway">mdi-cloud-outline</v-icon>
<v-icon v-else class="mr-6" color="orange" title="Gateway connection is unreliable. Expect outages.">mdi-cloud-off</v-icon>
</template>
<v-icon v-else class="mr-6" color="red" :title="`Server connection lost: the gateway cannot reach the remote server.\nWe will reconnect automatically when the link with the remote server is restored.`">mdi-cloud-off</v-icon>
</template>
<template v-else>
@@ -57,6 +60,13 @@ export default {
DougalNotificationsControl
},
data () {
return {
lastGatewayErrorTimestamp: 0,
gatewayErrorSilencePeriod: 60000,
}
},
computed: {
year () {
const date = new Date();
@@ -65,8 +75,24 @@ export default {
...mapState({
serverConnected: state => state.notify.serverConnected,
isFrontendRemote: state => state.api.serverInfo?.["remote-frontend"] ?? false
isFrontendRemote: state => state.api.serverInfo?.["remote-frontend"] ?? false,
isGatewayReliable: state => state.api.isGatewayReliable
})
},
watch: {
isGatewayReliable (val) {
if (val === false) {
const elapsed = Date.now() - this.lastGatewayErrorTimestamp;
const lastGatewayErrorTimestamp = Date.now();
if (elapsed > this.gatewayErrorSilencePeriod) {
this.$root.showSnack("Gateway error", "warning");
}
}
}
}
};
</script>

View File

@@ -1,8 +1,5 @@
<template>
<div class="line-status" v-if="sequences.length == 0">
<slot name="empty"></slot>
</div>
<div class="line-status" v-else-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<div class="line-status" v-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence" v-if="sequenceHref"
class="sequence"
:class="sequence.status"
@@ -26,7 +23,7 @@
>
</router-link>
</div>
<div class="line-status" v-else>
<div class="line-status" v-else-if="sequences.length || plannedSequences.length || Object.keys(pendingReshoots).length">
<div v-for="sequence in sequences" :key="sequence.sequence"
class="sequence"
:class="sequence.status"
@@ -47,6 +44,9 @@
>
</div>
</div>
<div class="line-status" v-else>
<slot name="empty"></slot>
</div>
</template>
<style lang="stylus" scoped>

View File

@@ -62,9 +62,7 @@ new Vue({
showSnack(text, colour = "primary") {
console.log("showSnack", text, colour);
this.snackColour = colour;
this.snackText = text;
this.snack = true;
this.$store.dispatch("showSnack", [text, colour]);
},
sendJwt () {

View File

@@ -71,7 +71,7 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb
res = await limiter.enqueue(async () => await fetch(url, init));
}
if (cache && !isCached) {
if (cache && !isCached && res.ok) { // Only cache successful responses
cache.put(url, res.clone());
}
@@ -95,6 +95,12 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb
return [key, value];
});
state.serverInfo = entries.length ? Object.fromEntries(entries) : {};
if (state.serverInfo["remote-frontend"]) {
state.isGatewayReliable = ![ 502, 503, 504 ].includes(res.status);
} else {
state.isGatewayReliable = null;
}
}
if (res.ok) {

View File

@@ -2,7 +2,8 @@ const state = () => ({
apiUrl: "/api",
requestsCount: 0,
maxConcurrent: 15,
serverInfo: {} // Contents of the last received X-Dougal-Server HTTP header
serverInfo: {}, // Contents of the last received X-Dougal-Server HTTP header
isGatewayReliable: null, // True if we start seeing HTTP 502504 responses
});
export default state;

View File

@@ -80,4 +80,4 @@ function processServerEvent({ commit, dispatch, state, rootState }, message) {
state.debouncedRunners[table](message);
}
export default { registerHandler, processServerEvent };
export default { registerHandler, unregisterHandler, processServerEvent };

View File

@@ -30,4 +30,10 @@ function UNREGISTER_HANDLER(state, { table, handler }) {
}
export default { setServerEvent, clearServerEvent, setServerConnectionState, REGISTER_HANDLER };
export default {
setServerEvent,
clearServerEvent,
setServerConnectionState,
REGISTER_HANDLER,
UNREGISTER_HANDLER
};

View File

@@ -29,21 +29,6 @@ async function logout ({ commit, dispatch }) {
commit('setPreferences', {});
}
function setCookie(context, {name, value, expiry, path}) {
if (!path) path = "/";
if (!value) value = "";
if (name) {
if (expiry) {
document.cookie = `${name}=${value}; expiry=${(new Date(expiry)).toUTCString()}; path=${path}`;
} else {
document.cookie = `${name}=${value}; path=${path}`;
}
} else {
console.warn(`seCookie: You must supply a name`);
}
}
function setCredentials({ state, commit, getters, dispatch, rootState }, { force, token, response } = {}) {
try {
let tokenValue = token;
@@ -61,6 +46,7 @@ function setCredentials({ state, commit, getters, dispatch, rootState }, { force
const decoded = jwt_decode(tokenValue);
commit('setToken', tokenValue);
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
commit('setCookie', {name: "JWT", value: tokenValue, expires: (decoded.exp??0)*1000});
console.log('Credentials refreshed at', new Date().toISOString());
} else {
@@ -71,6 +57,7 @@ function setCredentials({ state, commit, getters, dispatch, rootState }, { force
if (err.name === 'InvalidTokenError') {
commit('setToken', null);
commit('setUser', null);
commit('clearCookie', "JWT")
}
}
dispatch('loadUserPreferences');
@@ -105,7 +92,6 @@ async function loadUserPreferences({ state, commit }) {
export default {
login,
logout,
setCookie,
setCredentials,
saveUserPreference,
loadUserPreferences

View File

@@ -16,4 +16,18 @@ function setPreferences (state, preferences) {
state.preferences = preferences;
}
export default { setToken, setUser, setPreferences };
function setCookie (state, opts = {}) {
const name = opts.name ?? "JWT";
const value = opts.value ?? "";
const expires = opts.expires ? (new Date(opts.expires)) : (new Date(0));
const path = opts.path ?? "/";
const sameSite = opts.sameSite ?? "Lax";
document.cookie = `${name}=${value};path=${path};SameSite=${sameSite};expires=${expires.toUTCString()}`;
}
function clearCookie (state, name) {
setCookie(state, {name});
}
export default { setToken, setUser, setPreferences, setCookie, clearCookie };

View File

@@ -121,10 +121,12 @@ app.map({
get: [ mw.auth.access.read, mw.project.summary.get ],
},
'/project/:project/configuration': {
get: [ mw.project.configuration.get ], // Get project configuration
patch: [ mw.auth.access.edit, mw.project.configuration.patch ], // Modify project configuration
put: [ mw.auth.access.edit, mw.project.configuration.put ], // Overwrite configuration
},
'/project/:project/configuration/:path(*)?': {
get: [ mw.auth.access.read, mw.configuration.get ],
},
/*
* GIS endpoints
@@ -272,10 +274,6 @@ app.map({
'/project/:project/label/': {
get: [ mw.auth.access.read, mw.label.list ],
// post: [ mw.label.post ],
},
'/project/:project/configuration/:path(*)?': {
get: [ mw.auth.access.read, mw.configuration.get ],
// post: [ mw.auth.access.admin, mw.label.post ],
},
'/project/:project/info/:path(*)': {
get: [ mw.auth.operations, mw.auth.access.read, mw.info.get ],

View File

@@ -1,6 +1,7 @@
const { projectOrganisations, vesselOrganisations/*, orgAccess */} = require('../../../lib/db/project/organisations');
const ServerUser = require('../../../lib/db/user/User');
const { Organisations } = require('@dougal/organisations');
const { ERROR, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
/** Second-order function.
* Returns a middleware that checks if the user has access to
@@ -14,11 +15,7 @@ function operation (operation) {
if (req.params.project) {
const projectOrgs = new Organisations(await projectOrganisations(req.params.project));
const availableOrgs = projectOrgs.accessToOperation(operation).filter(user.organisations);
console.log("Operation: ", operation);
console.log("User: ", user.name);
console.log("User orgs: ", user.organisations);
console.log("Project orgs: ", projectOrgs);
console.log("Available orgs: ", availableOrgs);
DEBUG(`operation = ${operation}, user = ${user?.name}, user orgs = %j, project orgs = %j, availableOrgs = %j`, user.organisations.toJSON(), projectOrgs.toJSON(), availableOrgs.toJSON());
if (availableOrgs.length > 0) {
next();
return;
@@ -26,16 +23,13 @@ function operation (operation) {
} else {
const vesselOrgs = new Organisations(await vesselOrganisations());
const availableOrgs = vesselOrgs.accessToOperation(operation).filter(user.organisations);
console.log("Operation: ", operation);
console.log("User: ", user.name);
console.log("User orgs: ", user.organisations);
console.log("Vessel orgs: ", vesselOrgs);
console.log("Available orgs: ", availableOrgs);
DEBUG(`operation = ${operation}, user = ${user?.name}, user orgs = %j, vessel orgs = %j, availableOrgs = %j`, user.organisations.toJSON(), vesselOrgs.toJSON(), availableOrgs.toJSON());
if (availableOrgs.length > 0) {
next();
return;
}
}
DEBUG(`Access denied to operation ${operation}.`);
next({status: 403, message: "Access denied"});
}
}

View File

@@ -1,41 +1,123 @@
const dns = require('dns');
const { Netmask } = require('netmask');
const ipaddr = require('ipaddr.js');
const { isIPv6, isIPv4 } = require('net');
const cfg = require('../../../lib/config');
const jwt = require('../../../lib/jwt');
const user = require('../../../lib/db/user');
const ServerUser = require('../../../lib/db/user/User');
const { ERROR, WARNING, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
async function authorisedIP (req, res) {
const validIPs = await user.ip({active: true}); // Get all active IP logins
validIPs.forEach( i => i.$block = new Netmask(i.ip) );
validIPs.sort( (a, b) => b.$block.bitmask - a.$block.netmask ); // More specific IPs have precedence
for (const ip of validIPs) {
const block = ip.$block;
if (block.contains(req.ip)) {
const payload = {
...ip,
ip: req.ip,
autologin: true
};
delete payload.$block;
delete payload.hash;
delete payload.active;
jwt.issue(payload, req, res);
return true;
function parseIP(ip) {
if (!ip || typeof ip !== 'string') {
WARNING('Invalid IP input:', ip);
return null;
}
// Handle comma-separated X-Forwarded-For (e.g., "87.90.254.127,")
const cleanIp = ip.split(',')[0].trim();
if (!cleanIp) {
WARNING('Empty IP after parsing:', ip);
return null;
}
// Convert IPv6-mapped IPv4 (e.g., ::ffff:127.0.0.1 -> 127.0.0.1)
if (cleanIp.startsWith('::ffff:') && isIPv4(cleanIp.split('::ffff:')[1])) {
return cleanIp.split('::ffff:')[1];
}
return cleanIp;
}
function normalizeCIDR(range) {
if (!range || typeof range !== 'string') {
WARNING('Invalid CIDR range:', range);
return null;
}
// If no /prefix, assume /32 for IPv4 or /128 for IPv6
if (!range.includes('/')) {
try {
const parsed = ipaddr.parse(range);
const prefix = parsed.kind() === 'ipv4' ? 32 : 128;
return `${range}/${prefix}`;
} catch (err) {
WARNING(`Failed to parse bare IP ${range}:`, err.message);
return null;
}
}
return range;
}
async function authorisedIP(req, res) {
const ip = parseIP(req.ip || req.headers['x-forwarded-for'] || req.headers['x-real-ip']);
DEBUG('authorisedIP:', { ip, headers: req.headers }); // Debug
if (!ip) {
WARNING('No valid IP provided:', { ip, headers: req.headers });
return false;
}
let addr;
try {
addr = ipaddr.parse(ip);
} catch (err) {
WARNING('Invalid IP:', ip, err.message);
return false;
}
const validIPs = await user.ip({ active: true }); // Get active IP logins
// Attach parsed CIDR to each IP entry
validIPs.forEach(i => {
const normalized = normalizeCIDR(i.ip);
if (!normalized) {
i.$range = null;
return;
}
try {
const [rangeAddr, prefix] = ipaddr.parseCIDR(normalized);
i.$range = { addr: rangeAddr, prefix };
} catch (err) {
WARNING(`Invalid CIDR range ${i.ip}:`, err.message);
i.$range = null; // Skip invalid ranges
}
});
// Filter out invalid ranges and sort by specificity (descending prefix length)
const validRanges = validIPs.filter(i => i.$range).sort((a, b) => b.$range.prefix - a.$range.prefix);
for (const ipEntry of validRanges) {
const { addr: rangeAddr, prefix } = ipEntry.$range;
try {
if (addr.match(rangeAddr, prefix)) {
const payload = {
...ipEntry,
ip,
autologin: true
};
delete payload.$range;
delete payload.hash;
delete payload.active;
jwt.issue(payload, req, res);
return true;
}
} catch (err) {
WARNING(`Error checking range ${ipEntry.ip}:`, err.message);
continue;
}
}
return false;
}
async function authorisedHost (req, res) {
const validHosts = await user.host({active: true}); // Get all active host logins
async function authorisedHost(req, res) {
const ip = parseIP(req.ip || req.headers['x-forwarded-for'] || req.headers['x-real-ip']);
DEBUG('authorisedHost:', { ip, headers: req.headers }); // Debug
if (!ip) {
WARNING('No valid IP for host check:', { ip, headers: req.headers });
return false;
}
const validHosts = await user.host({ active: true });
for (const key in validHosts) {
try {
const ip = await dns.promises.resolve(key);
if (ip == req.ip) {
const resolvedIPs = await dns.promises.resolve(key);
if (resolvedIPs.includes(ip)) {
const payload = {
...validHosts[key],
ip: req.ip,
ip,
autologin: true
};
delete payload.$block;
@@ -45,49 +127,28 @@ async function authorisedHost (req, res) {
return true;
}
} catch (err) {
if (err.code != "ENODATA") {
console.error(err);
if (err.code !== 'ENODATA') {
ERROR(`DNS error for host ${key}:`, err);
}
}
}
return false;
}
// TODO: Check client TLS certificates
// Probably will do this via Nginx with
// ssl_verify_client optional;
// and then putting either of the
// $ssl_client_s_dn or $ssl_client_escaped_cert
// variables into an HTTP header for Node
// to check (naturally, it must be ensured
// that a user cannot just insert the header
// in a request).
async function auth (req, res, next) {
async function auth(req, res, next) {
if (res.headersSent) {
// Nothing to do, this request must have been
// handled already by another middleware.
return;
return; // Handled by another middleware
}
// Check for a valid JWT (already decoded by a previous
// middleware).
// Check for valid JWT
if (req.user) {
if (!req.user.autologin) {
// If this is not an automatic login, check if the token is in the
// second half of its lifetime. If so, reissue a new one, valid for
// another cfg.jwt.options.expiresIn seconds.
if (req.user.exp) {
const ttl = req.user.exp - Date.now()/1000;
if (ttl < cfg.jwt.options.expiresIn/2) {
const credentials = await ServerUser.fromSQL(null, req.user.id);
if (credentials) {
// Refresh token
payload = Object.assign({}, credentials.toJSON());
jwt.issue(Object.assign({}, credentials.toJSON()), req, res);
}
if (!req.user.autologin && req.user.exp) {
const ttl = req.user.exp - Date.now() / 1000;
if (ttl < cfg.jwt.options.expiresIn / 2) {
const credentials = await ServerUser.fromSQL(null, req.user.id);
if (credentials) {
const payload = Object.assign({}, credentials.toJSON());
jwt.issue(payload, req, res);
}
}
}
@@ -95,19 +156,27 @@ async function auth (req, res, next) {
return;
}
// Check if the IP is known to us
// Check IP and host
if (await authorisedIP(req, res)) {
next();
return;
}
// Check if the hostname is known to us
if (await authorisedHost(req, res)) {
next();
return;
}
next({status: 401, message: "Not authorised"});
// If *all* else fails, check if the user came with a cookie
// (see https://gitlab.com/wgp/dougal/software/-/issues/335)
if (req.cookies.JWT) {
const token = req.cookies.JWT;
delete req.cookies.JWT;
DEBUG("falling back to cookie-based authentication");
req.user = await jwt.checkValidCredentials({jwt: token});
return await auth(req, res, next);
}
next({ status: 401, message: 'Not authorised' });
}
module.exports = auth;

View File

@@ -23,9 +23,9 @@ function ifNoneMatch (req, res, next) {
if (cached) {
DEBUG("ETag match. Returning cached response (ETag: %s, If-None-Match: %s) for %s %s",
cached.etag, req.get("If-None-Match"), req.method, req.url);
setHeaders(res, cached.headers);
if (req.method == "GET" || req.method == "HEAD") {
res.status(304).send();
setHeaders(res, cached.headers);
res.status(304).end();
// No next()
} else if (!isIdempotentMethod(req.method)) {
res.status(412).send();

View File

@@ -10,7 +10,6 @@ function json (req, res, next) {
} else {
res.status(404).send({message: "Not found"});
}
next();
}
function yaml (req, res, next) {
@@ -19,7 +18,6 @@ function yaml (req, res, next) {
} else {
res.status(404).send({message: "Not found"});
}
next();
}
function csv (req, res, next) {
@@ -33,7 +31,6 @@ function csv (req, res, next) {
} else {
res.status(404).send({message: "Not found"});
}
next();
}
module.exports = async function (req, res, next) {
@@ -53,9 +50,10 @@ module.exports = async function (req, res, next) {
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();
next();
}
next();
} catch (err) {
console.error(err);
next(err);
}
}

View File

@@ -37,11 +37,11 @@
"debug": "^4.3.4",
"express": "^4.17.1",
"express-jwt": "^8.4.1",
"ipaddr.js": "^1.9.1",
"json2csv": "^5.0.6",
"jsonwebtoken": "^9.0.2",
"leaflet-headless": "git+https://git@gitlab.com/aaltronav/contrib/leaflet-headless.git#devel",
"marked": "^4.0.12",
"netmask": "^2.0.2",
"node-fetch": "^2.6.1",
"nunjucks": "^3.2.3",
"path-to-regexp": "^6.2.1",

View File

@@ -20,8 +20,10 @@ function start (server, pingInterval=30000) {
const exp = decoded?.exp;
if (exp) {
const timeout = (exp*1000 - Date.now()) / 2;
socket._jwtRefresh = setTimeout(() => refreshJwt(token), timeout);
console.log(`Scheduled JWT refresh in ${timeout/1000} seconds at time ${(new Date(Date.now() + timeout)).toISOString()}`);
if (!socket._jwtRefresh) {
socket._jwtRefresh = setTimeout(() => refreshJwt(token), timeout);
console.log(`Scheduled JWT refresh in ${timeout/1000} seconds at time ${(new Date(Date.now() + timeout)).toISOString()}`);
}
} else {
console.log("Token has no exp claim. Refresh not scheduled");
}
@@ -76,8 +78,8 @@ function start (server, pingInterval=30000) {
});
socket.on('close', () => {
if (socket._jwtTimeout) {
clearTimeout(socket._jwtTimeout);
if (socket._jwtRefresh) {
clearTimeout(socket._jwtRefresh);
}
});
});

26
package-lock.json generated
View File

@@ -5366,14 +5366,6 @@
"node": ">= 0.10"
}
},
"lib/www/client/source/node_modules/ipaddr.js": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"lib/www/client/source/node_modules/is-arrayish": {
"version": "0.2.1",
"dev": true,
@@ -9375,11 +9367,11 @@
"debug": "^4.3.4",
"express": "^4.17.1",
"express-jwt": "^8.4.1",
"ipaddr.js": "^1.9.1",
"json2csv": "^5.0.6",
"jsonwebtoken": "^9.0.2",
"leaflet-headless": "git+https://git@gitlab.com/aaltronav/contrib/leaflet-headless.git#devel",
"marked": "^4.0.12",
"netmask": "^2.0.2",
"node-fetch": "^2.6.1",
"nunjucks": "^3.2.3",
"path-to-regexp": "^6.2.1",
@@ -10180,13 +10172,6 @@
"node": ">= 0.6"
}
},
"lib/www/server/node_modules/netmask": {
"version": "2.0.2",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"lib/www/server/node_modules/nunjucks": {
"version": "3.2.4",
"license": "BSD-2-Clause",
@@ -15585,6 +15570,15 @@
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
"dev": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",