Merge pull request #116 from theta42/feat/issues-48-57
Fix user creation & password (#48), per-host auth incl. SSO (#57), scheduler (#69) + host modal
This commit is contained in:
+6
-1
@@ -60,10 +60,15 @@ app.use(express.json());
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
// 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
|
||||
// per-host session cookie. Mounted before the page router.
|
||||
app.use('/__proxy_auth', require('./routes/host_auth'));
|
||||
|
||||
// Routes for front end content.
|
||||
app.use('/', require('./routes/render'));
|
||||
|
||||
// Routes for API
|
||||
// Routes for API
|
||||
app.use('/api', require('./routes/api'));
|
||||
|
||||
// Catch 404 and forward to error handler. If none of the above routes are
|
||||
|
||||
@@ -77,4 +77,13 @@ module.exports = {
|
||||
'https://ifconfig.me/ip',
|
||||
],
|
||||
},
|
||||
|
||||
// Per-host SSO (#57). Reuses conf.oidc for the identity provider. Sessions
|
||||
// are Redis-backed and read directly by OpenResty; the cookie only carries a
|
||||
// random session id.
|
||||
hostSso:{
|
||||
enabled: true,
|
||||
sessionTtl: 28800, // 8 hours, in seconds
|
||||
cookieName: '__proxy_sso',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,28 @@ class DnsProvider extends Table{
|
||||
return out;
|
||||
}
|
||||
|
||||
// Re-sync every configured provider's domain list from its API. Mirrors the
|
||||
// manual /dns/domain/refresh/:item route (get -> updateDomains) across all
|
||||
// providers; used by the host scheduler. Never throws — one bad provider
|
||||
// (e.g. a revoked key) must not abort the rest.
|
||||
static async refreshAllDomains(){
|
||||
let ids;
|
||||
try{
|
||||
ids = await this.list();
|
||||
}catch(error){
|
||||
console.error('refreshAllDomains: could not list providers', error.message);
|
||||
return;
|
||||
}
|
||||
for(let id of ids){
|
||||
try{
|
||||
let provider = await this.get(id);
|
||||
await provider.updateDomains();
|
||||
}catch(error){
|
||||
console.error('refreshAllDomains: provider', id, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get api(){
|
||||
return new this.constructor.Provider(this);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,18 @@ class Host extends Table{
|
||||
'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000},
|
||||
'respcache_enabled': {default: false, isRequired: false, type: 'boolean',},
|
||||
'hsts_enabled': {default: false, isRequired: false, type: 'boolean',},
|
||||
// Per-host HTTP basic auth. basicauth_users is {username: base64(sha1(pw))}
|
||||
// (hashed at the route layer, see utils/basicauth.js); enforced in
|
||||
// ops/nginx_conf/hostfeatures.lua.
|
||||
'basicauth_enabled': {default: false, isRequired: false, type: 'boolean',},
|
||||
'basicauth_realm': {default: 'Restricted', isRequired: false, type: 'string', min: 1, max: 128},
|
||||
'basicauth_users': {default: function(){return {}}, isRequired: false, type: 'object',},
|
||||
// Per-host SSO (OIDC via conf.oidc) — enforced by a signed session cookie
|
||||
// checked in ops/nginx_conf/hostfeatures.lua. Empty allow-lists mean "any
|
||||
// authenticated user". basic auth and SSO are OR'd (either satisfies).
|
||||
'sso_enabled': {default: false, isRequired: false, type: 'boolean',},
|
||||
'sso_allow_users': {default: function(){return []}, isRequired: false, type: 'object',},
|
||||
'sso_allow_groups': {default: function(){return []}, isRequired: false, type: 'object',},
|
||||
'req_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
|
||||
'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
|
||||
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
|
||||
|
||||
@@ -14,3 +14,4 @@ require('./user');
|
||||
require('./local_group');
|
||||
require('./permission');
|
||||
require('./oidc_state');
|
||||
require('./sso_session');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
/**
|
||||
* Per-host SSO models (#57).
|
||||
*
|
||||
* HostSsoState — short-lived, in-flight OIDC authorization request for a
|
||||
* protected host (like OidcState, but carries the target host + post-login
|
||||
* redirect). Auto-expires via TTL.
|
||||
*
|
||||
* SsoSession — an established session after a successful, authorized login.
|
||||
* Keyed by a random session id stored in the browser's `__proxy_sso` cookie.
|
||||
* OpenResty (ops/nginx_conf/hostfeatures.lua) reads `proxy_SsoSession_<sid>`
|
||||
* straight from Redis to gate requests; the allow-list was already enforced at
|
||||
* callback time (utils/host_sso.js), so the Lua side only checks that a session
|
||||
* exists and belongs to this host. Auto-expires via TTL.
|
||||
*/
|
||||
|
||||
const SESSION_TTL = (conf.hostSso && conf.hostSso.sessionTtl) || 28800; // 8h
|
||||
|
||||
class HostSsoState extends Table{
|
||||
static _key = 'state';
|
||||
static _ttl = 300; // 5 minutes bounds the auth round-trip / replay
|
||||
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},
|
||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'rd': {default: '/', isRequired: false, type: 'string'},
|
||||
}
|
||||
}
|
||||
HostSsoState.register();
|
||||
|
||||
class SsoSession extends Table{
|
||||
static _key = 'sid';
|
||||
static _ttl = SESSION_TTL;
|
||||
static _keyMap = {
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'sid': {isRequired: true, type: 'string', min: 16, max: 500},
|
||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'sub': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'email': {default: '', isRequired: false, type: 'string'},
|
||||
'groups': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||
}
|
||||
|
||||
static ttl(){ return SESSION_TTL; }
|
||||
}
|
||||
SsoSession.register();
|
||||
|
||||
module.exports = {HostSsoState, SsoSession};
|
||||
+1
-24
@@ -61,27 +61,4 @@ class AuthToken extends Token{
|
||||
}
|
||||
AuthToken.register();
|
||||
|
||||
class InviteToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
claimed_by: {default:"__NONE__", isRequired: false, type: 'string',},
|
||||
}
|
||||
|
||||
async consume(data){
|
||||
try{
|
||||
if(this.is_valid){
|
||||
data['is_valid'] = false;
|
||||
|
||||
await this.update(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
InviteToken.register();
|
||||
|
||||
module.exports = {Token, InviteToken, AuthToken};
|
||||
module.exports = {Token, AuthToken};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { Client, Attribute, Change } = require('ldapts');
|
||||
const {Token, InviteToken} = require('./token');
|
||||
const {Token} = require('./token');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
|
||||
@@ -146,17 +146,6 @@ User.exists = async function(data){
|
||||
}
|
||||
};
|
||||
|
||||
User.invite = async function(){
|
||||
try{
|
||||
let token = await InviteToken.add({created_by: this.username});
|
||||
|
||||
return token;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
let user = await this.get(data.username);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const linuxUser = require('linux-sys-user').promise();
|
||||
const objValidate = require('../utils/object_validate');
|
||||
const {Token, InviteToken} = require('./token');
|
||||
const {Token} = require('./token');
|
||||
const {promisify} = require('util');
|
||||
const pam = require('authenticate-pam');
|
||||
const authenticate = promisify(pam.authenticate);
|
||||
@@ -90,31 +90,6 @@ User.create = async function(data) {
|
||||
}
|
||||
};
|
||||
|
||||
User.addByInvite = async function(data){
|
||||
try{
|
||||
let token = await InviteToken.get(data.token);
|
||||
|
||||
if(!token.is_valid){
|
||||
let error = new Error('Token Invalid');
|
||||
error.name = 'Token Invalid';
|
||||
error.message = `Token is not valid or as allready been used. ${data.token}`;
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let user = await this.add(data);
|
||||
|
||||
if(user){
|
||||
await token.consume({claimed_by: user.username});
|
||||
return user;
|
||||
}
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
User.remove = async function(data){
|
||||
try{
|
||||
return await linuxUser.removeUser(this.username);
|
||||
@@ -133,17 +108,6 @@ User.setPassword = async function(data){
|
||||
}
|
||||
};
|
||||
|
||||
User.invite = async function(){
|
||||
try{
|
||||
let token = await InviteToken.add({created_by: this.username});
|
||||
|
||||
return token;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
let auth = await authenticate(data.username, data.password);
|
||||
|
||||
+3
-3
@@ -11,10 +11,10 @@
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
||||
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js",
|
||||
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
||||
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js",
|
||||
"test:integration": "node --test test/integration/dns_provider.test.js",
|
||||
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -176,10 +176,22 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
||||
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
||||
password: function( value ) {
|
||||
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
|
||||
if ( reg.test( value ) === false ) {
|
||||
return "Weak password, Try again";
|
||||
if ( typeof value !== 'string' || value.length < 8 ) {
|
||||
return "Password must be at least 8 characters";
|
||||
}
|
||||
if ( value.length >= 12 ) return;
|
||||
|
||||
var classes = 0;
|
||||
if ( /[a-z]/.test( value ) ) classes++;
|
||||
if ( /[A-Z]/.test( value ) ) classes++;
|
||||
if ( /[0-9]/.test( value ) ) classes++;
|
||||
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
||||
|
||||
if ( classes < 3 ) {
|
||||
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-1
@@ -1,10 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const {Host, Domain} = require('../models').models;
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const {Host, Domain, User} = require('../models').models;
|
||||
const {LocalGroup} = require('../models/local_group');
|
||||
const {Permission} = require('../models/permission');
|
||||
const authz = require('../middleware/authz');
|
||||
const {normalizeHostFeatures} = require('../utils/host_features');
|
||||
const {collectHostFieldErrors} = require('../utils/hostname_validate');
|
||||
const {hashBasicAuthUsers} = require('../utils/basicauth');
|
||||
|
||||
const Model = Host;
|
||||
|
||||
@@ -15,6 +19,40 @@ function validateHostFields(body){
|
||||
if(errors.length) throw Model.errors.ObjectValidateError(errors);
|
||||
}
|
||||
|
||||
// After normalizeHostFeatures has parsed basic-auth creds to {user: plaintext},
|
||||
// hash them so plaintext never reaches Redis. Runs at the route layer only, so
|
||||
// internally-copied records (cache/wildcard children) keep their existing hashes.
|
||||
function hashHostSecrets(body){
|
||||
if(body.basicauth_users && typeof body.basicauth_users === 'object'){
|
||||
body.basicauth_users = hashBasicAuthUsers(body.basicauth_users);
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete source for the per-host auth allow-lists (SSO users/groups).
|
||||
// Available to any authenticated host editor (not just global admins). Groups
|
||||
// are derived from local groups, existing permission group-subjects, and the
|
||||
// conf.auth admin/role-map groups.
|
||||
router.get('/auth-suggestions', async function(req, res, next){
|
||||
try{
|
||||
let users = [];
|
||||
try{ users = (await User.list()) || []; }catch(error){ /* none */ }
|
||||
|
||||
let groups = new Set();
|
||||
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
|
||||
try{
|
||||
for(let p of await Permission.listDetail()){
|
||||
if(p.subjectType === 'group' && p.subject) groups.add(p.subject);
|
||||
}
|
||||
}catch(error){ /* none */ }
|
||||
for(let g of (conf.auth && conf.auth.adminGroups) || []) groups.add(g);
|
||||
for(let g of Object.keys((conf.auth && conf.auth.groupRoleMap) || {})) groups.add(g);
|
||||
|
||||
return res.json({users, groups: [...groups].sort()});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
let results = await Model[req.query.detail ? "listDetail" : "list"]();
|
||||
@@ -35,6 +73,7 @@ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), asy
|
||||
req.body.created_by = authz.reqUsername(req);
|
||||
validateHostFields(req.body);
|
||||
normalizeHostFeatures(req.body);
|
||||
hashHostSecrets(req.body);
|
||||
let item = await Model.create(req.body);
|
||||
|
||||
return res.json({
|
||||
@@ -100,6 +139,7 @@ router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam)
|
||||
req.body.updated_by = authz.reqUsername(req);
|
||||
validateHostFields(req.body);
|
||||
normalizeHostFeatures(req.body);
|
||||
hashHostSecrets(req.body);
|
||||
let item = await Model.get(req.params.item);
|
||||
item = await item.update(req.body);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a
|
||||
* successful + authorized login, mint a Redis-backed SsoSession and set the
|
||||
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
||||
* session (ops/nginx_conf/hostfeatures.lua).
|
||||
*
|
||||
* Callback style: per-host (option A) — redirect_uri is
|
||||
* https://<host>/__proxy_auth/callback, so each protected host's callback must
|
||||
* be an allowed redirect URI at the IdP (a wildcard redirect URI covers all).
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const oidc = require('../utils/oidc');
|
||||
const {Host} = require('../models').models;
|
||||
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
||||
const {identityAllowed} = require('../utils/host_sso');
|
||||
|
||||
const COOKIE = (conf.hostSso && conf.hostSso.cookieName) || '__proxy_sso';
|
||||
|
||||
// Minimal HTML notice page (these endpoints are hit by browsers, not the API).
|
||||
function page(message){
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign in</title>`
|
||||
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
||||
+ `<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}</style>`
|
||||
+ `</head><body><p>${String(message).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}</p></body></html>`;
|
||||
}
|
||||
|
||||
// This host's own callback URL — must match between authorize and token steps.
|
||||
function callbackUri(req){
|
||||
return `${req.protocol}://${req.get('host')}/__proxy_auth/callback`;
|
||||
}
|
||||
|
||||
// Constrain the post-login redirect to this same host (no open redirect). `rd`
|
||||
// may be a bare path or a full same-host URL.
|
||||
function safeRd(req, rd){
|
||||
try{
|
||||
if(!rd) return '/';
|
||||
if(rd.charAt(0) === '/' && rd.charAt(1) !== '/') return rd;
|
||||
let u = new URL(rd);
|
||||
if(u.host === req.get('host')) return u.pathname + u.search;
|
||||
}catch(error){ /* fall through */ }
|
||||
return '/';
|
||||
}
|
||||
|
||||
function readCookie(req, name){
|
||||
for(let part of (req.headers.cookie || '').split(';')){
|
||||
let idx = part.indexOf('=');
|
||||
if(idx === -1) continue;
|
||||
if(part.slice(0, idx).trim() === name) return decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve the effective Host record (exact, else via the wildcard lookup tree)
|
||||
// so we can read its SSO allow-lists.
|
||||
async function resolveHost(hostname){
|
||||
try{ return await Host.get(hostname); }catch(error){ /* try wildcard */ }
|
||||
try{ return Host.lookUp(hostname) || null; }catch(error){ return null; }
|
||||
}
|
||||
|
||||
// Begin login: create PKCE/state, remember the target host + return path, and
|
||||
// redirect the browser to the IdP.
|
||||
router.get('/start', async function(req, res, next){
|
||||
try{
|
||||
if(!conf.oidc || !conf.oidc.enabled){
|
||||
return res.status(503).send(page('SSO is not configured on this proxy.'));
|
||||
}
|
||||
let hostname = req.hostname;
|
||||
let hostRec = await resolveHost(hostname);
|
||||
if(!hostRec || !hostRec.sso_enabled){
|
||||
return res.status(404).send(page('SSO is not enabled for this host.'));
|
||||
}
|
||||
|
||||
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||
await HostSsoState.create({state, codeVerifier, host: hostname, rd: safeRd(req, req.query.rd)});
|
||||
|
||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge, callbackUri(req)));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// OIDC redirect target: validate state, exchange the code, enforce the host's
|
||||
// allow-list, then establish the session and return the user to where they were.
|
||||
router.get('/callback', async function(req, res, next){
|
||||
try{
|
||||
let {code, state} = req.query;
|
||||
if(!code || !state) return res.status(400).send(page('Missing authorization code.'));
|
||||
|
||||
let st = await HostSsoState.get(state).catch(() => null);
|
||||
if(!st) return res.status(400).send(page('Your login session expired. Please try again.'));
|
||||
await st.remove().catch(() => {}); // one-time use
|
||||
|
||||
let hostname = req.hostname;
|
||||
if(st.host !== hostname) return res.status(400).send(page('Login host mismatch.'));
|
||||
|
||||
let tokens = await oidc.exchangeCode(code, st.codeVerifier, callbackUri(req));
|
||||
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
||||
let identity = oidc.claimsToIdentity(claims);
|
||||
let email = claims.email || '';
|
||||
|
||||
let hostRec = await resolveHost(hostname);
|
||||
let allowUsers = (hostRec && hostRec.sso_allow_users) || [];
|
||||
let allowGroups = (hostRec && hostRec.sso_allow_groups) || [];
|
||||
|
||||
if(!identityAllowed({username: identity.username, email, groups: identity.groups}, allowUsers, allowGroups)){
|
||||
return res.status(403).send(page(`You are not authorized to access ${hostname}.`));
|
||||
}
|
||||
|
||||
let sid = oidc.randomToken(32);
|
||||
await SsoSession.create({sid, host: hostname, sub: identity.username, email, groups: identity.groups || []});
|
||||
|
||||
res.cookie(COOKIE, sid, {
|
||||
httpOnly: true,
|
||||
secure: req.protocol === 'https',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SsoSession.ttl() * 1000,
|
||||
});
|
||||
return res.redirect(safeRd(req, st.rd));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// End the session for this host.
|
||||
router.get('/logout', async function(req, res, next){
|
||||
try{
|
||||
let sid = readCookie(req, COOKIE);
|
||||
if(sid){
|
||||
try{ let s = await SsoSession.get(sid); await s.remove(); }catch(error){ /* gone */ }
|
||||
}
|
||||
res.clearCookie(COOKIE, {path: '/'});
|
||||
return res.redirect(safeRd(req, req.query.rd));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+17
-31
@@ -3,10 +3,18 @@
|
||||
const router = require('express').Router();
|
||||
const {User} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
const {passwordError} = require('../utils/password_policy');
|
||||
|
||||
// Reject a weak password before it reaches the model. Throws 422 with a
|
||||
// per-field key the frontend surfaces inline.
|
||||
function validatePassword(password){
|
||||
let message = passwordError(password);
|
||||
if(message) throw User.errors.ObjectValidateError([{key: 'password', message}]);
|
||||
}
|
||||
|
||||
// User management is global-admin-only, except the self-service routes below
|
||||
// (GET /me, PUT /password, POST /key) which any authenticated user may call for
|
||||
// their own account.
|
||||
// (GET /me, PUT /password) which any authenticated user may call for their own
|
||||
// account.
|
||||
|
||||
router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
@@ -21,8 +29,12 @@ router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
router.post('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = authz.reqUsername(req)
|
||||
validatePassword(req.body.password);
|
||||
|
||||
return res.json(await User.add(req.body));
|
||||
// User.create (not the nonexistent User.add) — the drift here meant every
|
||||
// API-created account threw, so the new credentials never existed to log
|
||||
// in with (issue #48).
|
||||
return res.json(await User.create(req.body));
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -62,6 +74,7 @@ router.get('/me', async function(req, res, next){
|
||||
// Self-service: change your own password.
|
||||
router.put('/password', async function(req, res, next){
|
||||
try{
|
||||
validatePassword(req.body.password);
|
||||
return res.json({results: await req.user.setPassword(req.body)})
|
||||
}catch(error){
|
||||
next(error);
|
||||
@@ -71,6 +84,7 @@ router.put('/password', async function(req, res, next){
|
||||
// Admin: reset another user's password.
|
||||
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
validatePassword(req.body.password);
|
||||
let user = await User.get(req.params.username);
|
||||
return res.json({results: await user.setPassword(req.body)});
|
||||
}catch(error){
|
||||
@@ -78,32 +92,4 @@ router.put('/password/:username', authz.requireAdmin, async function(req, res, n
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/invite', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let token = await req.user.invite();
|
||||
|
||||
return res.json({token: token.token});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: add an SSH key to your own account.
|
||||
router.post('/key', async function(req, res, next){
|
||||
try{
|
||||
let added = await User.addSSHkey({
|
||||
username: authz.reqUsername(req),
|
||||
key: req.body.key
|
||||
});
|
||||
|
||||
return res.status(added === true ? 200 : 400).json({
|
||||
message: added
|
||||
});
|
||||
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const {Host} = require('../models/host');
|
||||
const {DnsProvider} = require('../models').models;
|
||||
|
||||
|
||||
function hostSchedulerService(){
|
||||
@@ -29,8 +30,14 @@ function hostSchedulerService(){
|
||||
// Ensures certificates are renewed well before expiration
|
||||
setInterval(Host.checkWildcardForRenew.bind(Host), conf.service.hostScheduler.interval);
|
||||
|
||||
// Refresh each DNS provider's domain list on the same cadence so domains
|
||||
// added/removed at the provider are picked up without a manual refresh.
|
||||
setTimeout(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.initial);
|
||||
setInterval(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.interval);
|
||||
|
||||
console.log('Host scheduler service initialized');
|
||||
console.log('- Wildcard cert check: 30s after start, then every 24h');
|
||||
console.log('- DNS provider domain refresh: 30s after start, then every 24h');
|
||||
}
|
||||
|
||||
if(conf.service.hostScheduler.enabled !== false) hostSchedulerService();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {hashPassword, hashBasicAuthUsers} = require('../../utils/basicauth');
|
||||
const {
|
||||
parseBasicAuthLines,
|
||||
sanitizeBasicAuthObject,
|
||||
sanitizeRealm,
|
||||
parseAllowList,
|
||||
normalizeHostFeatures,
|
||||
} = require('../../utils/host_features');
|
||||
|
||||
/**
|
||||
* Per-host basic auth (#57). The hash must match what OpenResty computes in
|
||||
* ops/nginx_conf/hostfeatures.lua: base64(sha1(password)) (htpasswd "{SHA}").
|
||||
*/
|
||||
describe('basicauth hashing', () => {
|
||||
test('base64(sha1(password)) matches the known htpasswd {SHA} vector', () => {
|
||||
assert.strictEqual(hashPassword('secret'), '5en6G6MezRroT3XKqkdPOmY/BfQ=');
|
||||
});
|
||||
test('hashBasicAuthUsers hashes each password, skips empties', () => {
|
||||
assert.deepStrictEqual(
|
||||
hashBasicAuthUsers({alice: 'secret', bob: '', carol: null}),
|
||||
{alice: '5en6G6MezRroT3XKqkdPOmY/BfQ='}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBasicAuthLines', () => {
|
||||
test('parses user:password lines; passwords may contain colons', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseBasicAuthLines('alice:secret\nbob:pw:with:colons'),
|
||||
{alice: 'secret', bob: 'pw:with:colons'}
|
||||
);
|
||||
});
|
||||
test('drops blank lines, lines without a colon, and empty passwords', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseBasicAuthLines('\nalice:secret\nnopassword\nbob:\n \n'),
|
||||
{alice: 'secret'}
|
||||
);
|
||||
});
|
||||
test('rejects usernames with spaces/control chars', () => {
|
||||
assert.deepStrictEqual(parseBasicAuthLines('a b:secret'), {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeRealm', () => {
|
||||
test('strips CR/LF and quotes and trims', () => {
|
||||
assert.strictEqual(sanitizeRealm('My "Realm"\r\n'), 'My Realm');
|
||||
assert.strictEqual(sanitizeRealm(undefined), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeHostFeatures (basic auth)', () => {
|
||||
test('coerces enabled, parses users to plaintext object, sanitizes realm', () => {
|
||||
let body = {
|
||||
basicauth_enabled: 'true',
|
||||
basicauth_realm: 'Admins\n',
|
||||
basicauth_users: 'alice:secret\nbob:pw',
|
||||
};
|
||||
normalizeHostFeatures(body);
|
||||
assert.strictEqual(body.basicauth_enabled, true);
|
||||
assert.strictEqual(body.basicauth_realm, 'Admins');
|
||||
assert.deepStrictEqual(body.basicauth_users, {alice: 'secret', bob: 'pw'});
|
||||
});
|
||||
test('empty users input is dropped so a blank edit keeps existing users', () => {
|
||||
let body = {basicauth_enabled: 'true', basicauth_users: ' \n'};
|
||||
normalizeHostFeatures(body);
|
||||
assert.ok(!('basicauth_users' in body));
|
||||
});
|
||||
test('object input is sanitized like text input', () => {
|
||||
let body = {basicauth_users: {alice: 'secret', 'bad user': 'x', bob: ''}};
|
||||
normalizeHostFeatures(body);
|
||||
assert.deepStrictEqual(body.basicauth_users, {alice: 'secret'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO allow-lists (#57)', () => {
|
||||
test('parseAllowList splits on commas/whitespace/newlines and dedupes', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseAllowList('alice@x.com, bob@x.com\ncarol@x.com alice@x.com'),
|
||||
['alice@x.com', 'bob@x.com', 'carol@x.com']
|
||||
);
|
||||
assert.deepStrictEqual(parseAllowList(['a', 'a', ' b ', '']), ['a', 'b']);
|
||||
assert.deepStrictEqual(parseAllowList(''), []);
|
||||
});
|
||||
test('normalizeHostFeatures coerces sso_enabled and parses allow-lists', () => {
|
||||
let body = {
|
||||
sso_enabled: 'true',
|
||||
sso_allow_users: 'alice@x.com\nbob@x.com',
|
||||
sso_allow_groups: 'dns-team, admins',
|
||||
};
|
||||
normalizeHostFeatures(body);
|
||||
assert.strictEqual(body.sso_enabled, true);
|
||||
assert.deepStrictEqual(body.sso_allow_users, ['alice@x.com', 'bob@x.com']);
|
||||
assert.deepStrictEqual(body.sso_allow_groups, ['dns-team', 'admins']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {identityAllowed} = require('../../utils/host_sso');
|
||||
|
||||
describe('identityAllowed (per-host SSO authorization)', () => {
|
||||
const id = {username: 'alice', email: 'alice@x.com', groups: ['dns-team', 'staff']};
|
||||
|
||||
test('empty allow-lists allow any authenticated user', () => {
|
||||
assert.strictEqual(identityAllowed(id, [], []), true);
|
||||
assert.strictEqual(identityAllowed(id, undefined, undefined), true);
|
||||
});
|
||||
|
||||
test('allows by username', () => {
|
||||
assert.strictEqual(identityAllowed(id, ['bob', 'alice'], []), true);
|
||||
});
|
||||
|
||||
test('allows by email', () => {
|
||||
assert.strictEqual(identityAllowed(id, ['alice@x.com'], []), true);
|
||||
});
|
||||
|
||||
test('allows by group membership', () => {
|
||||
assert.strictEqual(identityAllowed(id, [], ['dns-team']), true);
|
||||
});
|
||||
|
||||
test('denies when neither user nor group matches a non-empty list', () => {
|
||||
assert.strictEqual(identityAllowed(id, ['bob'], ['admins']), false);
|
||||
});
|
||||
|
||||
test('is case-insensitive', () => {
|
||||
assert.strictEqual(identityAllowed(id, ['ALICE'], []), true);
|
||||
assert.strictEqual(identityAllowed(id, [], ['DNS-Team']), true);
|
||||
assert.strictEqual(identityAllowed({username: 'A', email: 'A@X.com'}, ['a@x.com'], []), true);
|
||||
});
|
||||
|
||||
test('handles a missing/empty identity gracefully', () => {
|
||||
assert.strictEqual(identityAllowed({}, ['bob'], ['admins']), false);
|
||||
assert.strictEqual(identityAllowed({}, [], []), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {passwordError} = require('../../utils/password_policy');
|
||||
|
||||
/**
|
||||
* The old rule (issue #48) rejected strong passwords and accepted weak ones.
|
||||
* These pin the corrected behavior: length-forward, 3-of-4 character classes.
|
||||
*/
|
||||
describe('passwordError', () => {
|
||||
test('accepts a strong mixed password (previously rejected)', () => {
|
||||
assert.strictEqual(passwordError('@123Caplowercase'), null);
|
||||
});
|
||||
test('accepts a 12+ char passphrase on length alone', () => {
|
||||
assert.strictEqual(passwordError('correcthorsebattery'), null);
|
||||
});
|
||||
test('rejects a weak two-class password (previously accepted)', () => {
|
||||
assert.notStrictEqual(passwordError('lowercase1'), null);
|
||||
});
|
||||
test('rejects too-short passwords', () => {
|
||||
assert.notStrictEqual(passwordError('Ab3$xy'), null); // 6 chars
|
||||
assert.notStrictEqual(passwordError(''), null);
|
||||
assert.notStrictEqual(passwordError(undefined), null);
|
||||
});
|
||||
test('accepts 8 chars with 3 classes', () => {
|
||||
assert.strictEqual(passwordError('Abcd123!'), null); // upper, lower, num, sym
|
||||
assert.strictEqual(passwordError('Abcdefg1'), null); // upper, lower, num
|
||||
});
|
||||
test('rejects 8-11 chars with only 2 classes', () => {
|
||||
assert.notStrictEqual(passwordError('abcdefg1'), null); // lower + num only
|
||||
assert.notStrictEqual(passwordError('ABCDEFG1'), null); // upper + num only
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Server-only hashing for per-host basic-auth credentials. Kept out of the pure,
|
||||
* browser-mirrored utils/host_features.js because it needs Node crypto.
|
||||
*
|
||||
* Passwords are stored as base64(SHA-1(password)) — the Apache htpasswd "{SHA}"
|
||||
* scheme — so plaintext never lands in Redis. OpenResty verifies with the same
|
||||
* hash (ops/nginx_conf/hostfeatures.lua): base64(sha1(password)).
|
||||
*
|
||||
* SHA-1 is weak for password storage in general, but this is a lightweight proxy
|
||||
* gate (not the app's own accounts) and matches htpasswd; upgrading the scheme is
|
||||
* a follow-up. Enforce strong passwords operationally.
|
||||
*/
|
||||
function hashPassword(password){
|
||||
return crypto.createHash('sha1').update(String(password)).digest('base64');
|
||||
}
|
||||
|
||||
// { username: plaintext } -> { username: base64sha1 }. Skips empty passwords.
|
||||
function hashBasicAuthUsers(users){
|
||||
let out = {};
|
||||
if(!users || typeof users !== 'object') return out;
|
||||
for(let user of Object.keys(users)){
|
||||
let pass = users[user];
|
||||
if(pass === undefined || pass === null || pass === '') continue;
|
||||
out[user] = hashPassword(pass);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {hashPassword, hashBasicAuthUsers};
|
||||
@@ -11,6 +11,13 @@
|
||||
const MAX_HEADERS = 50; // per direction (req/resp)
|
||||
const MAX_HEADER_VALUE = 2048; // chars
|
||||
const MAX_CIDRS = 200; // per list (allow/deny)
|
||||
const MAX_BASICAUTH_USERS = 100;
|
||||
const MAX_PASSWORD = 256;
|
||||
const MAX_REALM = 128;
|
||||
|
||||
// Basic-auth username: printable ASCII, no space or control chars. ':' can't
|
||||
// appear (we split on the first ':'), but the class excludes it anyway.
|
||||
const BASICAUTH_USER_RE = /^[\x21-\x39\x3B-\x7e]+$/;
|
||||
|
||||
// RFC 7230 header field-name token characters.
|
||||
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
@@ -125,6 +132,79 @@ function stringifyCidrs(arr){
|
||||
return arr.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* "username:password" lines -> { username: password } (plaintext). The first
|
||||
* ':' splits; usernames are validated and CR/LF is stripped from passwords.
|
||||
* Lines without a password are dropped. Hashing happens server-side
|
||||
* (utils/basicauth.js) — this stays pure so the browser can share it.
|
||||
*/
|
||||
function parseBasicAuthLines(text){
|
||||
let out = {};
|
||||
if(text === undefined || text === null) return out;
|
||||
|
||||
for(let line of String(text).split(/\r?\n/)){
|
||||
line = line.replace(/[\r\n]/g, '');
|
||||
if(!line.trim()) continue;
|
||||
let idx = line.indexOf(':');
|
||||
if(idx === -1) continue;
|
||||
|
||||
let user = line.slice(0, idx).trim();
|
||||
let pass = line.slice(idx + 1).slice(0, MAX_PASSWORD);
|
||||
if(!user || !pass) continue;
|
||||
if(!BASICAUTH_USER_RE.test(user)) continue;
|
||||
|
||||
out[user] = pass;
|
||||
if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sanitize an already-object credential map ({user: password}) the same way. */
|
||||
function sanitizeBasicAuthObject(obj){
|
||||
let out = {};
|
||||
if(!obj || typeof obj !== 'object') return out;
|
||||
|
||||
for(let user of Object.keys(obj)){
|
||||
if(!BASICAUTH_USER_RE.test(user)) continue;
|
||||
let pass = String(obj[user]).replace(/[\r\n]/g, '').slice(0, MAX_PASSWORD);
|
||||
if(!pass) continue;
|
||||
out[user] = pass;
|
||||
if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const MAX_ALLOW_ENTRIES = 500;
|
||||
|
||||
/**
|
||||
* Newline/comma/whitespace-separated text -> deduped array of trimmed entries
|
||||
* (usernames, emails, or group names for the SSO allow-lists). CR/LF stripped.
|
||||
*/
|
||||
function parseAllowList(input){
|
||||
let items = Array.isArray(input)
|
||||
? input
|
||||
: String(input === undefined || input === null ? '' : input).split(/[\s,]+/);
|
||||
|
||||
let seen = new Set();
|
||||
let out = [];
|
||||
for(let raw of items){
|
||||
let s = String(raw).replace(/[\r\n]/g, '').trim();
|
||||
if(!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
if(out.length >= MAX_ALLOW_ENTRIES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Realm goes into a WWW-Authenticate header; strip CR/LF and quotes, cap len. */
|
||||
function sanitizeRealm(value){
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/[\r\n"]/g, '')
|
||||
.trim()
|
||||
.slice(0, MAX_REALM);
|
||||
}
|
||||
|
||||
function toBool(v){
|
||||
return v === true || v === 'true';
|
||||
}
|
||||
@@ -151,6 +231,27 @@ function normalizeHostFeatures(body){
|
||||
if('ratelimit_enabled' in body) body.ratelimit_enabled = toBool(body.ratelimit_enabled);
|
||||
if('respcache_enabled' in body) body.respcache_enabled = toBool(body.respcache_enabled);
|
||||
if('hsts_enabled' in body) body.hsts_enabled = toBool(body.hsts_enabled);
|
||||
if('basicauth_enabled' in body) body.basicauth_enabled = toBool(body.basicauth_enabled);
|
||||
|
||||
if('basicauth_realm' in body) body.basicauth_realm = sanitizeRealm(body.basicauth_realm);
|
||||
|
||||
if('basicauth_users' in body){
|
||||
let users = typeof body.basicauth_users === 'string'
|
||||
? parseBasicAuthLines(body.basicauth_users)
|
||||
: sanitizeBasicAuthObject(body.basicauth_users);
|
||||
// Empty input means "leave the existing users untouched" (passwords are
|
||||
// never echoed to the form, so a blank textarea must not wipe them). Drop
|
||||
// the key so the partial update skips it. Disable basic auth to clear.
|
||||
if(Object.keys(users).length === 0){
|
||||
delete body.basicauth_users;
|
||||
}else{
|
||||
body.basicauth_users = users;
|
||||
}
|
||||
}
|
||||
|
||||
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_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
||||
|
||||
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);
|
||||
@@ -181,8 +282,10 @@ function normalizeHostFeatures(body){
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS,
|
||||
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS, MAX_BASICAUTH_USERS,
|
||||
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
|
||||
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
|
||||
parseBasicAuthLines, sanitizeBasicAuthObject, sanitizeRealm,
|
||||
parseAllowList,
|
||||
normalizeHostFeatures,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pure authorization check for per-host SSO (#57).
|
||||
*
|
||||
* After the OIDC dance, the callback decides whether the authenticated identity
|
||||
* may access the host, based on the host's allow-lists. Enforcing here (at
|
||||
* session creation) keeps the OpenResty side simple — the Lua gate only has to
|
||||
* confirm a valid session exists for the host.
|
||||
*
|
||||
* Semantics: empty allow-lists mean "any authenticated user". Otherwise the
|
||||
* identity is allowed if its username OR email is in sso_allow_users, or any of
|
||||
* its groups is in sso_allow_groups. All comparisons are case-insensitive.
|
||||
*/
|
||||
function identityAllowed(identity, allowUsers, allowGroups){
|
||||
identity = identity || {};
|
||||
allowUsers = Array.isArray(allowUsers) ? allowUsers : [];
|
||||
allowGroups = Array.isArray(allowGroups) ? allowGroups : [];
|
||||
|
||||
if(allowUsers.length === 0 && allowGroups.length === 0) return true;
|
||||
|
||||
let lc = s => String(s).trim().toLowerCase();
|
||||
|
||||
let ids = [identity.username, identity.email].filter(Boolean).map(lc);
|
||||
let users = allowUsers.map(lc);
|
||||
if(ids.some(id => users.includes(id))) return true;
|
||||
|
||||
let groups = (Array.isArray(identity.groups) ? identity.groups : []).map(lc);
|
||||
let allow = allowGroups.map(lc);
|
||||
if(groups.some(g => allow.includes(g))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {identityAllowed};
|
||||
@@ -36,13 +36,14 @@ function createAuthRequest(){
|
||||
return {state, codeVerifier, codeChallenge};
|
||||
}
|
||||
|
||||
// Build the SSO authorize URL the browser is redirected to.
|
||||
function buildAuthUrl(state, 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: o.redirectUri,
|
||||
redirect_uri: redirectUri || o.redirectUri,
|
||||
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
@@ -51,13 +52,14 @@ function buildAuthUrl(state, codeChallenge){
|
||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Exchange an authorization code for tokens at the token endpoint.
|
||||
async function exchangeCode(code, codeVerifier){
|
||||
// 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: o.redirectUri,
|
||||
redirect_uri: redirectUri || o.redirectUri,
|
||||
client_id: o.clientId,
|
||||
client_secret: o.clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local-account password policy.
|
||||
*
|
||||
* The previous rule was a single opaque regex that rejected strong passwords
|
||||
* (e.g. "@123Caplowercase") while accepting weak ones (e.g. "lowercase1") — see
|
||||
* issue #48. This replaces it with a clear, length-forward policy:
|
||||
*
|
||||
* - at least MIN characters, and
|
||||
* - either PASSPHRASE+ characters (a long passphrase passes on length alone),
|
||||
* or at least 3 of the 4 character classes (lowercase, uppercase, number,
|
||||
* symbol).
|
||||
*
|
||||
* Pure and dependency-free so it can run server-side (routes/user.js) and be
|
||||
* mirrored client-side (public/lib/js/val.js) and unit tested.
|
||||
*/
|
||||
|
||||
const MIN = 8;
|
||||
const PASSPHRASE = 12;
|
||||
|
||||
// Returns a human-readable error message if the password is unacceptable, else
|
||||
// null when it passes.
|
||||
function passwordError(value){
|
||||
if(typeof value !== 'string' || value.length < MIN){
|
||||
return `Password must be at least ${MIN} characters.`;
|
||||
}
|
||||
if(value.length >= PASSPHRASE) return null;
|
||||
|
||||
let classes = 0;
|
||||
if(/[a-z]/.test(value)) classes++;
|
||||
if(/[A-Z]/.test(value)) classes++;
|
||||
if(/[0-9]/.test(value)) classes++;
|
||||
if(/[^A-Za-z0-9]/.test(value)) classes++;
|
||||
|
||||
if(classes < 3){
|
||||
return 'Use at least 3 of: lowercase, uppercase, number, symbol — or make it 12+ characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {passwordError, MIN, PASSPHRASE};
|
||||
+420
-384
@@ -11,7 +11,11 @@
|
||||
}
|
||||
|
||||
div.form-group{
|
||||
margin-bottom: 1em;
|
||||
margin-bottom: 1.1em;
|
||||
}
|
||||
|
||||
.field-help{
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
/* my Div class for my search bar */
|
||||
@@ -22,25 +26,16 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* The input bar */
|
||||
input {
|
||||
font-size: 1rem;
|
||||
border-top-left-radius: 5px !important;
|
||||
border-bottom-left-radius: 5px !important;
|
||||
border-top-right-radius: 5px !important;
|
||||
border-bottom-right-radius: 5px !important;
|
||||
}
|
||||
|
||||
/* Greys out a challenge/matching option that isn't available for the host. */
|
||||
.challengeType-container {
|
||||
pointer-events: none; /* Prevents clicking */
|
||||
opacity: 0.5; /* Greys it out */
|
||||
filter: grayscale(1); /* Removes blue/color tint */
|
||||
cursor: not-allowed;
|
||||
}
|
||||
pointer-events: none; /* Prevents clicking */
|
||||
opacity: 0.5; /* Greys it out */
|
||||
filter: grayscale(1); /* Removes blue/color tint */
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
var $editHostForm;
|
||||
|
||||
// Parse the JSON object for a host to something the UI wants
|
||||
function hostParseRow(host) {
|
||||
@@ -82,53 +77,113 @@
|
||||
});
|
||||
}
|
||||
|
||||
function hostEditCancle(){
|
||||
$('tr.jq-repeat-hosts').each(function(idx, el){
|
||||
$(el).removeClass('table-warning');
|
||||
});
|
||||
$.scope.editHost.remove(0);
|
||||
}
|
||||
|
||||
// Mirror of utils/host_features.js stringify* helpers for populating the edit
|
||||
// form's textareas. The server re-parses the posted text authoritatively.
|
||||
function hostFeatureHeadersToText(obj){
|
||||
if(!obj || typeof obj != 'object') return '';
|
||||
return Object.keys(obj).map(function(name){ return name + ': ' + obj[name]; }).join('\n');
|
||||
}
|
||||
function hostFeatureCidrsToText(arr){
|
||||
function hostFeatureListToText(arr){
|
||||
return Array.isArray(arr) ? arr.join('\n') : '';
|
||||
}
|
||||
|
||||
function hostEditOpen(btn, host){
|
||||
hostEditCancle();
|
||||
console.log('host:', host)
|
||||
host = $.scope.hosts.getByKey(host);
|
||||
host.__jq_$el.addClass('table-warning');
|
||||
$editHostForm.find('[name=is_wildcard').attr('disabled', true);
|
||||
$.scope.editHost.update({...host, form: $editHostForm.html()});
|
||||
// ----- Add / Edit modal --------------------------------------------------
|
||||
|
||||
if(host.is_wildcard){
|
||||
$('.hostEditPanel [name="host"]').attr('disabled', true);
|
||||
// Allow toggling the wildcard matching mode when editing a wildcard host.
|
||||
$('.hostEditPanel #wildcard_matchAny-container').removeClass('challengeType-container');
|
||||
}
|
||||
function hostModal(){
|
||||
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
|
||||
}
|
||||
function hostModalClose(){ hostModal().hide(); }
|
||||
|
||||
$.each(host, function( key, value ) { if(typeof value == "boolean"){
|
||||
$(".hostEditPanel #"+ key +"-"+ value).prop('checked', true)
|
||||
function hostShowTab(id){
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
|
||||
}
|
||||
|
||||
// Append a picked/typed value to one of the SSO allow-list textareas (deduped).
|
||||
function allowListAdd(input, name){
|
||||
let val = (input.value || '').trim();
|
||||
if(!val) return;
|
||||
let $ta = $('#hostForm textarea[name="' + name + '"]');
|
||||
let lines = ($ta.val() || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
if(lines.indexOf(val) === -1) lines.push(val);
|
||||
$ta.val(lines.join('\n'));
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Fill the user/group datalists that back the allow-list autocomplete.
|
||||
function hostLoadAuthSuggestions(){
|
||||
app.api.get('host/auth-suggestions', function(error, data){
|
||||
if(error || !data) return;
|
||||
let $u = $('#hostSsoUsers').empty();
|
||||
for(let u of (data.users || [])) $u.append($('<option>').val(u));
|
||||
let $g = $('#hostSsoGroups').empty();
|
||||
for(let g of (data.groups || [])) $g.append($('<option>').val(g));
|
||||
});
|
||||
}
|
||||
|
||||
// Return the form to a clean "add" state.
|
||||
function hostFormReset(){
|
||||
let form = document.getElementById('hostForm');
|
||||
form.reset();
|
||||
let $f = $(form);
|
||||
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
|
||||
$f.find('[name=host]').prop('disabled', false);
|
||||
if($f.validateClear) $f.validateClear();
|
||||
|
||||
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
|
||||
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
|
||||
.addClass('challengeType-container');
|
||||
$('#challengeType-child-relatedHost').text('');
|
||||
$('.basicauth-current').text('none');
|
||||
hostShowTab('hostTab-general-btn');
|
||||
}
|
||||
|
||||
function hostAddOpen(){
|
||||
hostFormReset();
|
||||
$('#hostModalTitle').text('Add host');
|
||||
$('#hostModalSubmitText').text('Add host');
|
||||
hostModal().show();
|
||||
}
|
||||
|
||||
function hostEditOpen(host){
|
||||
hostFormReset();
|
||||
let h = $.scope.hosts.getByKey(host);
|
||||
let $f = $('#hostForm');
|
||||
|
||||
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
|
||||
$('#hostModalTitle').text('Edit ' + host);
|
||||
$('#hostModalSubmitText').text('Save changes');
|
||||
|
||||
// Scalar fields: booleans drive the matching radio, everything else the
|
||||
// input with that name. Object/array fields are handled as text below.
|
||||
$.each(h, function(key, value){
|
||||
if(typeof value === 'boolean'){
|
||||
$f.find('#' + key + '-' + value).prop('checked', true);
|
||||
}else{
|
||||
$(".hostEditPanel input[name='" + key + "']").val(value);
|
||||
$f.find("input[name='" + key + "']").val(value);
|
||||
}
|
||||
});
|
||||
|
||||
// Object/array proxy-control fields render into textareas as text. Server
|
||||
// (utils/host_features.js) parses the same text/shape back on save.
|
||||
$(".hostEditPanel textarea[name='req_headers']").val(hostFeatureHeadersToText(host.req_headers));
|
||||
$(".hostEditPanel textarea[name='resp_headers']").val(hostFeatureHeadersToText(host.resp_headers));
|
||||
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
|
||||
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
|
||||
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_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_deny']").val(hostFeatureListToText(h.ip_deny));
|
||||
$f.find("textarea[name='sso_allow_users']").val(hostFeatureListToText(h.sso_allow_users));
|
||||
$f.find("textarea[name='sso_allow_groups']").val(hostFeatureListToText(h.sso_allow_groups));
|
||||
|
||||
$('.hostEditPanel').scrollTo();
|
||||
};
|
||||
// Never echo basic-auth passwords; show current usernames as a hint.
|
||||
$f.find("textarea[name='basicauth_users']").val('');
|
||||
$('.basicauth-current').text(Object.keys(h.basicauth_users || {}).join(', ') || 'none');
|
||||
|
||||
// The host name is the key; it can't change on edit. Wildcard hosts can
|
||||
// still toggle their matching mode.
|
||||
$f.find('[name=host]').prop('disabled', true);
|
||||
if(h.is_wildcard){
|
||||
$('#wildcard_matchAny-container').removeClass('challengeType-container');
|
||||
}
|
||||
|
||||
hostModal().show();
|
||||
}
|
||||
|
||||
function hostDownloadCert(host, type){
|
||||
app.host.getCert({host}, function(error, data){
|
||||
@@ -138,13 +193,7 @@
|
||||
}
|
||||
|
||||
function hostSearchInput(){
|
||||
//search bar html event logic stolen from here
|
||||
// https://github.com/WebDevSimplified/js-search-bar/blob/main/script.js
|
||||
let inputValue = $(event.target).val().toLowerCase();
|
||||
|
||||
// on each input detected we need to get all host list that was called by the hostPopulate function which is store in
|
||||
// $.scope.hosts and then we need to loop through each host and check if the host name is equal to the input value
|
||||
// if it is we will display the host if not we will hide it
|
||||
for(let hostObj of $.scope.hosts){
|
||||
if (hostObj.host.toLowerCase().includes(inputValue)) {
|
||||
hostObj.__jq_$el.show();
|
||||
@@ -156,9 +205,7 @@
|
||||
|
||||
async function verifyWildcardRequirements(host){
|
||||
try{
|
||||
|
||||
let res = await app.api.get(`dns/domain/${host}`);
|
||||
|
||||
return res.results.length === 1;
|
||||
}catch(error){
|
||||
return false;
|
||||
@@ -180,7 +227,6 @@
|
||||
async function hostMatchWildcard(host){
|
||||
try{
|
||||
let res = await app.api.get(`host/lookup/${host}`);
|
||||
|
||||
if(res.results && res.results.is_wildcard){
|
||||
return res.results;
|
||||
}
|
||||
@@ -190,17 +236,13 @@
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
// Clone the new host form to be used on edit requests.
|
||||
$editHostForm = $('#addHost').clone();
|
||||
$editHostForm.find('hr.buttonBreak').nextAll().remove();
|
||||
// $editHostForm.find('.autoSll').addClass('bg-secondary');
|
||||
|
||||
// Populate the host UI table
|
||||
// Populate the host UI table
|
||||
hostPopulate();
|
||||
hostLoadAuthSuggestions();
|
||||
|
||||
// Determine what lets encrypt challenge type the given host name can use
|
||||
$hostField = $('[name=host');
|
||||
$hostField.keyup(async function(){
|
||||
// Determine what Let's Encrypt challenge type the given host name can use.
|
||||
let $hostField = $('#hostForm [name=host]');
|
||||
$hostField.on('keyup', async function(){
|
||||
// Reset the allowed types on start
|
||||
$('#challengeType-child-container').addClass('challengeType-container');
|
||||
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
|
||||
@@ -208,36 +250,31 @@
|
||||
|
||||
let host = $hostField.val();
|
||||
|
||||
// If its a wild card, we must check if the domain has a registered
|
||||
// provider.
|
||||
// If it's a wildcard, we must check the domain has a registered provider.
|
||||
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
|
||||
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
|
||||
// Wildcard matching mode only applies to wildcard hosts.
|
||||
$('#wildcard_matchAny-container').removeClass('challengeType-container');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a wildcard cert is available for the given host.
|
||||
let wildcardParent = await hostMatchWildcard($hostField.val());
|
||||
// Check if a wildcard cert is available for the given host. When it is,
|
||||
// make "Parent Wildcard" the default choice (it reuses an existing cert).
|
||||
let wildcardParent = await hostMatchWildcard(host);
|
||||
if(wildcardParent){
|
||||
$('#challengeType-child-container').removeClass('challengeType-container');
|
||||
$('#challengeType-child-relatedHost').text(wildcardParent.host);
|
||||
|
||||
$('#challengeType-wildcardChild').prop('checked', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we hit here, make sure the form is reverted to a valid state
|
||||
// Revert the form to a valid state.
|
||||
$('#challengeType-child-relatedHost').text('');
|
||||
$('#challengeType-HTTP-01').prop('checked', true);
|
||||
});
|
||||
|
||||
//
|
||||
$.scope.hosts.take = function($el, item, list){
|
||||
$el.addClass('table-danger');
|
||||
$el.fadeOut(500, function(){
|
||||
$el.remove()
|
||||
});
|
||||
$el.fadeOut(500, function(){ $el.remove() });
|
||||
};
|
||||
|
||||
$.scope.hosts.putUpdate = function($el, $render, item, list){
|
||||
@@ -245,21 +282,8 @@
|
||||
$el.replaceWith($render);
|
||||
};
|
||||
|
||||
$.scope.editHost.put = function($el, item, list){
|
||||
$el.slideDown();
|
||||
};
|
||||
|
||||
$.scope.editHost.take = function($el, item, list){
|
||||
$el.slideUp();
|
||||
};
|
||||
|
||||
// app.subscribe(/^model:Host/, function(data, topic){
|
||||
// console.log(topic, data);
|
||||
// });
|
||||
|
||||
app.subscribe(/^model:Host:create/, function(data, topic){
|
||||
let [a,b, action, host] = topic.split(':');
|
||||
|
||||
if($.scope.hosts.indexOf(host) >= 0){
|
||||
$.scope.hosts.update(host, hostParseRow(data));
|
||||
}else{
|
||||
@@ -269,7 +293,6 @@
|
||||
|
||||
app.subscribe(/^model:Host:update/, function(data, topic){
|
||||
let [a,b, action, host] = topic.split(':');
|
||||
|
||||
if($.scope.hosts.indexOf(host) >= 0){
|
||||
$.scope.hosts.update(host, hostParseRow(data));
|
||||
}else{
|
||||
@@ -279,290 +302,26 @@
|
||||
|
||||
app.subscribe(/^model:Host:remove/, function(data, topic){
|
||||
let [a,b, action, host] = topic.split(':');
|
||||
|
||||
$.scope.hosts.remove(host);
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="row" style="display:none">
|
||||
<div class="col col-md-12 col-lg-4 col-xl-3 col-xxl-2">
|
||||
<!--
|
||||
left column
|
||||
-->
|
||||
<div jq-repeat="editHost" class="card shadow-lg border-warning hostEditPanel mb-3" style="display:none">
|
||||
<!--
|
||||
Edit host card
|
||||
-->
|
||||
<div class="card-header text-center bg-warning">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</span>
|
||||
<span class="card-title">
|
||||
Edit {{ host }}
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
<i class="fa-solid fa-circle-xmark" onclick="hostEditCancle()"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form class="addHost" method="PUT" action="host/{{ host }}" onsubmit="formAJAX(this)" evalAJAX="hostEditCancle()">
|
||||
{{{ form }}}
|
||||
<input type="hidden" name="edit_host" />
|
||||
<button type="submit" data-type="edit" class="btn btn-warning">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
Update
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="reset" onclick="hostEditCancle()">
|
||||
<i class="fa-solid fa-ban"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-lg mb-3 hostAddPanel">
|
||||
<!--
|
||||
Add new host card
|
||||
-->
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</span>
|
||||
<span class="card-title">
|
||||
New Entry
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
<div class="card-body d-none d-md-block">
|
||||
<form class="addHost" id="addHost" method="POST" action="host" onsubmit="formAJAX(this)">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Incoming SSL
|
||||
</label>
|
||||
<br />
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
|
||||
Force incoming connections over HTTPS <b>Recommended</b>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="forcessl" id="forcessl-false" value="false">
|
||||
Allow use of both HTTP and HTTPS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="host" class="form-label">
|
||||
Incoming Host Name
|
||||
</label>
|
||||
<div>
|
||||
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net, *.cloud-ops.net, **.cloud-ops.net, or **" validate="host" >
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group autoSll">
|
||||
<label class="form-label">
|
||||
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">Validation Type</a>:
|
||||
</label>
|
||||
<div class="radio" id="challengeType-HTTP-01-container">
|
||||
<label>
|
||||
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
|
||||
HTTP-01
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container">
|
||||
<label>
|
||||
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
|
||||
DNS-01 Wildcard
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio challengeType-container" id="challengeType-child-container">
|
||||
<label>
|
||||
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
|
||||
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
|
||||
<label class="form-label">
|
||||
Wildcard Matching
|
||||
</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
|
||||
Match only subdomains defined here <b>Recommended</b>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
||||
Match any subdomain and proxy to this host
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-group">
|
||||
<label for="ip" class="form-label">
|
||||
Target IP or Host Name
|
||||
</label>
|
||||
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="targetPort" class="form-label">
|
||||
Target TCP Port
|
||||
</label>
|
||||
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Target SSL
|
||||
</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
||||
Proxy to HTTPS
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
|
||||
Proxy to HTTP <b>Recommended</b>
|
||||
</label>
|
||||
</div>
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6 class="text-muted">Proxy controls</h6>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rate limiting</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
|
||||
Off <b>Recommended</b>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
|
||||
Limit requests per client IP
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col form-group">
|
||||
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
|
||||
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
|
||||
</div>
|
||||
<div class="col form-group">
|
||||
<label for="ratelimit_burst" class="form-label">Burst</label>
|
||||
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Response caching</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
|
||||
Off <b>Recommended</b>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
|
||||
Cache cacheable responses
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">HSTS</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
|
||||
Send Strict-Transport-Security
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
|
||||
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
|
||||
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="req_headers" class="form-label">Upstream request headers</label>
|
||||
<textarea name="req_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="resp_headers" class="form-label">Response headers</label>
|
||||
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
|
||||
</div>
|
||||
|
||||
<hr class="buttonBreak" />
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-12 col-lg-8 col-xl-9 col-xxl-10">
|
||||
<!--
|
||||
Right column
|
||||
-->
|
||||
<div class="col-12">
|
||||
<div class="card shadow-lg hostListPanel">
|
||||
<!--
|
||||
List current hosts
|
||||
-->
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-network-wired"></i>
|
||||
</span>
|
||||
<span class="card-title">
|
||||
Proxy List
|
||||
</span>
|
||||
<span class="float-end">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
|
||||
<span class="card-title fw-bold">Proxy List</span>
|
||||
<span class="ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
|
||||
<i class="fa-solid fa-broom"></i>
|
||||
Clear Cache
|
||||
Clear cache
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="hostAddOpen()">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add host
|
||||
</button>
|
||||
<i class="fa-solid fa-circle-minus"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -575,7 +334,6 @@
|
||||
</div>
|
||||
<div class='table-responsive'>
|
||||
<table class="m-0 card-body table table-striped overflow-x-scroll">
|
||||
|
||||
<thead>
|
||||
<th>
|
||||
<input type="checkbox"
|
||||
@@ -590,21 +348,11 @@
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
SSL Expire
|
||||
</th>
|
||||
<th>
|
||||
Host Name
|
||||
</th>
|
||||
<th>
|
||||
target
|
||||
</th>
|
||||
<th class="hidden-xs">
|
||||
Updated
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
</th>
|
||||
<th>SSL Expire</th>
|
||||
<th>Host Name</th>
|
||||
<th>target</th>
|
||||
<th class="hidden-xs">Updated</th>
|
||||
<th>Actions</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
@@ -639,7 +387,6 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-brands fa-expeditedssl"></i>
|
||||
@@ -670,7 +417,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="hostEditOpen(this, '{{ host }}');" class="btn btn-sm btn-warning">
|
||||
<button type="button" onclick="hostEditOpen('{{ host }}');" class="btn btn-sm btn-warning">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
Edit
|
||||
</button>
|
||||
@@ -687,4 +434,293 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit host modal ------------------------------------------------- -->
|
||||
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content card border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage m-0" style="display:none"></div>
|
||||
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
|
||||
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS & Wildcard</button></li>
|
||||
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
|
||||
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
|
||||
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
|
||||
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
|
||||
</ul>
|
||||
|
||||
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
|
||||
<div class="tab-content pt-3">
|
||||
|
||||
<!-- General -->
|
||||
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
|
||||
<div class="form-group">
|
||||
<label for="host" class="form-label">Incoming host name</label>
|
||||
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
|
||||
<b class="invalid-feedback"></b>
|
||||
<small class="field-help text-muted d-block">
|
||||
The public hostname clients request. Use <code>*.example.com</code>
|
||||
for one subdomain level, <code>**.example.com</code> for any depth,
|
||||
or <code>**</code> as a catch-all.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Incoming SSL</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
|
||||
Force HTTPS <b>(recommended)</b>
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="forcessl" id="forcessl-false" value="false">
|
||||
Allow both HTTP and HTTPS
|
||||
</label></div>
|
||||
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ip" class="form-label">Target IP or host name</label>
|
||||
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
|
||||
<b class="invalid-feedback"></b>
|
||||
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only — no protocol, port, or path.</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col form-group">
|
||||
<label for="targetPort" class="form-label">Target TCP port</label>
|
||||
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
<div class="col form-group">
|
||||
<label class="form-label">Target SSL</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
|
||||
Proxy to HTTP <b>(recommended)</b>
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="targetssl" id="targetssl-true" value="true">
|
||||
Proxy to HTTPS
|
||||
</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS & Wildcard -->
|
||||
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
|
||||
<div class="form-group autoSll">
|
||||
<label class="form-label">
|
||||
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
|
||||
</label>
|
||||
<div class="radio" id="challengeType-HTTP-01-container"><label>
|
||||
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
|
||||
HTTP-01
|
||||
</label></div>
|
||||
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
|
||||
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
|
||||
DNS-01 Wildcard
|
||||
</label></div>
|
||||
<div class="radio challengeType-container" id="challengeType-child-container"><label>
|
||||
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
|
||||
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
|
||||
</label></div>
|
||||
<small class="field-help text-muted d-block">
|
||||
Options light up based on the host name: wildcard certs need a DNS
|
||||
provider for the domain; child hosts reuse a parent wildcard.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
|
||||
<label class="form-label">Wildcard matching</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
|
||||
Match only subdomains defined here <b>(recommended)</b>
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
|
||||
Match any subdomain and proxy to this host
|
||||
</label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic -->
|
||||
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rate limiting</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
|
||||
Off <b>(recommended)</b>
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
|
||||
Limit requests per client IP
|
||||
</label></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col form-group">
|
||||
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
|
||||
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
|
||||
</div>
|
||||
<div class="col form-group">
|
||||
<label for="ratelimit_burst" class="form-label">Burst</label>
|
||||
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
|
||||
</div>
|
||||
</div>
|
||||
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Response caching</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
|
||||
Off <b>(recommended)</b>
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
|
||||
Cache cacheable responses
|
||||
</label></div>
|
||||
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">HSTS</label>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
|
||||
Off
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
|
||||
Send Strict-Transport-Security
|
||||
</label></div>
|
||||
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Headers -->
|
||||
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
|
||||
<div class="form-group">
|
||||
<label for="req_headers" class="form-label">Upstream request headers</label>
|
||||
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
||||
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="resp_headers" class="form-label">Response headers</label>
|
||||
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
|
||||
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access -->
|
||||
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
|
||||
<h6 class="text-muted">IP access</h6>
|
||||
<div class="form-group">
|
||||
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
|
||||
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
|
||||
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
|
||||
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
|
||||
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
|
||||
<p class="field-help text-muted">
|
||||
Basic auth and SSO are OR'd — if either is enabled, a request
|
||||
is allowed when it passes <b>either</b> one. Leave both off for a
|
||||
public host.
|
||||
</p>
|
||||
|
||||
<h6 class="text-muted">Basic authentication</h6>
|
||||
<div class="form-group">
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-false" value="false" checked>
|
||||
Off
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-true" value="true">
|
||||
Require username / password
|
||||
</label></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="basicauth_realm" class="form-label">Realm</label>
|
||||
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="basicauth_users" class="form-label">Users</label>
|
||||
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
Current: <span class="basicauth-current">none</span>.
|
||||
Passwords are stored hashed and never shown here. Leave blank to keep
|
||||
the current users; entering any lines replaces the whole list.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6 class="text-muted">Single sign-on (SSO)</h6>
|
||||
<div class="form-group">
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="sso_enabled" id="sso_enabled-false" value="false" checked>
|
||||
Off
|
||||
</label></div>
|
||||
<div class="radio"><label>
|
||||
<input type="radio" name="sso_enabled" id="sso_enabled-true" value="true">
|
||||
Require login via the configured OIDC provider
|
||||
</label></div>
|
||||
<small class="field-help text-muted d-block">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sso_allow_users" class="form-label">Allowed users</label>
|
||||
<div class="input-group mb-1">
|
||||
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
|
||||
<i class="fa-solid fa-plus"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
|
||||
<div class="input-group mb-1">
|
||||
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
|
||||
<i class="fa-solid fa-plus"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<datalist id="hostSsoUsers"></datalist>
|
||||
<datalist id="hostSsoGroups"></datalist>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fa-solid fa-ban"></i> Cancel
|
||||
</button>
|
||||
<button type="submit" form="hostForm" class="btn btn-success">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
<span id="hostModalSubmitText">Add host</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
|
||||
@@ -82,14 +82,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" placeholder="Atleast 5 char. long" validate="password:5"/>
|
||||
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Again</label>
|
||||
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
|
||||
</div>
|
||||
<hr />
|
||||
<button type="button" class="btn btn-info">
|
||||
<button type="submit" class="btn btn-info">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
-- req_headers (JSON object) -- added to the upstream request
|
||||
-- resp_headers (JSON object) -- added to the client response
|
||||
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
||||
-- basicauth_enabled / basicauth_realm
|
||||
-- basicauth_users (JSON object {username: base64(sha1(password))})
|
||||
-- sso_enabled -- gate on a __proxy_sso session (established by /__proxy_auth)
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
|
||||
@@ -80,6 +83,115 @@ local function apply_ratelimit(res, host, ip)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- Per-host authentication (basic auth OR SSO) -----------------------
|
||||
--
|
||||
-- Both are optional. If either is enabled, a request must satisfy at least one.
|
||||
-- A "Basic" Authorization header routes to the basic-auth path (401 challenge on
|
||||
-- failure); otherwise a browser is redirected into the SSO login. Basic-auth
|
||||
-- creds are stored as {user: base64(sha1(pw))} (htpasswd "{SHA}"; hashed in
|
||||
-- nodejs). SSO relies on a Redis-backed session established by /__proxy_auth
|
||||
-- (nodejs); the allow-list was enforced there, so here we only confirm a valid
|
||||
-- session for this host.
|
||||
|
||||
-- True when the request carries valid basic-auth credentials for this host.
|
||||
local function basic_auth_ok(res)
|
||||
local users = decode_table(res["basicauth_users"])
|
||||
if not users then return false end
|
||||
|
||||
local header = ngx.var.http_authorization
|
||||
if not header then return false end
|
||||
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
|
||||
if not b64 then return false end
|
||||
|
||||
local decoded = ngx.decode_base64(b64)
|
||||
if not decoded then return false end
|
||||
local user, pass = decoded:match("^([^:]*):(.*)$")
|
||||
if not user or user == "" then return false end
|
||||
|
||||
local stored = users[user]
|
||||
if not stored then return false end
|
||||
|
||||
local sha1 = require "resty.sha1"
|
||||
local hasher = sha1:new()
|
||||
if not hasher then return false end
|
||||
hasher:update(pass or "")
|
||||
return ngx.encode_base64(hasher:final()) == stored
|
||||
end
|
||||
|
||||
local function basic_challenge(res)
|
||||
local realm = res["basicauth_realm"]
|
||||
if not realm or realm == "" then realm = "Restricted" end
|
||||
realm = realm:gsub('[\r\n"]', "") -- defense in depth for the header
|
||||
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
|
||||
return ngx.exit(401)
|
||||
end
|
||||
|
||||
-- Read an SSO session hash from Redis. Returns the table or nil. The sid comes
|
||||
-- from a cookie (attacker-controlled), so it is character-restricted before use.
|
||||
local function sso_get_session(sid)
|
||||
if not sid or not sid:match("^[%w_%-]+$") then return nil end
|
||||
|
||||
local redis = require "resty.redis"
|
||||
local red = redis:new()
|
||||
red:set_timeout(1000)
|
||||
local ok, err = red:connect("127.0.0.1", 6379)
|
||||
if not ok then
|
||||
ngx.log(ngx.ERR, "hostfeatures: sso redis connect ", err)
|
||||
return nil
|
||||
end
|
||||
|
||||
local arr = red:hgetall("proxy_SsoSession_" .. sid)
|
||||
local sess = arr and red:array_to_hash(arr) or nil
|
||||
red:set_keepalive(10000, 100)
|
||||
|
||||
if sess and next(sess) ~= nil then return sess end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- True when a valid SSO session cookie exists for THIS host. (The session
|
||||
-- auto-expires via Redis TTL; a missing key reads as no session.)
|
||||
local function sso_session_ok()
|
||||
local sid = ngx.var.cookie___proxy_sso
|
||||
if not sid or sid == "" then return false end
|
||||
local sess = sso_get_session(sid)
|
||||
if not sess or not sess["sub"] then return false end
|
||||
if sess["host"] ~= ngx.var.host then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Send a browser into the SSO login, preserving where it was headed. Non-idempotent
|
||||
-- methods get a 401 instead of a redirect they couldn't safely replay.
|
||||
local function sso_redirect()
|
||||
local m = ngx.req.get_method()
|
||||
if m ~= "GET" and m ~= "HEAD" then
|
||||
return ngx.exit(401)
|
||||
end
|
||||
local rd = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri
|
||||
return ngx.redirect("/__proxy_auth/start?rd=" .. ngx.escape_uri(rd), 302)
|
||||
end
|
||||
|
||||
-- Enforce whichever auth methods are enabled; allow if EITHER passes.
|
||||
local function apply_auth(res)
|
||||
local basic_on = res["basicauth_enabled"] == "true"
|
||||
local sso_on = res["sso_enabled"] == "true"
|
||||
if not basic_on and not sso_on then return end
|
||||
|
||||
if basic_on and basic_auth_ok(res) then return end
|
||||
if sso_on and sso_session_ok() then return end
|
||||
|
||||
-- Not authenticated. Pick the right challenge for the client.
|
||||
local auth = ngx.var.http_authorization
|
||||
local has_basic_header = auth and auth:match("^%s*[Bb]asic%s") ~= nil
|
||||
|
||||
if sso_on and not has_basic_header then
|
||||
return sso_redirect()
|
||||
end
|
||||
if basic_on then
|
||||
return basic_challenge(res)
|
||||
end
|
||||
return sso_redirect()
|
||||
end
|
||||
|
||||
-- Extra request headers sent to the upstream.
|
||||
local function apply_req_headers(res)
|
||||
local headers = decode_table(res["req_headers"])
|
||||
@@ -97,6 +209,7 @@ function M.access(ngx_, res)
|
||||
|
||||
apply_ip_access(res, ip)
|
||||
apply_ratelimit(res, host, ip)
|
||||
apply_auth(res)
|
||||
apply_req_headers(res)
|
||||
|
||||
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
||||
|
||||
@@ -30,6 +30,13 @@ http {
|
||||
|
||||
resolver 8.8.4.4 8.8.8.8;
|
||||
|
||||
# Backend for per-host SSO endpoints (/__proxy_auth, see proxy.conf). Point
|
||||
# this at the nodejs app that serves the admin UI. Default assumes it is
|
||||
# colocated on this box; change the address for a split deployment.
|
||||
upstream proxy_auth_backend {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
init_by_lua_block {
|
||||
|
||||
auto_ssl = (require "resty.auto-ssl").new()
|
||||
|
||||
@@ -12,6 +12,19 @@ server {
|
||||
real_ip_header X-Real-IP;
|
||||
real_ip_recursive on;
|
||||
|
||||
# Per-host SSO endpoints (#57), served on EVERY proxied host by the nodejs app.
|
||||
# This location deliberately sits OUTSIDE the auth gate in `location /` (so the
|
||||
# login flow itself is never gated) and forwards to the app, which runs the
|
||||
# OIDC flow and sets the __proxy_sso session cookie for this host.
|
||||
location /__proxy_auth/ {
|
||||
proxy_pass http://proxy_auth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
set $target '';
|
||||
|
||||
Reference in New Issue
Block a user