feat: rebuild web UI on the shared theta42 app stack; OIDC + local admin auth
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 <noreply@anthropic.com>
This commit is contained in:
+8
-29
@@ -1,36 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
// Auditing + metrics API (admin-gated by middleware/auth in app.js).
|
||||
const router = require('express').Router();
|
||||
const middleware = require('../middleware/auth');
|
||||
|
||||
const express = require('express');
|
||||
const audit = require('../models/audit_event');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
||||
router.use('/auth', require('./auth'));
|
||||
|
||||
const router = express.Router();
|
||||
// Who am I — needs a valid session but no admin gate (drives the login state).
|
||||
router.use('/user', middleware.auth, require('./user'));
|
||||
|
||||
router.get('/sessions', (req, res) => {
|
||||
res.json({ results: registry.list(), active: registry.count() });
|
||||
});
|
||||
|
||||
router.get('/audit', async (req, res, next) => {
|
||||
try {
|
||||
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
||||
const data = await audit.list({
|
||||
page,
|
||||
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
|
||||
uid: req.query.uid || undefined,
|
||||
target: req.query.target || undefined,
|
||||
status: req.query.status || undefined,
|
||||
});
|
||||
res.json(data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/metrics', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ ...(await metrics.summary()), active: registry.count() });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Regular → Executable
+98
-29
@@ -1,42 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
// Web login: LDAP bind as the user, require an adminGroups membership, mint a
|
||||
// session cookie. (OIDC against the SSO is a follow-up.)
|
||||
|
||||
const express = require('express');
|
||||
const router = require('express').Router();
|
||||
const { rateLimit } = require('express-rate-limit');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
const Session = require('../models/session');
|
||||
const { Auth } = require('../models/auth');
|
||||
const { OidcState } = require('../models/oidc_state');
|
||||
const oidc = require('../utils/oidc');
|
||||
const { safeInternalPath } = require('../utils/safe_redirect');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('login', { error: null, name: conf.name });
|
||||
// Throttle unauthenticated auth endpoints (credential login + the OIDC
|
||||
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 60, // 60 attempts per IP per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
|
||||
});
|
||||
|
||||
router.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
|
||||
const { uid, password } = req.body || {};
|
||||
const fail = (msg) => res.status(401).render('login', { error: msg, name: conf.name });
|
||||
try {
|
||||
const user = await userLdap.getUser(uid);
|
||||
if (!user) return fail('Invalid credentials.');
|
||||
const ok = await userLdap.checkPassword(user.dn, password);
|
||||
if (!ok) return fail('Invalid credentials.');
|
||||
const groups = await userLdap.getGroups(user.dn);
|
||||
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
|
||||
if (!admin) return fail('Your account is not a jump-host admin.');
|
||||
|
||||
const session = await Session.start(user.uid, groups, conf.auth.sessionTTLms);
|
||||
res.setHeader('Set-Cookie', `jump_session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(conf.auth.sessionTTLms / 1000)}`);
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
return fail('Login failed.');
|
||||
router.post('/login', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
let auth = await Auth.login(req.body);
|
||||
return res.json({
|
||||
login: true,
|
||||
token: auth.token.token,
|
||||
message:`${req.body.username} logged in!`,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.setHeader('Set-Cookie', 'jump_session=; HttpOnly; Path=/; Max-Age=0');
|
||||
res.redirect('/login');
|
||||
router.all('/logout', async function(req, res, next){
|
||||
try{
|
||||
if(req.user){
|
||||
await req.user.logout();
|
||||
}
|
||||
|
||||
res.json({message: 'Bye'})
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
||||
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
||||
*/
|
||||
router.get('/oidc/start', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
if(!conf.oidc || !conf.oidc.enabled){
|
||||
let error = new Error('OidcDisabled');
|
||||
error.status = 404;
|
||||
error.message = 'OIDC login is not enabled.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||
await OidcState.create({
|
||||
state,
|
||||
codeVerifier,
|
||||
// Sanitize now so a hostile ?redirect= can't be stored and later
|
||||
// reflected into the login page's navigation.
|
||||
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||
});
|
||||
|
||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC callback: validate state (consuming the one-time record), exchange the
|
||||
* code for tokens, read identity from userinfo, establish a session, and hand
|
||||
* the app token back to the browser via a URL fragment for the login page to
|
||||
* store in localStorage.
|
||||
*/
|
||||
router.get('/oidc/callback', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
let {code, state} = req.query;
|
||||
if(!code || !state){
|
||||
let error = new Error('OidcCallbackInvalid');
|
||||
error.status = 400;
|
||||
error.message = 'Missing code or state.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// get() throws if the state is unknown or has expired — this both binds
|
||||
// the callback to our request and bounds replay.
|
||||
let saved = await OidcState.get(state);
|
||||
await saved.remove();
|
||||
|
||||
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
|
||||
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
||||
let identity = oidc.claimsToIdentity(claims);
|
||||
|
||||
let {token} = await Auth.oidcSession(identity);
|
||||
|
||||
let redirect = safeInternalPath(saved.redirect || '/');
|
||||
return res.redirect(
|
||||
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
||||
);
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const audit = require('../models/audit_event');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
const buildInfo = require('../models/build_info');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [m, recent] = await Promise.all([
|
||||
metrics.summary(),
|
||||
audit.list({ page: 0, pageSize: 10 }),
|
||||
]);
|
||||
res.render('dashboard', {
|
||||
name: conf.name, buildInfo, user: req.jumpUser,
|
||||
metrics: { ...m, active: registry.count() },
|
||||
active: registry.list(),
|
||||
recent: recent.results,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/sessions', (req, res) => {
|
||||
res.render('sessions', { name: conf.name, buildInfo, user: req.jumpUser, active: registry.list() });
|
||||
});
|
||||
|
||||
router.get('/audit', async (req, res, next) => {
|
||||
try {
|
||||
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
||||
const data = await audit.list({ page, pageSize: 50, uid: req.query.uid, target: req.query.target, status: req.query.status });
|
||||
res.render('audit', { name: conf.name, buildInfo, user: req.jumpUser, data, query: req.query });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
// Jump-host data API: active sessions, the audit log, and metrics. Admin-gated
|
||||
// (mounted behind middleware.auth + requireAdmin in routes/api.js).
|
||||
|
||||
const router = require('express').Router();
|
||||
const audit = require('../models/audit_event');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
|
||||
router.get('/sessions', (req, res) => {
|
||||
res.json({results: registry.list(), active: registry.count()});
|
||||
});
|
||||
|
||||
router.get('/audit', async (req, res, next) => {
|
||||
try{
|
||||
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
||||
const data = await audit.list({
|
||||
page,
|
||||
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
|
||||
uid: req.query.uid || undefined,
|
||||
target: req.query.target || undefined,
|
||||
status: req.query.status || undefined,
|
||||
});
|
||||
res.json(data);
|
||||
}catch(error){ next(error); }
|
||||
});
|
||||
|
||||
router.get('/metrics', async (req, res, next) => {
|
||||
try{
|
||||
res.json({...(await metrics.summary()), active: registry.count()});
|
||||
}catch(error){ next(error); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../models/build_info');
|
||||
const registry = require('../services/session_registry');
|
||||
|
||||
const values = {
|
||||
title: conf.environment !== 'production' ? 'dev' : '',
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
...buildInfo,
|
||||
};
|
||||
|
||||
// Serve front-end vendor libraries straight from node_modules (same convention
|
||||
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'];
|
||||
frontEndModules.forEach(dep => {
|
||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}));
|
||||
});
|
||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
|
||||
|
||||
// Liveness probe — no auth.
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit});
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
||||
|
||||
// Page shells. The client framework (app-base.js + app.js) loads data via the
|
||||
// authenticated /api/* endpoints and gates the UI on /api/user/me, so these
|
||||
// render unauthenticated (like the sibling apps) and the client redirects to
|
||||
// /login when there's no valid session.
|
||||
router.get('/login', (req, res) => res.render('login', {
|
||||
...values,
|
||||
redirect: '/',
|
||||
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
|
||||
}));
|
||||
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
||||
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
// Minimal user endpoint the client framework needs: GET /api/user/me tells the
|
||||
// browser who it is and whether it's an admin (drives login state + nav).
|
||||
|
||||
const router = require('express').Router();
|
||||
const { isAdmin } = require('../middleware/auth');
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
res.json({
|
||||
username: req.user && req.user.username,
|
||||
groups: req.groups || [],
|
||||
isAdmin: isAdmin(req),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user