diff --git a/nodejs/app.js b/nodejs/app.js index cd06995..eb7d128 100644 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -10,6 +10,11 @@ const app = express(); app.set('view engine', 'ejs'); app.set('views', require('path').join(__dirname, 'views')); +// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs). +// Set as an app local so every res.render has it, including routes that don't +// spread the render router's `values` object. +app.locals.ui = require('./utils/ui'); + app.use(compression()); app.use(express.json()); app.use(express.urlencoded({extended: false})); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e9b0d71..c358626 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-jump-host", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-jump-host", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", @@ -22,7 +22,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "jq-repeat": "^2.2.0", - "jquery": "^3.7.1", + "jquery": "^4.0.0", "ldapts": "^8.1.8", "model-redis": "^1.6.0", "moment": "^2.30.1", @@ -1186,9 +1186,9 @@ } }, "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==", "license": "MIT" }, "node_modules/ldapts": { diff --git a/nodejs/package.json b/nodejs/package.json index 35c61b5..162f13c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -32,7 +32,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "jq-repeat": "^2.2.0", - "jquery": "^3.7.1", + "jquery": "^4.0.0", "ldapts": "^8.1.8", "model-redis": "^1.6.0", "moment": "^2.30.1", diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 804357f..a34ad78 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -18,3 +18,7 @@ body { .card-title{ font-weight: bold; } + +.group-required{ + display: none; +} diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 86eac3c..2b323a3 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -1,3 +1,12 @@ +// Shared client framework for the theta42 apps. +// +// This file is byte-identical across sso-manager-node, proxy and jump-host — +// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and +// the /api/user/me response), never from edits to this file. Edit all three +// copies together. +// +// jQuery 4 safe: no $.isFunction, no $.holdReady. + var app = {}; app.pubsub = (function(){ @@ -45,7 +54,7 @@ app.pubsub = (function(){ app.socket = (function(app){ // $.getScript('/socket.io/socket.io.js') // - + var socket; $(document).ready(function(){ socket = io({ @@ -75,10 +84,26 @@ app.socket = (function(app){ app.api = (function(app){ var baseURL = '/api/' - function post(url, data, callback){ - if(typeof callback !== 'function') callback = callback2; + // post/put/delete are dual-mode: pass a callback for the node-style + // (error, data, status) form, or omit it to get a Promise that resolves + // with the parsed body and rejects with the error body. get/options return + // the jqXHR, which is itself thenable, so `await app.api.get(...)` works. + + function body(method, url, data, callback){ + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ + $.ajax({ + type: method, + url: baseURL+url, + headers: { 'auth-token': app.auth.getToken() }, + data: JSON.stringify(data), + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); }); + }); + } return $.ajax({ - type: 'POST', + type: method, url: baseURL+url, headers:{ 'auth-token': app.auth.getToken() @@ -87,40 +112,37 @@ app.api = (function(app){ contentType: "application/json; charset=utf-8", dataType: "json", complete: function(res, text){ - callback ? callback( + callback( text !== 'success' ? res.statusText : null, JSON.parse(res.responseText), res.status - ) : function(){} + ); } }); } + function post(url, data, callback){ + return body('POST', url, data, callback); + } + function put(url, data, callback){ - if(typeof callback !== 'function') callback = callback2; - return $.ajax({ - type: 'PUT', - url: baseURL+url, - headers:{ - 'auth-token': app.auth.getToken() - }, - data: JSON.stringify(data), - contentType: "application/json; charset=utf-8", - dataType: "json", - complete: function(res, text){ - callback ? callback( - text !== 'success' ? res.statusText : null, - JSON.parse(res.responseText), - res.status - ) : function(){} - } - }); + return body('PUT', url, data, callback); } - function remove(url, callback, callback2){ - if(typeof callback !== 'function') callback = callback2; + function remove(url, callback){ + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ + $.ajax({ + type: 'DELETE', + url: baseURL+url, + headers: { 'auth-token': app.auth.getToken() }, + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); }); + }); + } return $.ajax({ - type: 'delete', + type: 'DELETE', url: baseURL+url, headers:{ 'auth-token': app.auth.getToken() @@ -128,11 +150,11 @@ app.api = (function(app){ contentType: "application/json; charset=utf-8", dataType: "json", complete: function(res, text){ - callback ? callback( + callback( text !== 'success' ? res.statusText : null, JSON.parse(res.responseText), res.status - ) : function(){} + ); } }); } @@ -179,7 +201,11 @@ app.api = (function(app){ })(app) app.auth = (function(app){ - var user = {} + // One in-flight/cached GET /api/user/me per page load. Every gating + // decision (nav items, per-view forceLogin, group-required elements) reads + // this same promise instead of re-fetching. + var userPromise = null; + function setToken(token){ localStorage.setItem('APIToken', token); } @@ -188,18 +214,95 @@ app.auth = (function(app){ return localStorage.getItem('APIToken'); } - function isLoggedIn(callback){ - if(getToken()){ - return app.api.get('user/me', function(error, data){ - // data now carries effective rights (isAdmin, global, domains). - if(!error) app.auth.user = app.auth.perms = data; - return callback(error, data); - }); - }else{ - callback(null, false); + async function getUser(){ + try{ + return await app.api.get('user/me'); + }catch(error){ + if(error && error.status === 401) return null; + throw error; } } + // Cached current user, or false when there's no token at all. Callers that + // need a fresh copy (after a login or a profile change) pass force. + function loadUser(force){ + if(force || !userPromise){ + userPromise = getToken() ? getUser() : Promise.resolve(null); + userPromise = userPromise.then(function(user){ + app.auth.user = app.auth.perms = user || null; + return user; + }); + } + return userPromise; + } + + // The apps report group membership two ways: sso-manager-node returns LDAP + // DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both + // normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag) + // is exposed as a synthetic `admin` group so one gating model covers both. + function groupCNs(user){ + var raw = (user && (user.memberOf || user.groups)) || []; + if(!Array.isArray(raw)) raw = [raw]; + var names = raw.map(function(group){ + return String(group).split(',')[0].replace(/^cn=/i, ''); + }); + if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin'); + return names; + } + + async function memberOf(groupNameToFind, user){ + user = user || await loadUser(); + if(!user) return false; + groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]; + + return groupCNs(user).some(function(group){ + return groupNameToFind.includes(group); + }); + } + + // True when the logged-in user is a global admin (per user/me). Sync — only + // meaningful once isLoggedIn/forceLogin has resolved. + function isAdmin(){ + return !!(app.auth.perms && app.auth.perms.isAdmin); + } + + // Dual-mode: returns a Promise resolving to the user (or false), and calls + // an optional node-style callback with the same result. + function isLoggedIn(callback){ + var promise = loadUser().then(function(user){ + return user || false; + }); + + if(typeof callback === 'function'){ + promise.then(function(user){ + callback(null, user); + }, function(error){ + callback(error, false); + }); + } + + return promise; + } + + function logIn(args, callback){ + app.api.post('auth/login', args, function(error, data){ + if(data.login){ + setToken(data.token); + } + loadUser(true); + callback(error, !!data.token); + }); + } + + // Clears the session only — the caller decides where to go next (the nav's + // Log Out button uses ui.logoutRedirect). + function logOut(callback){ + localStorage.removeItem('APIToken'); + userPromise = null; + app.auth.user = app.auth.perms = null; + if(typeof callback === 'function') callback(); + } + // Constrain a redirect target to a same-origin absolute path. Rejects // absolute URLs (open redirect), protocol-relative "//host" and "/\host", // and non-path schemes like "javascript:" (XSS). Falls back to "/". @@ -230,48 +333,66 @@ app.auth = (function(app){ return true; } - // True when the logged-in user is a global admin (per user/me). - function isAdmin(){ - return !!(app.auth.perms && app.auth.perms.isAdmin); - } - - function logIn(args, callback){ - app.api.post('auth/login', args, function(error, data){ - if(data.login){ - setToken(data.token); - } - callback(error, !!data.token); - }); - } - - function logOut(callback){ - localStorage.removeItem('APIToken'); - callback(); - } - - function forceLogin(){ - // jQuery 4 removed $.holdReady; rely on the redirect below to keep an - // unauthenticated user off the page instead of pausing document ready. - app.auth.isLoggedIn(function(error, isLoggedIn){ - if(error || !isLoggedIn){ - app.auth.logOut(function(){}) - var path = location.href.replace(location.origin, ''); - location.replace('/login?redirect=' + encodeURIComponent(path)); - } - }); + // Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or + // unauthorised user is kept off the page by a redirect / an error panel + // rather than by pausing document ready. + // + // `requiredGroups` is a group CN or an OR-list of them; the synthetic + // `admin` group covers the OIDC clients' isAdmin flag. + async function forceLogin(requiredGroups){ + var user = await loadUser(); + + if(!user){ + logOut(function(){}); + location.replace('/login?redirect=' + encodeURIComponent( + location.pathname + location.search + )); + return false; + } + + if(user.onboardingRequired && location.pathname !== '/onboarding'){ + location.replace('/onboarding'); + return false; + } + + if(requiredGroups && !await memberOf(requiredGroups, user)){ + app.util.actionMessage( + `