From aa3b3ed515076f311fe13f22f6ab1508fcc6fbae Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 23 Jul 2026 20:57:34 -0400 Subject: [PATCH] feat: rebuild web UI on the shared theta42 app stack; OIDC + local admin auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web management UI was a bespoke minimal theme with LDAP-bind login. Rebuild it to match the SSO Manager and Proxy — same stack, same look/feel, same auth model. The SSH bridge, audit, metrics, and access logic are unchanged; this is purely the web layer. Frontend (mirrors proxy/sso): - Express + EJS with the shared top.ejs/bottom.ejs shell, Bootstrap 5, jQuery, jq-repeat, FontAwesome, Socket.IO, and the shared app-base.js / val.js client framework (copied verbatim). Vendor libs served from node_modules via /static-modules; app assets via /static. - Dashboard / Sessions / Audit pages render in the common look/feel, loading data through the authenticated /api/* endpoints. Auth (mirrors proxy): - OIDC against the SSO (utils/oidc.js + routes/auth.js + models/oidc_state) plus a local anti-lockout admin (models/user_redis.js, bootstrapped from auth.adminUsers[0] / auth.localAdminPass). AuthToken sessions carry the group snapshot; middleware gates the data API on adminGroups or the local admin. New config: oidc{} + auth.adminUsers/localAdminPass. - /api/user/me drives the client login state; "Log in with SSO" hidden when oidc.enabled is false. Verified end to end: local admin login -> token -> /api/user/me isAdmin, metrics/sessions/audit 200 with token / 401 without / 401 bad password; static + page shells serve; 26 tests green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + README.md | 10 +- docs/architecture.md | 6 +- docs/installation.md | 3 +- nodejs/app.js | 47 +-- nodejs/bin/www | 17 +- nodejs/conf/base.js | 28 +- nodejs/middleware/auth.js | 72 ++-- nodejs/models/auth.js | 118 +++++++ nodejs/models/index.js | 11 +- nodejs/models/oidc_state.js | 31 ++ nodejs/models/session.js | 42 --- nodejs/models/token.js | 64 ++++ nodejs/models/user_redis.js | 123 +++++++ nodejs/package-lock.json | 487 ++++++++++++++++++++++++++- nodejs/package.json | 12 +- nodejs/public/css/app.css | 31 -- nodejs/public/css/styles.css | 20 ++ nodejs/public/favicon.svg | 17 + nodejs/public/img/theta42.svg | 51 +++ nodejs/public/js/app.js | 21 ++ nodejs/public/lib/js/app-base.js | 552 +++++++++++++++++++++++++++++++ nodejs/public/lib/js/val.js | 201 +++++++++++ nodejs/routes/api.js | 37 +-- nodejs/routes/auth.js | 127 +++++-- nodejs/routes/index.js | 39 --- nodejs/routes/jump.js | 35 ++ nodejs/routes/render.js | 46 +++ nodejs/routes/user.js | 17 + nodejs/utils/oidc.js | 127 +++++++ nodejs/utils/safe_redirect.js | 23 ++ nodejs/views/audit.ejs | 93 ++++-- nodejs/views/bottom.ejs | 28 +- nodejs/views/dashboard.ejs | 103 +++--- nodejs/views/login.ejs | 116 +++++-- nodejs/views/sessions.ejs | 39 ++- nodejs/views/top.ejs | 95 ++++-- secrets.js.example | 29 +- 38 files changed, 2550 insertions(+), 374 deletions(-) create mode 100644 nodejs/models/auth.js create mode 100644 nodejs/models/oidc_state.js delete mode 100644 nodejs/models/session.js create mode 100644 nodejs/models/token.js create mode 100644 nodejs/models/user_redis.js delete mode 100644 nodejs/public/css/app.css create mode 100755 nodejs/public/css/styles.css create mode 100644 nodejs/public/favicon.svg create mode 100644 nodejs/public/img/theta42.svg create mode 100644 nodejs/public/js/app.js create mode 100644 nodejs/public/lib/js/app-base.js create mode 100755 nodejs/public/lib/js/val.js mode change 100644 => 100755 nodejs/routes/auth.js delete mode 100644 nodejs/routes/index.js create mode 100644 nodejs/routes/jump.js create mode 100644 nodejs/routes/render.js create mode 100644 nodejs/routes/user.js create mode 100644 nodejs/utils/oidc.js create mode 100644 nodejs/utils/safe_redirect.js mode change 100644 => 100755 nodejs/views/login.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2fd41..1060129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.1.0] - 2026-07-23 + +### Changed +- **Rebuilt the web UI on the shared theta42 app stack** so it looks and behaves like the SSO Manager and Proxy: Express + EJS with the same `top.ejs`/`bottom.ejs` shell, Bootstrap 5, jQuery, jq-repeat, FontAwesome, the shared `app-base.js` client framework, and Socket.IO — replacing the bespoke minimal theme. Dashboard, Sessions, and Audit pages now render in the common look/feel. +- **Web-UI auth is now OIDC + a local anti-lockout admin** (the proxy's model), replacing the direct LDAP-bind login. Normal users log in through the SSO ("Log in with SSO"); a local `auth.adminUsers` account (bootstrapped on first boot, password from `auth.localAdminPass`) still works if the SSO is unreachable. Admin access is gated by `auth.adminGroups` or the local admin account. New config: `oidc` block + `auth.adminUsers`/`localAdminPass`. **Note:** the SSH bridge and its own LDAP auth are unchanged — this only affects the web management UI. + ## [1.0.1] - 2026-07-23 ### Fixed diff --git a/README.md b/README.md index 7ce2511..550fdbf 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,13 @@ The default SSH port is **2222** so the service needs no privilege. To listen on ## Web UI / API -`https://jump.example.com/` (behind the proxy) — admin login uses your LDAP -credentials and requires membership in `auth.adminGroups` (default -`app_sso_admin`). +`https://jump.example.com/` (behind the proxy) — built on the same +Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/) +and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the +rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO" +button) plus a **local anti-lockout admin** that works even if the SSO is +unreachable. Admin access requires membership in `auth.adminGroups` (default +`app_sso_admin`) or being the local `auth.adminUsers` account. - `GET /health` — open; `{status, activeSessions, version}` - `GET /api/sessions` — active sessions diff --git a/docs/architecture.md b/docs/architecture.md index b6ec287..c5dfbf1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,8 +98,10 @@ Byte counts per direction are tallied cheaply for the audit record. ## Web UI, API & audit -A small Express app on `:3002` (admin login via LDAP, gated by -`auth.adminGroups`) exposes: +An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and +look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a +local anti-lockout admin (`auth.adminUsers`), with admin access gated by +`auth.adminGroups`. It exposes: - `GET /health` — open; `{status, activeSessions, version}` - `GET /api/sessions` — active sessions diff --git a/docs/installation.md b/docs/installation.md index 417a453..23de338 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -94,7 +94,8 @@ Every key is documented in [`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example): `ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh` (`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts, -`maxSessions`), `web.port`, `auth.adminGroups`, and `redis`. +`maxSessions`), `web.port`, `oidc` (web-UI SSO login), `auth` +(`adminGroups` / `adminUsers` / `localAdminPass`), and `redis`. ## Verifying diff --git a/nodejs/app.js b/nodejs/app.js index a20197d..cd06995 100644 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -1,37 +1,42 @@ 'use strict'; -const path = require('path'); const express = require('express'); -const conf = require('@simpleworkjs/conf'); +const compression = require('compression'); -const registry = require('./services/session_registry'); -const { requireAdmin } = require('./middleware/auth'); -const buildInfo = require('./models/build_info'); +require('./models'); // wire model-redis + register models const app = express(); app.set('view engine', 'ejs'); -app.set('views', path.join(__dirname, 'views')); -app.use('/public', express.static(path.join(__dirname, 'public'))); +app.set('views', require('path').join(__dirname, 'views')); -// Open health check — no auth (used by Docker/compose + the proxy). -app.get('/health', (req, res) => { - res.json({ status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit }); +app.use(compression()); +app.use(express.json()); +app.use(express.urlencoded({extended: false})); + +// Page shells + static assets + /health (mostly unauthenticated; the client +// gates itself on /api/user/me and redirects to /login). +app.use('/', require('./routes/render')); + +// API — auth handled per-router inside (see routes/api.js). +app.use('/api', require('./routes/api')); + +// 404 +app.use((req, res, next) => { + const error = new Error('Not Found'); + error.status = 404; + next(error); }); -// Login routes (no session required). -app.use('/', require('./routes/auth')); - -// Everything else requires an admin session. -app.use(requireAdmin); -app.use('/api', require('./routes/api')); -app.use('/', require('./routes/index')); - +// Error handler — JSON for API, redirect to login for pages on 401. // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { - console.error(err); - if (req.path.startsWith('/api/')) return res.status(500).json({ error: err.message }); - res.status(500).render('login', { error: 'Internal error.', name: conf.name }); + const status = err.status || 500; + if(status >= 500) console.error(err); + if(req.path.startsWith('/api/')){ + return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'}); + } + res.status(status).send(err.message || 'Error'); }); module.exports = app; diff --git a/nodejs/bin/www b/nodejs/bin/www index f22237a..f37f312 100644 --- a/nodejs/bin/www +++ b/nodejs/bin/www @@ -2,24 +2,33 @@ 'use strict'; // Boots BOTH faces of the jump host: the SSH front door (services/ssh_server) -// and the web UI/API (app.js). One process, one redis, shared audit store. +// and the web UI/API (app.js + Socket.IO). One process, one redis, shared +// audit store. const http = require('http'); const conf = require('@simpleworkjs/conf'); +const { Server } = require('socket.io'); -require('../models'); // wire model-redis + register models +require('../models'); const app = require('../app'); +const middleware = require('../middleware/auth'); const sshServer = require('../services/ssh_server'); -// Web server const webPort = (conf.web && conf.web.port) || 3002; const server = http.createServer(app); + +// Socket.IO — the client framework (app-base.js) opens an authenticated socket. +// We don't push anything yet, but serving /socket.io keeps the shared front-end +// working exactly as it does in the sibling apps. +const io = new Server(server); +io.use(middleware.authIO); +app.io = io; + server.listen(webPort, () => { console.log(`[web] jump-host UI/API on :${server.address().port}`); }); -// SSH server sshServer.start(); function shutdown() { diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index 45c7fd9..a88f615 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -7,6 +7,7 @@ module.exports = { name: 'Jump Host', + logo: '/static/img/theta42.svg', // LDAP directory the users live in (same directory the SSO manages). // bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups, @@ -59,11 +60,32 @@ module.exports = { port: 3002, }, + // Web UI/API login. Same model as the proxy: OIDC against the SSO for + // normal users, plus a local anti-lockout admin that works even if the SSO + // is unreachable. OIDC endpoints + clientId/clientSecret live in the + // secrets file; enabled:false hides the "Log in with SSO" button. + oidc: { + enabled: false, + issuer: '', + authorizationEndpoint: '', + tokenEndpoint: '', + userinfoEndpoint: '', + clientId: '', + clientSecret: '', + redirectUri: '', + scopes: ['openid', 'profile', 'email', 'groups'], + groupsClaim: 'groups', + usernameClaim: 'preferred_username', + }, + auth: { - // LDAP groups whose members may use the web UI/API. + // OIDC group memberships that grant web UI/API admin access. adminGroups: ['app_sso_admin'], - // Web session lifetime (ms). - sessionTTLms: 12 * 60 * 60 * 1000, + // Local anti-lockout admin: the first name here is bootstrapped as a + // redis-backed user on first boot (password from localAdminPass, or a + // random one printed to the log once). Lets you in even with OIDC down. + adminUsers: ['jumpadmin'], + localAdminPass: '', }, redis: { diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index 534628a..8c1b8bb 100644 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -1,36 +1,54 @@ 'use strict'; -// Web UI/API auth: a signed-in admin session (cookie) whose LDAP groups -// intersect conf.auth.adminGroups. /health and the login routes are exempt -// (mounted before this middleware). +// Web UI/API auth, mirroring the sibling apps: a browser session token +// (`auth-token: `) established via local login or the OIDC +// callback. The token carries the group snapshot captured at login. const conf = require('@simpleworkjs/conf'); -const Session = require('../models/session'); +const { Auth } = require('../models/auth'); -function parseCookies(header) { - const out = {}; - (header || '').split(';').forEach((p) => { - const i = p.indexOf('='); - if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); - }); - return out; +async function auth(req, res, next){ + try{ + req.token = await Auth.checkToken(req.header('auth-token')); + req.user = req.token.user; + req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : []; + return next(); + }catch(error){ + next(error); + } } -async function requireAdmin(req, res, next) { - const token = parseCookies(req.headers.cookie).jump_session; - const session = await Session.verify(token); - if (!session) { - if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' }); - return res.redirect('/login'); - } - const groups = JSON.parse(session.groups || '[]'); - const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g)); - if (!admin) { - if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'forbidden' }); - return res.status(403).render('login', { error: 'Your account is not a jump-host admin.', name: conf.name }); - } - req.jumpUser = { uid: session.uid, groups }; - next(); +// Is the authenticated request an admin? Admin = a session whose OIDC groups +// intersect conf.auth.adminGroups, OR the local anti-lockout admin +// (conf.auth.adminUsers). The whole web UI is admin-only (audit + metrics). +function isAdmin(req){ + const adminGroups = (conf.auth && conf.auth.adminGroups) || []; + const adminUsers = (conf.auth && conf.auth.adminUsers) || []; + const username = req.user && req.user.username; + if(username && adminUsers.includes(username)) return true; + return (req.groups || []).some(g => adminGroups.includes(g)); } -module.exports = { requireAdmin, parseCookies }; +async function requireAdmin(req, res, next){ + if(isAdmin(req)) return next(); + const error = new Error('Forbidden'); + error.name = 'Forbidden'; + error.status = 403; + error.message = 'Admin access required.'; + next(error); +} + +// Socket.IO handshake auth (app-base.js connects with the session token). +async function authIO(socket, next){ + try{ + const tok = socket.handshake.auth && socket.handshake.auth.token; + if(!tok) return next(Auth.errors.login()); + const token = await Auth.checkToken(tok); + socket.user = token.user; + next(); + }catch(error){ + next(error); + } +} + +module.exports = { auth, requireAdmin, authIO, isAdmin }; diff --git a/nodejs/models/auth.js b/nodejs/models/auth.js new file mode 100644 index 0000000..34f33b5 --- /dev/null +++ b/nodejs/models/auth.js @@ -0,0 +1,118 @@ +'use strict'; + +const Table = require('../models'); +const {User, AuthToken} = Table.models; + +/** + * Auth Model + * + * Handles authentication operations for the application. + * Manages user login, token validation, and logout processes. + * + * Dependencies: + * - User model: Validates user credentials + * - AuthToken model: Creates and manages authentication tokens + * + * All methods throw standardized login errors on failure to avoid + * leaking information about whether usernames exist or tokens are valid. + */ +class Auth{ + /** + * Standardized error responses for authentication failures. + * Returns generic "Invalid Credentials" message for security. + */ + static errors = { + login: function(){ + let error = new Error('LoginFailed'); + error.name = 'LoginFailed'; + error.message = `Invalid Credentials, login failed.`; + error.status = 401; + + return error; + } + } + + /** + * Authenticate user and create session token. + * + * @param {Object} data - Login credentials {username, password} + * @returns {Object} {user, token} - User object and auth token + * @throws {Error} Generic login error on any failure + * + * Flow: + * 1. Validate credentials via User.login() + * 2. Create new AuthToken for the user + * 3. Return both user data and token + */ + static async login(data){ + try{ + let user = await User.login(data); + // Backends may attach group membership to the user (LDAP); default + // to none for local/redis users. + let groups = Array.isArray(user.groups) ? user.groups : []; + let token = await AuthToken.create({username: user.username, groups}); + + return {user, token} + }catch(error){ + console.log('login error', error); + throw this.errors.login(); + } + } + + /** + * Establish a session for an OIDC-authenticated identity: JIT-provision the + * local user (redis-backed) and mint an AuthToken carrying the SSO groups. + * + * @param {Object} identity - {username, groups} from utils/oidc claims + * @returns {Object} {user, token} + */ + static async oidcSession(identity){ + let user = typeof User.upsertOidc === 'function' + ? await User.upsertOidc(identity) + : await User.get(identity.username); + let token = await AuthToken.create({ + username: user.username, + groups: identity.groups || [], + }); + + return {user, token}; + } + + /** + * Validate an authentication token. + * + * @param {string} token - Token string to validate + * @returns {Object} Token object if valid + * @throws {Error} Generic login error if token invalid or expired + * + * Checks: + * 1. Token exists in database + * 2. Token has not expired (via token.check()) + */ + static async checkToken(token){ + try{ + token = await AuthToken.get(token); + if(token && token.check()) return token; + + throw this.errors.login(); + }catch(error){ + console.log('check error', error); + throw this.errors.login(); + } + } + + /** + * Destroy an authentication token (logout). + * + * @param {string} data - Token string to destroy + * @returns {void} + * + * Removes token from database, invalidating the session. + */ + static async logout(data){ + let token = await AuthToken.get(data); + await token.destroy(); + } +} + +module.exports = {Auth}; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 1d13f9a..5561597 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -1,8 +1,8 @@ 'use strict'; -// model-redis backing (same store the other stack apps use). Table is the -// base class; getRedis() exposes the underlying node-redis client for the -// counters and sorted-set index in models/metrics.js and models/audit_event.js. +// model-redis backing (same store the sibling apps use). Table is the base +// class; getRedis() exposes the underlying node-redis client for the counters +// and sorted-set index in models/metrics.js and models/audit_event.js. const conf = require('@simpleworkjs/conf'); const { setUpTable } = require('model-redis'); @@ -31,5 +31,8 @@ async function getRedis() { module.exports.getRedis = getRedis; -require('./session'); +// Register models (order matters: User before AuthToken's relation resolves). +require('./user_redis'); +require('./token'); +require('./oidc_state'); require('./audit_event'); diff --git a/nodejs/models/oidc_state.js b/nodejs/models/oidc_state.js new file mode 100644 index 0000000..d46ede4 --- /dev/null +++ b/nodejs/models/oidc_state.js @@ -0,0 +1,31 @@ +'use strict'; + +const Table = require('.'); + +/** + * OidcState + * + * Short-lived store for an in-flight OpenID Connect authorization request. + * Keyed by the random `state` value; holds the PKCE `code_verifier` and the + * post-login redirect target until the SSO calls us back. + * + * The record auto-expires via model-redis per-key TTL (static _ttl), so an + * abandoned login attempt leaves nothing behind and there is no cleanup job. + */ +class OidcState extends Table{ + static _key = 'state'; + + // Auth round-trips are quick; 5 minutes is plenty and bounds replay. + static _ttl = 300; + + static _keyMap = { + 'created_on': {default: function(){return (new Date).getTime()}}, + 'state': {isRequired: true, type: 'string', min: 8, max: 500}, + 'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500}, + 'redirect': {default: '/', isRequired: false, type: 'string'}, + } +} + +OidcState.register(); + +module.exports = {OidcState}; diff --git a/nodejs/models/session.js b/nodejs/models/session.js deleted file mode 100644 index 8b402d5..0000000 --- a/nodejs/models/session.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -// Web UI sessions — a signed-in admin's browser token. model-redis Table with -// a TTL so entries expire and survive restarts. - -const crypto = require('crypto'); -const Table = require('.'); - -class Session extends Table { - static _key = 'token'; - static _keyMap = { - 'token': {default: function(){ return crypto.randomUUID() }, type: 'string'}, - 'uid': {isRequired: true, type: 'string'}, - 'groups': {default: '[]', type: 'string'}, - 'created_on': {default: function(){ return (new Date).getTime() }}, - 'expires_at': {default: 0, type: 'number'}, - } -} - -Session.register(); - -Session.start = async function (uid, groups, ttlMs) { - return Session.create({ - uid, - groups: JSON.stringify(groups || []), - expires_at: Date.now() + ttlMs, - }, { ttl: Math.ceil(ttlMs / 1000) }); -}; - -Session.verify = async function (token) { - if (!token) return null; - let session; - try { - session = await Session.get(token); - } catch (_) { - return null; - } - if (!session || session.expires_at < Date.now()) return null; - return session; -}; - -module.exports = Session; diff --git a/nodejs/models/token.js b/nodejs/models/token.js new file mode 100644 index 0000000..f497e36 --- /dev/null +++ b/nodejs/models/token.js @@ -0,0 +1,64 @@ +'use strict'; + +const Table = require('.'); +const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}; + + +class Token extends Table{ + static _key = 'token'; + static _keyMap = { + 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, + 'created_on': {default: function(){return (new Date).getTime()}}, + 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, + 'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true}, + 'is_valid': {default: true, type: 'boolean'}, + } + + constructor(...args){ + super(...args); + } + + async check(){ + try{ + return this.is_valid; + }catch(error){ + return false + } + } +} + +Token.register(); + +class AuthToken extends Token{ + static _keyMap = { + ...super._keyMap, + user: {model: 'User', rel: 'one', localKey: 'created_by'}, + // Group memberships captured at login (OIDC `groups` claim or LDAP + // group membership), stored as a JSON string. Drives authorization for + // the life of the session without re-querying the IdP on every request. + groups: {default: '[]', isRequired: false, type: 'string'}, + } + + static async create(data){ + data.created_by = data.username; + if(Array.isArray(data.groups)){ + data.groups = JSON.stringify(data.groups); + } + return super.create(data) + + } + + // Parse the stored groups JSON back into an array, tolerating bad/missing + // data so authorization never crashes on a malformed token. + groupsArray(){ + try{ + let parsed = JSON.parse(this.groups); + return Array.isArray(parsed) ? parsed : []; + }catch(error){ + return []; + } + } +} +AuthToken.register(); + +module.exports = {Token, AuthToken}; diff --git a/nodejs/models/user_redis.js b/nodejs/models/user_redis.js new file mode 100644 index 0000000..4c01d59 --- /dev/null +++ b/nodejs/models/user_redis.js @@ -0,0 +1,123 @@ +'use strict'; + +const Table = require('.'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf'); +const saltRounds = 10; + +class User extends Table{ + static _key = 'username'; + static _keyMap = { + 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, + 'created_on': {default: function(){return (new Date).getTime()}}, + 'updated_by': {default:"__NONE__", isRequired: false, type: 'string',}, + 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, + 'username': {isRequired: true, type: 'string', min: 3, max: 500}, + 'password': {isRequired: true, type: 'string', min: 3, max: 500, isPrivate: true}, + 'backing': {default:"redis", isRequired: false, type: 'string',}, + } + + static backing = 'redis' + + static async create(data) { + try{ + data['password'] = await bcrypt.hash(data['password'], saltRounds); + data['backing'] = data['backing'] || 'redis'; + + return await super.create(data) + + }catch(error){ + throw error; + } + } + + async setPassword(data){ + try{ + data['password'] = await bcrypt.hash(data['password'], saltRounds); + + return this.update(data); + }catch(error){ + throw error; + } + } + + /** + * Just-in-time provisioning for an OIDC-authenticated user. Creates the + * local user on first login so relations (tokens, created_by, grants) have + * something to point at. OIDC users get a random, unusable password — they + * authenticate through the SSO, never the local password form. + * + * @param {Object} data - {username, ...} from the OIDC userinfo claims + * @returns {User} the existing or newly created user + */ + static async upsertOidc(data){ + try{ + return await User.get(data.username); + }catch(error){ + return await User.create({ + username: data.username, + password: crypto.randomBytes(24).toString('hex'), + created_by: data.username, + backing: 'oidc', + }); + } + } + + static async login(data){ + try{ + let user = await User.get(data); + let auth = await bcrypt.compare(data.password, user.password); + + if(auth){ + return user + }else{ + throw this.errors.login(); + } + }catch(error){ + console.error('!!!!!!!!!!', error) + if (error == 'Authentication failure'){ + throw this.errors.login() + } + throw error; + } + }; +} + +User.register(); + +(async function(){ + // The anti-lockout local admin: the first entry in conf.auth.adminUsers + // (default 'jumpadmin'). A local login that works even if the SSO/OIDC is + // unreachable — the whole point of "OIDC + internal users". + var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin'; + // Optional: an orchestrator (e.g. theta-env's setup.sh) can set + // auth.localAdminPass in jump-secrets.js to a generated password so this + // bootstrap account isn't left at a well-known default. Only used on first + // creation -- once the account exists this is never read again, so it's + // safe to leave set. If unset, a random password is generated and printed + // once; save it from the log or set auth.localAdminPass explicitly. + var defaultPass = (conf.auth && conf.auth.localAdminPass); + if (!defaultPass) { + defaultPass = crypto.randomBytes(16).toString('hex'); + console.warn(`====================================================================`); + console.warn(`Bootstrap admin "${defaultUser}" created with random password:`); + console.warn(`${defaultPass}`); + console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`); + console.warn(`====================================================================`); + } + try{ + let user = await User.get(defaultUser); + }catch(error){ + try{ + let user = await User.create({ + username:defaultUser, + password: defaultPass, + created_by: defaultUser + }); + console.log(defaultUser, 'created'); + }catch(error){ + console.error(error) + } + } +})(); \ No newline at end of file diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e8d6201..b9f84b9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,20 +1,30 @@ { "name": "t42-jump-host", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-jump-host", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "dependencies": { + "@fortawesome/fontawesome-free": "^7.3.0", "@simpleworkjs/conf": "^1.2.0", + "bcrypt": "^6.0.0", + "bootstrap": "^5.3.8", + "compression": "^1.8.1", "ejs": "^3.1.10", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "jq-repeat": "^2.2.0", + "jquery": "^3.7.1", "ldapts": "^8.1.2", "model-redis": "^1.6.0", + "moment": "^2.30.1", + "mustache": "^4.2.0", "redis": "^4.7.0", + "socket.io": "^4.8.3", "ssh2": "^1.16.0" }, "devDependencies": { @@ -24,6 +34,26 @@ "node": ">=20.14" } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz", + "integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -95,6 +125,39 @@ "node": ">=16.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -143,6 +206,29 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -202,6 +288,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/brace-expansion": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", @@ -305,6 +410,60 @@ "node": ">=0.10.0" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -345,6 +504,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -429,6 +605,79 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -517,6 +766,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -762,6 +1030,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -840,6 +1117,25 @@ "node": ">=10" } }, + "node_modules/jq-repeat": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.1.tgz", + "integrity": "sha512-1M0jRo7rJKO2mHbeENNjUx9YMOCobUuG4XElKt2NWDc4+j22wrZELqt7Twy95BBEkZQuwDteoPTd1etupZZPtQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "jquery": ">=3.0.0", + "mustache": ">=4.0.0" + } + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" + }, "node_modules/ldapts": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", @@ -1016,12 +1312,30 @@ "node": ">= 20.0.0" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nan": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", @@ -1038,6 +1352,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1116,6 +1450,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1140,6 +1483,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1297,6 +1649,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1452,6 +1824,90 @@ "node": ">=10" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -1573,6 +2029,12 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1597,6 +2059,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index f0ef89b..525ed8f 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-jump-host", - "version": "1.0.1", + "version": "1.1.0", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "author": [ { @@ -19,12 +19,22 @@ "test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js" }, "dependencies": { + "@fortawesome/fontawesome-free": "^7.3.0", "@simpleworkjs/conf": "^1.2.0", + "bcrypt": "^6.0.0", + "bootstrap": "^5.3.8", + "compression": "^1.8.1", "ejs": "^3.1.10", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "jq-repeat": "^2.2.0", + "jquery": "^3.7.1", "ldapts": "^8.1.2", "model-redis": "^1.6.0", + "moment": "^2.30.1", + "mustache": "^4.2.0", "redis": "^4.7.0", + "socket.io": "^4.8.3", "ssh2": "^1.16.0" }, "devDependencies": { diff --git a/nodejs/public/css/app.css b/nodejs/public/css/app.css deleted file mode 100644 index affbcbf..0000000 --- a/nodejs/public/css/app.css +++ /dev/null @@ -1,31 +0,0 @@ -:root { --bg:#0f1115; --panel:#181b22; --line:#272b34; --fg:#e6e8ec; --mut:#8b93a1; --acc:#4f9cf9; --bad:#ff6b6b; --ok:#4ec9a5; } -* { box-sizing: border-box; } -body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); } -a { color:var(--acc); text-decoration:none; } a:hover { text-decoration:underline; } -.nav { display:flex; align-items:center; gap:16px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); } -.brand { font-weight:600; } .brand small { color:var(--mut); font-weight:400; } -.nav .spacer { flex:1; } .nav .who { color:var(--mut); } -.wrap { max-width:1100px; margin:0 auto; padding:24px 20px; } -h1 { font-size:20px; margin:0 0 16px; } h2 { font-size:15px; margin:24px 0 8px; } -.tiles { display:flex; gap:16px; flex-wrap:wrap; } -.tile { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; min-width:150px; } -.tile .n { display:block; font-size:28px; font-weight:600; } .tile .l { color:var(--mut); } -.cols { display:grid; grid-template-columns:2fr 1fr; gap:24px; } -@media (max-width:800px){ .cols { grid-template-columns:1fr; } } -table { width:100%; border-collapse:collapse; margin-top:8px; } -th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); } -th { color:var(--mut); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.03em; } -td.r,th.r { text-align:right; } -tr.bad td { color:var(--bad); } -.muted { color:var(--mut); } -.more { font-size:12px; font-weight:400; margin-left:8px; } -.foot { max-width:1100px; margin:0 auto; padding:16px 20px; color:var(--mut); font-size:12px; } -.filters { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; } -.filters input,.filters select,.login input { background:#0c0e12; border:1px solid var(--line); color:var(--fg); border-radius:7px; padding:7px 10px; } -button { background:var(--acc); color:#fff; border:0; border-radius:7px; padding:8px 14px; cursor:pointer; font:inherit; } -button.link { background:none; color:var(--acc); padding:0; } -.inline { display:inline; } .pager { display:flex; gap:16px; align-items:center; margin-top:16px; color:var(--mut); } -.center { display:grid; place-items:center; min-height:100vh; } -.card.login { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:28px; width:320px; display:flex; flex-direction:column; gap:12px; } -.card.login h1 { margin:0 0 8px; } .card.login label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--mut); } -.card.login .hint { color:var(--mut); font-size:12px; margin:4px 0 0; } .err { color:var(--bad); margin:0; } diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css new file mode 100755 index 0000000..804357f --- /dev/null +++ b/nodejs/public/css/styles.css @@ -0,0 +1,20 @@ +nav.navbar{ + padding-left: 1em; + padding-right: 1em; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#spa-shell { + margin-top: 4.5rem; + padding-bottom: 1em; + flex-grow: 1; +} + +.card-title{ + font-weight: bold; +} diff --git a/nodejs/public/favicon.svg b/nodejs/public/favicon.svg new file mode 100644 index 0000000..35e1881 --- /dev/null +++ b/nodejs/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/nodejs/public/img/theta42.svg b/nodejs/public/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/nodejs/public/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js new file mode 100644 index 0000000..4efc5aa --- /dev/null +++ b/nodejs/public/js/app.js @@ -0,0 +1,21 @@ +'use strict'; + +// Jump-host page controllers. app.api / app.auth come from app-base.js (the +// shared client framework); this adds the jump-host data calls and the small +// render helpers each page uses. + +app.jump = (function(app){ + function metrics(cb){ app.api.get('metrics', cb); } + function sessions(cb){ app.api.get('sessions', cb); } + function audit(query, cb){ + var qs = $.param(query || {}); + app.api.get('audit' + (qs ? '?' + qs : ''), cb); + } + return {metrics: metrics, sessions: sessions, audit: audit}; +})(app); + +// Shared render helpers. +app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; }; +app.jump.esc = function(s){ return $('
').text(s == null ? '' : String(s)).html(); }; +app.jump.result = function(e){ return e.success ? 'ok' + : '' + app.jump.esc(e.failReason || 'fail') + ''; }; diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js new file mode 100644 index 0000000..1463c93 --- /dev/null +++ b/nodejs/public/lib/js/app-base.js @@ -0,0 +1,552 @@ +var app = {}; + +app.pubsub = (function(){ + app.topics = {}; + + app.subscribe = function(topic, listener){ + if(topic instanceof RegExp){ + listener.match = topic; + topic = "__REGEX__"; + } + + // create the topic if not yet created + if(!app.topics[topic]) app.topics[topic] = []; + + // add the listener + app.topics[topic].push(listener); + } + + app.matchTopics = function(topic){ + topic = topic || ''; + var matches = [... app.topics[topic] ? app.topics[topic] : []]; + + if(!app.topics['__REGEX__']) return matches; + + for(var listener of app.topics['__REGEX__']){ + if(topic.match(listener.match)) matches.push(listener); + } + + return matches; + } + + app.publish = function(topic, data){ + + // send the event to all listeners + app.matchTopics(topic).forEach(function(listener){ + setTimeout(function(data, topic){ + listener(data || {}, topic); + }, 0, data, topic); + }); + } + + return this; +})(app); + +app.socket = (function(app){ + // $.getScript('/socket.io/socket.io.js') + // + + var socket; + $(document).ready(function(){ + socket = io({ + auth: { + token: app.auth.getToken() + } + }); + // socket.emit('chat message', $('#m').val()); + socket.on('P2PSub', function(msg){ + msg.data.__noSocket = true; + app.publish(msg.topic, msg.data); + }); + + app.subscribe(/./g, function(data, topic){ + // console.log('local_pubs', data, topic) + if(data.__noSocket) return; + // console.log('local_pubs 2', data, topic) + + socket.emit('P2PSub', { topic, data }); + }); + }) + + return socket; + +})(app); + +app.api = (function(app){ + var baseURL = '/api/' + + function post(url, data, callback){ + if(typeof callback !== 'function') callback = callback2; + return $.ajax({ + type: 'POST', + 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(){} + } + }); + } + + 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(){} + } + }); + } + + function remove(url, callback, callback2){ + if(typeof callback !== 'function') callback = callback2; + return $.ajax({ + type: 'delete', + url: baseURL+url, + headers:{ + 'auth-token': app.auth.getToken() + }, + 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(){} + } + }); + } + + function options(url, callback){ + return $.ajax({ + type: 'OPTIONS', + url: baseURL+url, + headers:{ + 'auth-token': app.auth.getToken() + }, + 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(){} + } + }); + } + + function get(url, callback){ + return $.ajax({ + type: 'GET', + url: baseURL+url, + headers:{ + 'auth-token': app.auth.getToken() + }, + 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 {post: post, get: get, put: put, delete: remove, options: options,} +})(app) + +app.auth = (function(app){ + var user = {} + function setToken(token){ + localStorage.setItem('APIToken', token); + } + + function getToken(){ + 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); + } + } + + // 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 "/". + function safeInternalPath(path){ + if(typeof path !== 'string' || path.charAt(0) !== '/' + || path.charAt(1) === '/' || path.charAt(1) === '\\'){ + return '/'; + } + return path; + } + + // Consume an app token handed back by the OIDC callback via the URL + // fragment (#token=…&redirect=…). Stores it, strips the fragment, and + // forwards to the intended page. Returns true if a token was consumed. + function consumeTokenFragment(){ + if(!location.hash) return false; + var params = new URLSearchParams(location.hash.replace(/^#/, '')); + var token = params.get('token'); + if(!token) return false; + + setToken(token); + // redirect comes from the URL fragment (attacker-controllable); only + // allow a same-origin path so it can't become an open redirect / XSS. + var redirect = safeInternalPath(params.get('redirect') || '/'); + // Drop the token from the address bar before navigating on. + history.replaceState(null, '', location.pathname + location.search); + window.location.href = redirect; + 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(){}) + location.replace(`/login${location.href.replace(location.origin, '')}`); + } + }); + } + + function logInRedirect(){ + window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/') + } + + return { + getToken: getToken, + setToken: setToken, + isLoggedIn: isLoggedIn, + consumeTokenFragment: consumeTokenFragment, + isAdmin: isAdmin, + perms: null, + logIn: logIn, + logOut: logOut, + forceLogin, + logInRedirect, + } + +})(app); + +app.user = (function(app){ + function list(callback){ + app.api.get('user/?detail=true', function(error, data){ + callback(error, data); + }) + } + + function add(args, callback){ + app.api.post('user/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(args, callback){ + app.api.delete('user/'+ args.username, function(error, data){ + callback(error, data); + }); + } + + function changePassword(args, callback){ + app.api.put('users/'+ arg.username || '', args, function(error, data){ + callback(error, data); + }); + } + + return {list, remove}; + +})(app); + +app.permission = (function(app){ + function list(callback){ + app.api.get('permission/', function(error, data){ + callback(error, data); + }); + } + + function subjects(callback){ + app.api.get('permission/subjects', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('permission/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(id, callback){ + app.api.delete('permission/' + encodeURIComponent(id), function(error, data){ + callback(error, data); + }); + } + + return {list, subjects, add, remove}; + +})(app); + +app.group = (function(app){ + function list(callback){ + app.api.get('group/', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('group/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(name, callback){ + app.api.delete('group/' + encodeURIComponent(name), function(error, data){ + callback(error, data); + }); + } + + function addMember(name, username, callback){ + app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){ + callback(error, data); + }); + } + + function removeMember(name, username, callback){ + app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){ + callback(error, data); + }); + } + + return {list, add, remove, addMember, removeMember}; + +})(app); + +app.util = (function(app){ + + function getUrlParameter(name){ + name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); + var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); + var results = regex.exec(location.search); + return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); + }; + + function actionMessage(message, $target, type, callback){ + message = message || ''; + $target = $target.closest('div.card').find('.actionMessage'); + type = type || 'info'; + callback = callback || function(){}; + + if($target.html() === message) return; + + if($target.html()){ + $target.slideUp('fast', function(){ + $target.html('') + $target.removeClass (function(index, className){ + return (className.match (/(^|\s)bg-\S+/g) || []).join(' '); + }); + if(message) return actionMessage(message, $target, type, callback); + $target.hide() + }) + }else{ + if(type) $target.addClass('bg-' + type); + message = '' + message + '' + $target.html(message).slideDown('fast'); + } + setTimeout(callback,10) + } + + $.fn.serializeObject = function() { + var obj = {}; + + // Get the form values and work over them + for (let {name, value} of $(this).serializeArray()) { + console.log(name, value) + if (obj[name] === undefined) { + if (!value + && !$(this).parent().find(`[name="${name}"]`).attr('value') + // Keep empty