Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75045b4b33 | |||
| 2e959f7c48 | |||
| fcc09d1e4b | |||
| a83a5fd39a | |||
| 3a307c1563 | |||
| f055d4f567 | |||
| f4efdfb957 | |||
| 60dbfe5b9b | |||
| ecdced16fc | |||
| aecf275031 | |||
| 3f46a807e5 | |||
| bb1b84b56d | |||
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 |
@@ -6,6 +6,63 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
|
||||||
|
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
|
||||||
|
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
|
||||||
|
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
|
||||||
|
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
|
||||||
|
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
|
||||||
|
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
|
||||||
|
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
|
||||||
|
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
|
||||||
|
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema:
|
||||||
|
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, and `routes/auth.js`; `models/index.js` now wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils.
|
||||||
|
- `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping.
|
||||||
|
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=<username>))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dependency alignment: `model-redis` `^1.5` → `^1.6.0`, `ldapts` `^8.1.2` → `^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
|
||||||
|
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock installed by the Dockerfile/`install.sh` doesn't provide that path — it provides `resty.roundrobin` (constructed as `roundrobin:new(nodes)`, not `:new()` + `:reinit(nodes)`). Fixed `targetinfo.lua` to use the real module and API; verified end-to-end that requests now round-robin across targets with no Lua errors.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
|
||||||
|
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Multi-target load balancing: hosts can now specify additional backend targets (`IP:port`, one per line) alongside the primary target; the proxy distributes requests across all of them round-robin via `lua-resty-balancer`. Fixes #47.
|
||||||
|
|
||||||
## [1.1.17] - 2026-07-20
|
## [1.1.17] - 2026-07-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ RUN apt-get update \
|
|||||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||||
RUN luarocks install lua-resty-auto-ssl \
|
RUN luarocks install lua-resty-auto-ssl \
|
||||||
&& luarocks install luasocket \
|
&& luarocks install luasocket \
|
||||||
|
&& luarocks install lua-resty-balancer \
|
||||||
&& luarocks install lua-resty-ipmatcher
|
&& luarocks install lua-resty-ipmatcher
|
||||||
|
|
||||||
# ── Node app ─────────────────────────────────────────────────────────────────
|
# ── Node app ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ management once basic auth is enabled:
|
|||||||
|
|
||||||
[](docs/images/host-auth-basic.png)
|
[](docs/images/host-auth-basic.png)
|
||||||
|
|
||||||
|
Multiple backend targets per host, load balanced round-robin:
|
||||||
|
|
||||||
|
[](docs/images/load-balancing.png)
|
||||||
|
|
||||||
## Why this over the alternatives
|
## Why this over the alternatives
|
||||||
|
|
||||||
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
||||||
@@ -51,6 +55,7 @@ provider + LDAP directory you already run.
|
|||||||
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
||||||
- Wildcard SSL certificate support with automatic renewal
|
- Wildcard SSL certificate support with automatic renewal
|
||||||
- Dynamic host routing with wildcard domain matching (*, **)
|
- Dynamic host routing with wildcard domain matching (*, **)
|
||||||
|
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||||
- Web-based management interface
|
- Web-based management interface
|
||||||
- RESTful API for automation
|
- RESTful API for automation
|
||||||
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ host form whenever the name you're entering already has a matching
|
|||||||
wildcard available to reuse — including the wildcard's own bare base
|
wildcard available to reuse — including the wildcard's own bare base
|
||||||
domain (e.g. `example.com` itself, not just `something.example.com`).
|
domain (e.g. `example.com` itself, not just `something.example.com`).
|
||||||
|
|
||||||
|
## Load Balancing
|
||||||
|
|
||||||
|
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
||||||
|
|
||||||
## Want more detail?
|
## Want more detail?
|
||||||
|
|
||||||
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 428 KiB |
@@ -50,6 +50,7 @@ LDAP directory you already run.
|
|||||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||||
|
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||||
it's never ambiguous which one gated a request)
|
it's never ambiguous which one gated a request)
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ app.use(express.json());
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// 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 routers' `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
|
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
|
||||||
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
|
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
|
||||||
// per-host session cookie. Mounted before the page router.
|
// per-host session cookie. Mounted before the page router.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Auth} = require('../models/auth');
|
const {Auth} = require('../models');
|
||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const Table = require('../models');
|
|
||||||
const {User, AuthToken} = Table.models;
|
|
||||||
const {ApiToken} = require('./api_token');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an `Authorization: Bearer prx_<id>_<secret>` API token.
|
|
||||||
*
|
|
||||||
* Returns the authenticated ApiToken record (with created_by + the
|
|
||||||
* mint-time groups snapshot); middleware/auth.js wraps it into the
|
|
||||||
* req.token shape the authz layer expects. Every failure collapses to the
|
|
||||||
* same generic login 401 — no leak of existence / wrong secret / expired.
|
|
||||||
*/
|
|
||||||
static async checkApiToken(raw){
|
|
||||||
try{
|
|
||||||
return await ApiToken.authenticate(raw);
|
|
||||||
}catch(error){
|
|
||||||
console.log('api-token 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};
|
|
||||||
@@ -28,6 +28,7 @@ class Host extends Table{
|
|||||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
||||||
|
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||||
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
||||||
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const {setUpTable} = require('model-redis');
|
const {setUpTable} = require('model-redis');
|
||||||
|
const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client');
|
||||||
|
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
module.exports = Table;
|
module.exports = Table;
|
||||||
|
|
||||||
|
// App-local models. User + ApiToken register before the OIDC client factory
|
||||||
|
// below: Auth binds User, and checkApiToken wraps ApiToken.authenticate.
|
||||||
|
require('./user'); // User (redis-backed local + OIDC JIT)
|
||||||
|
const {ApiToken} = require('./api_token'); // ApiToken (Bearer PATs)
|
||||||
require('./dns_provider');
|
require('./dns_provider');
|
||||||
require('./dynamic_record');
|
require('./dynamic_record');
|
||||||
require('./host');
|
require('./host');
|
||||||
require('./token');
|
|
||||||
require('./user');
|
|
||||||
require('./local_group');
|
require('./local_group');
|
||||||
require('./permission');
|
require('./permission');
|
||||||
require('./oidc_state');
|
|
||||||
require('./sso_session');
|
require('./sso_session');
|
||||||
require('./api_token');
|
|
||||||
|
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
||||||
|
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
||||||
|
// /oidc/callback router — all created on this app's Table/redis. PAT validation
|
||||||
|
// is wired in (proxy accepts Bearer prx_<id>_<secret>); the package collapses
|
||||||
|
// every checkApiToken failure to a generic 401.
|
||||||
|
const oidcClient = createOidcClient({
|
||||||
|
Table,
|
||||||
|
checkApiToken: (raw) => ApiToken.authenticate(raw),
|
||||||
|
});
|
||||||
|
module.exports.Token = oidcClient.Token;
|
||||||
|
module.exports.AuthToken = oidcClient.AuthToken;
|
||||||
|
module.exports.OidcState = oidcClient.OidcState;
|
||||||
|
module.exports.Auth = oidcClient.Auth;
|
||||||
|
module.exports.authRouter = oidcClient.router;
|
||||||
|
|
||||||
|
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||||
|
bootstrapLocalAdmin(Table.models.User, {defaultName: 'proxyadmin2'});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'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};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'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};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const {Token} = require('./token');
|
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
const { escapeFilter } = require('@simpleworkjs/ldap');
|
||||||
|
|
||||||
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
|
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
|
||||||
// ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or
|
// ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or
|
||||||
@@ -107,7 +107,9 @@ User.get = async function(data){
|
|||||||
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
await client.bind(conf.bindDN, conf.bindPassword);
|
||||||
|
|
||||||
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${data.username}))`;
|
// Escape the interpolated username (RFC 4515) — previously raw, which
|
||||||
|
// let `*`/`(`/`)`/`\`/NUL in a username break or broaden the filter.
|
||||||
|
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${escapeFilter(data.username)}))`;
|
||||||
|
|
||||||
const res = await client.search(conf.searchBase, {
|
const res = await client.search(conf.searchBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
const linuxUser = require('linux-sys-user').promise();
|
const linuxUser = require('linux-sys-user').promise();
|
||||||
const objValidate = require('../utils/object_validate');
|
const objValidate = require('../utils/object_validate');
|
||||||
const {Token} = require('./token');
|
|
||||||
const {promisify} = require('util');
|
const {promisify} = require('util');
|
||||||
const pam = require('authenticate-pam');
|
const pam = require('authenticate-pam');
|
||||||
const authenticate = promisify(pam.authenticate);
|
const authenticate = promisify(pam.authenticate);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|
||||||
class User extends Table{
|
class User extends Table{
|
||||||
@@ -86,35 +85,6 @@ class User extends Table{
|
|||||||
|
|
||||||
User.register();
|
User.register();
|
||||||
|
|
||||||
(async function(){
|
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||||
var defaultUser = 'proxyadmin2'
|
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
// registered. See the package lib/bootstrap.js for the original logic.
|
||||||
// auth.localAdminPass in proxy-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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.17",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.17",
|
"version": "1.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^6.0.1",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.5.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
@@ -281,6 +284,18 @@
|
|||||||
"@redis/client": "^6.1.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/app-stack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -293,6 +308,32 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapts": "^8.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/oidc-client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
@@ -395,6 +436,12 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -791,15 +838,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "6.0.1",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jake": "^10.8.5"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.18"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -1011,6 +1061,42 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/filelist": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/filelist/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/filelist/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -1387,6 +1473,23 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jake": {
|
||||||
|
"version": "10.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||||
|
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.6",
|
||||||
|
"filelist": "^1.0.4",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"jake": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jq-repeat": {
|
"node_modules/jq-repeat": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
|
||||||
@@ -1508,9 +1611,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/model-redis": {
|
"node_modules/model-redis": {
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz",
|
||||||
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==",
|
"integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"redis": "^6.1.0"
|
"redis": "^6.1.0"
|
||||||
@@ -1693,6 +1796,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.17",
|
"version": "1.4.0",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -22,12 +22,15 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^6.0.1",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.5.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ body {
|
|||||||
.card-title{
|
.card-title{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-required{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,10 +84,26 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// (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({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -87,40 +112,44 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
return body('PUT', url, data, callback);
|
||||||
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){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
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({
|
return $.ajax({
|
||||||
type: 'delete',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -128,11 +157,11 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,7 +208,11 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(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){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
}
|
}
|
||||||
@@ -188,18 +221,95 @@ app.auth = (function(app){
|
|||||||
return localStorage.getItem('APIToken');
|
return localStorage.getItem('APIToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn(callback){
|
async function getUser(){
|
||||||
if(getToken()){
|
try{
|
||||||
return app.api.get('user/me', function(error, data){
|
return await app.api.get('user/me');
|
||||||
// data now carries effective rights (isAdmin, global, domains).
|
}catch(error){
|
||||||
if(!error) app.auth.user = app.auth.perms = data;
|
if(error && error.status === 401) return null;
|
||||||
return callback(error, data);
|
throw error;
|
||||||
});
|
|
||||||
}else{
|
|
||||||
callback(null, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
@@ -230,46 +340,68 @@ app.auth = (function(app){
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when the logged-in user is a global admin (per user/me).
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
function isAdmin(){
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
// rather than by pausing document ready.
|
||||||
}
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
function logIn(args, callback){
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
app.api.post('auth/login', args, function(error, data){
|
async function forceLogin(requiredGroups){
|
||||||
if(data.login){
|
var user = await loadUser();
|
||||||
setToken(data.token);
|
|
||||||
}
|
if(!user){
|
||||||
callback(error, !!data.token);
|
logOut(function(){});
|
||||||
});
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
}
|
location.pathname + location.search
|
||||||
|
));
|
||||||
function logOut(callback){
|
return false;
|
||||||
localStorage.removeItem('APIToken');
|
}
|
||||||
callback();
|
|
||||||
}
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
|
location.replace('/onboarding');
|
||||||
function forceLogin(){
|
return false;
|
||||||
// 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(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(error || !isLoggedIn){
|
app.util.actionMessage(
|
||||||
app.auth.logOut(function(){})
|
`<h1>
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
}
|
<b>You do not have permission to be here.</b>
|
||||||
});
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
</h1>`,
|
||||||
|
$('#spa-shell'),
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
throw new Error("User does not have permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
isLoggedIn: isLoggedIn,
|
getUser: getUser,
|
||||||
consumeTokenFragment: consumeTokenFragment,
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
perms: null,
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
@@ -279,6 +411,11 @@ app.auth = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
app.user = (function(app){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('user/?detail=true', function(error, data){
|
app.api.get('user/?detail=true', function(error, data){
|
||||||
@@ -308,6 +445,8 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
app.permission = (function(app){
|
app.permission = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('permission/', function(error, data){
|
app.api.get('permission/', function(error, data){
|
||||||
@@ -381,9 +520,12 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $target, type, callback){
|
function actionMessage(message, $targetPassed, type, callback){
|
||||||
message = message || '';
|
message = message || '';
|
||||||
$target = $target.closest('div.card').find('.actionMessage');
|
|
||||||
|
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||||
|
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||||
|
|
||||||
type = type || 'info';
|
type = type || 'info';
|
||||||
callback = callback || function(){};
|
callback = callback || function(){};
|
||||||
|
|
||||||
@@ -400,12 +542,48 @@ app.util = (function(app){
|
|||||||
})
|
})
|
||||||
}else{
|
}else{
|
||||||
if(type) $target.addClass('bg-' + type);
|
if(type) $target.addClass('bg-' + type);
|
||||||
message = '<span class="align-middle">' + message + '</span><button class="action-close btn btn-sm btn-outline-dark float-end"><i class="fa-solid fa-xmark"></i></button>'
|
|
||||||
|
// Messages that bring their own buttons (actionConfirm) are left
|
||||||
|
// alone; everything else gets the standard dismiss button.
|
||||||
|
if(!message.includes('<button')) message = `
|
||||||
|
<span class="align-middle">${message}</span>
|
||||||
|
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
$target.html(message).slideDown('fast');
|
$target.html(message).slideDown('fast');
|
||||||
}
|
}
|
||||||
setTimeout(callback,10)
|
setTimeout(callback,10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionConfirm(message, $target, type, callback){
|
||||||
|
return new Promise((resolve, reject) =>{
|
||||||
|
let id = crypto.randomUUID();
|
||||||
|
message = `
|
||||||
|
<h4 class"align-middle" >
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
<b>${message}</b>
|
||||||
|
<span class="float-end">
|
||||||
|
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
||||||
|
<i class="fa-solid fa-circle-check"></i>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger confirm-${id}">
|
||||||
|
<i class="fa-solid fa-circle-stop"></i>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
`
|
||||||
|
actionMessage(message, $target, type);
|
||||||
|
$("body").on('click', `.confirm-${id}`, function(){
|
||||||
|
actionMessage('', $target, type);
|
||||||
|
resolve(!!$(this).data('confirm'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
var obj = {};
|
var obj = {};
|
||||||
|
|
||||||
@@ -462,11 +640,42 @@ app.util = (function(app){
|
|||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage
|
actionMessage: actionMessage,
|
||||||
|
actionConfirm,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(function(){
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
|
var style = document.getElementById('group-required-rules');
|
||||||
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
|
try{
|
||||||
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
|
style.sheet.cssRules.length
|
||||||
|
);
|
||||||
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
$('div.row').fadeIn('slow'); //show the page
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
//panel button's
|
//panel button's
|
||||||
@@ -520,12 +729,14 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger');
|
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
app.util.actionMessage(
|
||||||
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
|
`<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>`,
|
||||||
$form,
|
$form,
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const middleware = require('../middleware/auth');
|
|||||||
const authz = require('../middleware/authz');
|
const authz = require('../middleware/authz');
|
||||||
|
|
||||||
// API routes for authentication.
|
// API routes for authentication.
|
||||||
router.use('/auth', require('./auth'));
|
router.use('/auth', require('../models').authRouter);
|
||||||
|
|
||||||
// API routes for working with users. All endpoints need to be have valid user.
|
// API routes for working with users. All endpoints need to be have valid user.
|
||||||
// User management is admin-only; the router allows self-service exceptions
|
// User management is admin-only; the router allows self-service exceptions
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { rateLimit } = require('express-rate-limit');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const { Auth } = require('../models/auth');
|
|
||||||
const { OidcState } = require('../models/oidc_state');
|
|
||||||
const oidc = require('../utils/oidc');
|
|
||||||
const { safeInternalPath } = require('../utils/safe_redirect');
|
|
||||||
|
|
||||||
// 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', 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.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;
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
|
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
|
||||||
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
|
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
|
||||||
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a
|
* OIDC authorization-code flow (reusing @simpleworkjs/oidc-client's pure oidc
|
||||||
|
* utils and conf.oidc) and, on a
|
||||||
* successful + authorized login, mint a Redis-backed SsoSession and set the
|
* successful + authorized login, mint a Redis-backed SsoSession and set the
|
||||||
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
||||||
* session (ops/nginx_conf/hostfeatures.lua).
|
* session (ops/nginx_conf/hostfeatures.lua).
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const oidc = require('../utils/oidc');
|
const {oidc} = require('@simpleworkjs/oidc-client');
|
||||||
const {Host} = require('../models').models;
|
const {Host} = require('../models').models;
|
||||||
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
||||||
const {identityAllowed} = require('../utils/host_sso');
|
const {identityAllowed} = require('../utils/host_sso');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const express = require('express');
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -15,24 +16,16 @@ const values ={
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// List of front end node modules to be served
|
||||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
|
|
||||||
'moment', '@popper', 'jq-repeat',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Server front end modules
|
|
||||||
// https://stackoverflow.com/a/55700773/3140931
|
|
||||||
// Vendor libraries only change when package versions are bumped (a rebuild),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
||||||
// still cover that rare case with a cheap 304 instead of a stale asset.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// every deploy and isn't cache-busted/fingerprinted.
|
||||||
|
mountStaticModules(router, {
|
||||||
|
root: path.join(__dirname, '..'),
|
||||||
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
|
||||||
// local folder. Shorter maxAge than /static-modules since this is the app's
|
|
||||||
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
|
|
||||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
|
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.redirect(301, '/hosts');
|
res.redirect(301, '/hosts');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const {describe, test} = require('node:test');
|
|||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const oidc = require('../../utils/oidc');
|
const oidc = require('@simpleworkjs/oidc-client').oidc;
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const {describe, test} = require('node:test');
|
const {describe, test} = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
const {safeInternalPath} = require('../../utils/safe_redirect');
|
const {safeInternalPath} = require('@simpleworkjs/oidc-client');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* safeInternalPath guards the OIDC post-login redirect against open-redirect
|
* safeInternalPath guards the OIDC post-login redirect against open-redirect
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||||
|
// (../ from here in utils/), matching the Dockerfile gitinfo stage; cwd is
|
||||||
|
// utils/ for the bare-metal git fallback.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile) -- the final image has no git binary and no .git
|
version,
|
||||||
// directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
function readBuildHash() {
|
});
|
||||||
try {
|
|
||||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
|
||||||
if (baked) return baked;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
|
||||||
} catch (_) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildVersion,
|
|
||||||
buildHash: readBuildHash(),
|
|
||||||
buildYear: new Date().getFullYear(),
|
|
||||||
};
|
|
||||||
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
|
|||||||
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
|
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
|
||||||
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
|
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
|
||||||
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
||||||
|
if('targets' in body) body.targets = parseAllowList(body.targets);
|
||||||
|
|
||||||
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
||||||
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal OpenID Connect authorization-code + PKCE client.
|
|
||||||
*
|
|
||||||
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
|
|
||||||
* instead we treat the flow as opaque and read identity from the userinfo
|
|
||||||
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
|
|
||||||
* global fetch (Node 18+) and crypto — no external dependency.
|
|
||||||
*
|
|
||||||
* All endpoints and client config come from conf.oidc (+ clientSecret from
|
|
||||||
* secrets.js, deep-merged by @simpleworkjs/conf).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const base64url = buf => buf.toString('base64')
|
|
||||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
|
|
||||||
// A high-entropy random string for `state` / PKCE verifier.
|
|
||||||
function randomToken(bytes = 32){
|
|
||||||
return base64url(crypto.randomBytes(bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// PKCE S256 challenge derived from the verifier.
|
|
||||||
function codeChallengeS256(verifier){
|
|
||||||
return base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
|
|
||||||
function createAuthRequest(){
|
|
||||||
let state = randomToken(32);
|
|
||||||
let codeVerifier = randomToken(32);
|
|
||||||
let codeChallenge = codeChallengeS256(codeVerifier);
|
|
||||||
return {state, codeVerifier, codeChallenge};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
|
|
||||||
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
|
|
||||||
function buildAuthUrl(state, codeChallenge, redirectUri){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: o.clientId,
|
|
||||||
redirect_uri: redirectUri || o.redirectUri,
|
|
||||||
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
|
||||||
state,
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
code_challenge_method: 'S256',
|
|
||||||
});
|
|
||||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
|
|
||||||
// must match the one used in buildAuthUrl (per-host for per-host SSO).
|
|
||||||
async function exchangeCode(code, codeVerifier, redirectUri){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let body = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri || o.redirectUri,
|
|
||||||
client_id: o.clientId,
|
|
||||||
client_secret: o.clientSecret,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
let res = await fetch(o.tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if(!res.ok){
|
|
||||||
let text = await res.text().catch(() => '');
|
|
||||||
let error = new Error('OidcTokenExchangeFailed');
|
|
||||||
error.name = 'OidcTokenExchangeFailed';
|
|
||||||
error.message = `Token exchange failed (${res.status}): ${text}`;
|
|
||||||
error.status = 502;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the userinfo claims for an access token.
|
|
||||||
async function fetchUserInfo(accessToken){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let res = await fetch(o.userinfoEndpoint, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if(!res.ok){
|
|
||||||
let error = new Error('OidcUserInfoFailed');
|
|
||||||
error.name = 'OidcUserInfoFailed';
|
|
||||||
error.message = `Userinfo request failed (${res.status})`;
|
|
||||||
error.status = 502;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull the app username and group list out of userinfo claims per conf.
|
|
||||||
function claimsToIdentity(claims){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
|
|
||||||
let groups = claims[o.groupsClaim || 'groups'] || [];
|
|
||||||
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
|
|
||||||
return {username, groups, claims};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
randomToken,
|
|
||||||
codeChallengeS256,
|
|
||||||
createAuthRequest,
|
|
||||||
buildAuthUrl,
|
|
||||||
exchangeCode,
|
|
||||||
fetchUserInfo,
|
|
||||||
claimsToIdentity,
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constrain a post-login redirect target to a same-origin path.
|
|
||||||
*
|
|
||||||
* Rejects anything that could leave the site or execute script:
|
|
||||||
* - absolute URLs ("https://evil.com") -> not a "/" path
|
|
||||||
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
|
|
||||||
* - scheme targets ("javascript:...", "data:...") -> XSS
|
|
||||||
* Anything not a plain "/path" falls back to "/".
|
|
||||||
*
|
|
||||||
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
|
|
||||||
* two in sync.
|
|
||||||
*/
|
|
||||||
function safeInternalPath(path){
|
|
||||||
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
|
||||||
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {safeInternalPath};
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/proxy',
|
||||||
|
licenseUrl: 'https://github.com/theta42/proxy/blob/master/LICENSE',
|
||||||
|
// In-app docs route (routes/docs.js). Apps without one point at the
|
||||||
|
// published docs site and set docsExternal.
|
||||||
|
docsUrl: '/docs',
|
||||||
|
docsExternal: false,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: null,
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: '/static/favicon.svg',
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: '/profile',
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: true,
|
||||||
|
updateLabel: 'the proxy',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
|
||||||
|
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []},
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
|
||||||
|
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
|
||||||
|
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
</div>
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
<span class="d-flex align-items-center gap-2">
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<a href="https://theta42.com" target="_blank">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
</a>
|
<span class="d-flex align-items-center gap-2">
|
||||||
© <%- buildYear %> theta42 ·
|
<a href="https://theta42.com" target="_blank">
|
||||||
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</span>
|
</a>
|
||||||
<span class="d-flex align-items-center gap-3">
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
</span>
|
||||||
</a>
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
</span>
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</div>
|
</a>
|
||||||
</footer>
|
<% if(ui.tosUrl){ %>
|
||||||
</body>
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
</html>
|
<% } %>
|
||||||
|
</span>
|
||||||
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
|
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
|
||||||
|
$f.find("textarea[name='targets']").val(hostFeatureListToText(h.targets));
|
||||||
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
|
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
|
||||||
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
|
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
|
||||||
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
|
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
|
||||||
@@ -632,6 +633,14 @@
|
|||||||
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
|
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
|
||||||
|
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080 10.0.0.3:8080"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TLS & Wildcard -->
|
<!-- TLS & Wildcard -->
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||||
// store it and forward on before doing anything else.
|
// store it and forward on before doing anything else.
|
||||||
if(!app.auth.consumeTokenFragment()){
|
if(!app.auth.consumeTokenFragment()){
|
||||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
// The reveal below touches an element further down this page, so wait
|
||||||
if(isLoggedIn){
|
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||||
app.auth.logInRedirect();
|
$(document).ready(function(){
|
||||||
}else{
|
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||||
// Reveal the login card once we know the user is not logged in.
|
if(isLoggedIn){
|
||||||
document.getElementById('login-card-row').style.display = '';
|
app.auth.logInRedirect();
|
||||||
}
|
}else{
|
||||||
})
|
// Reveal the login card once we know the user is not logged in.
|
||||||
|
document.getElementById('login-card-row').style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
<!-- CSS are placed here -->
|
<!-- CSS are placed here -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
@@ -14,8 +17,6 @@
|
|||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
|
|
||||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
|
|
||||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
@@ -28,49 +29,38 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link active" href="/hosts">
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
<i class="fa-solid fa-network-wired"></i>
|
<% for(const item of ui.nav){ %>
|
||||||
Hosts
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</a>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
</li>
|
<%- item.label %>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/dns"><i class="fa-solid fa-record-vinyl"></i>
|
|
||||||
DNS
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item nav-admin" style="display: none;">
|
|
||||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
|
||||||
Users
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item nav-admin" style="display: none;">
|
|
||||||
<a class="nav-link" href="/permissions"><i class="fa-solid fa-user-shield"></i>
|
|
||||||
Permissions
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item nav-admin" style="display: none;">
|
|
||||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-gear"></i>
|
|
||||||
Groups
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<% } %>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
<% if(ui.profileUrl){ %>
|
||||||
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||||
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
<i class="fas fa-sign-out"></i>
|
<i class="fas fa-sign-in"></i>
|
||||||
Login
|
Login
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
|
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
<i class="fas fa-sign-out"></i>
|
<i class="fas fa-sign-out"></i>
|
||||||
Log Out
|
Log Out
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
@@ -102,6 +93,22 @@
|
|||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkForUpdate(){
|
||||||
|
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||||
|
app.api.get('update-check', function(error, info){
|
||||||
|
if(error || !info || !info.updateAvailable) return;
|
||||||
|
$('#update-banner-text').html(
|
||||||
|
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||||
|
'(running v' + info.currentVersion + ') — ' +
|
||||||
|
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||||
|
);
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
// Set the correct link to active in the top nav bar
|
||||||
@@ -113,34 +120,24 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set the correct login/logout button, and reveal admin-only nav
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
// items for global admins.
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
app.auth.isLoggedIn(function(error, data){
|
// by app-base.js off the same cached user/me.
|
||||||
if(data){
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
$('#cl-logout-button').show();
|
$('#cl-logout-button').show();
|
||||||
if(data.username){
|
let username = me.uid || me.username;
|
||||||
$('#cl-username-text').text(data.username);
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
$('#cl-username').css('display', '');
|
$('#cl-username').css('display', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
}else{
|
}else{
|
||||||
$('#cl-login-button').show();
|
$('#cl-login-button').show();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(data && data.isAdmin){
|
|
||||||
$('.nav-admin').css('display', '');
|
|
||||||
|
|
||||||
if(!sessionStorage.getItem('update-banner-dismissed')){
|
|
||||||
app.api.get('update-check', function(error, info){
|
|
||||||
if(error || !info || !info.updateAvailable) return;
|
|
||||||
$('#update-banner-text').html(
|
|
||||||
'A newer version of the proxy is available: <b>v' + info.latestVersion + '</b> ' +
|
|
||||||
'(running v' + info.currentVersion + ') — ' +
|
|
||||||
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
|
||||||
);
|
|
||||||
showUpdateBanner();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -148,4 +145,5 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- Container -->
|
<!-- Container -->
|
||||||
<div id="spa-shell" class="container-fluid">
|
<div id="spa-shell" class="container-fluid">
|
||||||
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ apt-get install -y nodejs openresty
|
|||||||
echo "==> Lua modules"
|
echo "==> Lua modules"
|
||||||
luarocks install lua-resty-auto-ssl
|
luarocks install lua-resty-auto-ssl
|
||||||
luarocks install luasocket
|
luarocks install luasocket
|
||||||
|
luarocks install lua-resty-balancer
|
||||||
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
||||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||||
luarocks install lua-resty-ipmatcher
|
luarocks install lua-resty-ipmatcher
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
|
|||||||
|
|
||||||
local json = require "cjson"
|
local json = require "cjson"
|
||||||
local redis = require "resty.redis"
|
local redis = require "resty.redis"
|
||||||
|
local roundrobin = require "resty.roundrobin"
|
||||||
|
|
||||||
if not domain then
|
if not domain then
|
||||||
return nil, 499
|
return nil, 499
|
||||||
@@ -95,6 +96,44 @@ function M.get(ngx, domain, targetInfo)
|
|||||||
return nil, 406
|
return nil, 406
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Load balancing
|
||||||
|
local target_list = {}
|
||||||
|
table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"]))
|
||||||
|
|
||||||
|
if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then
|
||||||
|
local decodeOk, decodedTargets = pcall(json.decode, res["targets"])
|
||||||
|
if decodeOk and type(decodedTargets) == "table" then
|
||||||
|
for _, t in ipairs(decodedTargets) do
|
||||||
|
table.insert(target_list, t)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #target_list > 1 then
|
||||||
|
if not M.host_balancers then M.host_balancers = {} end
|
||||||
|
local cache_key = domain .. "_" .. (res["updated_on"] or "0")
|
||||||
|
|
||||||
|
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
|
||||||
|
local nodes = {}
|
||||||
|
for _, t in ipairs(target_list) do
|
||||||
|
nodes[t] = 1
|
||||||
|
end
|
||||||
|
local b = roundrobin:new(nodes)
|
||||||
|
M.host_balancers[domain] = { b = b, key = cache_key }
|
||||||
|
end
|
||||||
|
|
||||||
|
local peer = M.host_balancers[domain].b:find()
|
||||||
|
if peer then
|
||||||
|
local colon = peer:find(":")
|
||||||
|
if colon then
|
||||||
|
res["ip"] = peer:sub(1, colon - 1)
|
||||||
|
res["targetPort"] = peer:sub(colon + 1)
|
||||||
|
else
|
||||||
|
res["ip"] = peer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
ngx.ctx.targetInfo = res
|
ngx.ctx.targetInfo = res
|
||||||
-- Remember which host this target was resolved for, so the reuse guard at
|
-- Remember which host this target was resolved for, so the reuse guard at
|
||||||
-- the top can tell a genuine cache hit from a coalesced request for a
|
-- the top can tell a genuine cache hit from a coalesced request for a
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ module.exports = {
|
|||||||
adminUsers: ['proxyadmin'],
|
adminUsers: ['proxyadmin'],
|
||||||
groupRoleMap: {},
|
groupRoleMap: {},
|
||||||
// Optional: the local anti-lockout admin's initial password, used
|
// Optional: the local anti-lockout admin's initial password, used
|
||||||
// ONLY the first time that account is created. Leave unset and it
|
// ONLY the first time that account is created. Leave unset and a
|
||||||
// defaults to the username itself ("proxyadmin2") — fine for a quick
|
// random password is generated and printed to the container log on
|
||||||
// local test, but change it (or set this) before exposing the proxy
|
// first boot — fine for a quick local test if you copy it from the
|
||||||
// publicly. Once the account exists, this key is never read again;
|
// log right away, but set this (or change the password afterward)
|
||||||
// change the password via the app itself (or delete the Redis user
|
// before exposing the proxy publicly. Once the account exists, this
|
||||||
// to force it to be re-bootstrapped with a new value here).
|
// key is never read again; change the password via the app itself
|
||||||
|
// (or delete the Redis user to force it to be re-bootstrapped with a
|
||||||
|
// new value here).
|
||||||
// localAdminPass: 'change-me',
|
// localAdminPass: 'change-me',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||