diff --git a/nodejs/app.js b/nodejs/app.js index b8b644f..1330fdd 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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 diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index bb74a45..027cbe5 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -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', + }, }; diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index e5d09dd..f6e5640 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -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); } diff --git a/nodejs/models/host.js b/nodejs/models/host.js index 4c33072..e6d7fb7 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -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',}, diff --git a/nodejs/models/index.js b/nodejs/models/index.js index e71cc09..6477866 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -14,3 +14,4 @@ require('./user'); require('./local_group'); require('./permission'); require('./oidc_state'); +require('./sso_session'); diff --git a/nodejs/models/sso_session.js b/nodejs/models/sso_session.js new file mode 100644 index 0000000..9bb5a47 --- /dev/null +++ b/nodejs/models/sso_session.js @@ -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_` + * 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}; diff --git a/nodejs/models/token.js b/nodejs/models/token.js index 8af591d..f497e36 100644 --- a/nodejs/models/token.js +++ b/nodejs/models/token.js @@ -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}; diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index f852754..d7b99c3 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -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); diff --git a/nodejs/models/user_pam.js b/nodejs/models/user_pam.js index ebc83bc..3c1589f 100644 --- a/nodejs/models/user_pam.js +++ b/nodejs/models/user_pam.js @@ -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); diff --git a/nodejs/package.json b/nodejs/package.json index 3e89f4b..379923c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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" diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js index 90c8742..0029957 100755 --- a/nodejs/public/lib/js/val.js +++ b/nodejs/public/lib/js/val.js @@ -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)"; } } } diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index 75fcb6c..ccfe75d 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -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); diff --git a/nodejs/routes/host_auth.js b/nodejs/routes/host_auth.js new file mode 100644 index 0000000..751f591 --- /dev/null +++ b/nodejs/routes/host_auth.js @@ -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:///__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 `Sign in` + + `` + + `` + + `

${String(message).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}

`; +} + +// 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; diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 4be45a0..434d41c 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -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; diff --git a/nodejs/services/host_scheduler.js b/nodejs/services/host_scheduler.js index 465f658..399babf 100644 --- a/nodejs/services/host_scheduler.js +++ b/nodejs/services/host_scheduler.js @@ -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(); diff --git a/nodejs/test/unit/basicauth.test.js b/nodejs/test/unit/basicauth.test.js new file mode 100644 index 0000000..47bc600 --- /dev/null +++ b/nodejs/test/unit/basicauth.test.js @@ -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']); + }); +}); diff --git a/nodejs/test/unit/host_sso.test.js b/nodejs/test/unit/host_sso.test.js new file mode 100644 index 0000000..c7c88fd --- /dev/null +++ b/nodejs/test/unit/host_sso.test.js @@ -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); + }); +}); diff --git a/nodejs/test/unit/password_policy.test.js b/nodejs/test/unit/password_policy.test.js new file mode 100644 index 0000000..c0d3ca3 --- /dev/null +++ b/nodejs/test/unit/password_policy.test.js @@ -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 + }); +}); diff --git a/nodejs/utils/basicauth.js b/nodejs/utils/basicauth.js new file mode 100644 index 0000000..5454b73 --- /dev/null +++ b/nodejs/utils/basicauth.js @@ -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}; diff --git a/nodejs/utils/host_features.js b/nodejs/utils/host_features.js index 3e107aa..4e5cc83 100644 --- a/nodejs/utils/host_features.js +++ b/nodejs/utils/host_features.js @@ -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, }; diff --git a/nodejs/utils/host_sso.js b/nodejs/utils/host_sso.js new file mode 100644 index 0000000..1f7e6e5 --- /dev/null +++ b/nodejs/utils/host_sso.js @@ -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}; diff --git a/nodejs/utils/oidc.js b/nodejs/utils/oidc.js index c7c8e3e..9c0b034 100644 --- a/nodejs/utils/oidc.js +++ b/nodejs/utils/oidc.js @@ -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, diff --git a/nodejs/utils/password_policy.js b/nodejs/utils/password_policy.js new file mode 100644 index 0000000..9a3bf8c --- /dev/null +++ b/nodejs/utils/password_policy.js @@ -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}; diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 8ef3c2e..918bbfa 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -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; + } +