Merge branch 'master' into ops/install-idempotent-symlinks
This commit is contained in:
@@ -7,6 +7,11 @@ const express = require('express');
|
||||
// Set up the express app.
|
||||
const app = express();
|
||||
|
||||
// The app always runs behind the OpenResty reverse proxy (a single hop) which
|
||||
// sets X-Real-IP / X-Forwarded-For. Trust that one proxy so req.ip reflects the
|
||||
// real client — needed for correct per-client rate limiting on /api/auth.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Hold list of functions to run when the server is ready
|
||||
app.onListen = [];
|
||||
|
||||
|
||||
@@ -22,6 +22,40 @@ module.exports = {
|
||||
// self-correct. 0 disables expiry (entries live until bustCache/clearCache).
|
||||
cacheTTL: 3600,
|
||||
|
||||
// OpenID Connect login against the SSO. Endpoints come from the SSO's
|
||||
// /.well-known/openid-configuration. clientSecret lives in secrets.js.
|
||||
// redirectUri MUST be registered on the SSO client and match exactly.
|
||||
oidc: {
|
||||
enabled: true,
|
||||
issuer: 'https://sso.theta42.com',
|
||||
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://sso.theta42.com/oauth/token',
|
||||
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo',
|
||||
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout',
|
||||
clientId: '__SET_ME__',
|
||||
// Where the SSO sends the user back. Must be an absolute URL reachable
|
||||
// by the browser and registered on the SSO client.
|
||||
redirectUri: 'http://localhost:3000/api/auth/oidc/callback',
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
// Claim on the userinfo response that carries group membership.
|
||||
groupsClaim: 'groups',
|
||||
// Claim used as the local username.
|
||||
usernameClaim: 'preferred_username',
|
||||
},
|
||||
|
||||
// Authorization: how groups map to roles, and which groups are global admin.
|
||||
// Per-user overrides are Grant records managed in the app.
|
||||
auth: {
|
||||
// Members of these SSO/LDAP groups are always global admins.
|
||||
adminGroups: [],
|
||||
// Optional default role mapping for groups, e.g.
|
||||
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
|
||||
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
|
||||
groupRoleMap: {},
|
||||
// Local users always treated as global admin (anti-lockout bootstrap).
|
||||
adminUsers: ['proxyadmin2'],
|
||||
},
|
||||
|
||||
service:{
|
||||
hostScheduler:{
|
||||
enabled: true,
|
||||
|
||||
@@ -6,6 +6,8 @@ async function auth(req, res, next){
|
||||
try{
|
||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||
req.user = req.token.user;
|
||||
// Session group memberships captured at login, used by authz middleware.
|
||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||
return next();
|
||||
}catch(error){
|
||||
next(error);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const {Grant} = require('../models/grant');
|
||||
const tldExtract = require('tld-extract').parse_host;
|
||||
|
||||
/**
|
||||
* Authorization middleware.
|
||||
*
|
||||
* Builds on middleware/auth.js (which sets req.user + req.groups). Effective
|
||||
* rights are resolved once per request via Grant.effectiveFor and cached on
|
||||
* req._effective. Roles: admin > manager (owner/full over a domain) > viewer.
|
||||
*/
|
||||
|
||||
// The username for the request, tolerating a missing user relation by falling
|
||||
// back to the token's created_by (which is the username).
|
||||
function reqUsername(req){
|
||||
return (req.user && req.user.username) || (req.token && req.token.created_by) || null;
|
||||
}
|
||||
|
||||
// Normalize a host or domain string to its registrable domain.
|
||||
function toDomain(value){
|
||||
if(!value) return value;
|
||||
try{
|
||||
return tldExtract(value).domain;
|
||||
}catch(error){
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve (and cache) the effective rights for this request.
|
||||
async function getEffective(req){
|
||||
if(req._effective) return req._effective;
|
||||
req._effective = await Grant.effectiveFor({
|
||||
username: reqUsername(req),
|
||||
groups: req.groups || [],
|
||||
});
|
||||
return req._effective;
|
||||
}
|
||||
|
||||
function forbidden(message){
|
||||
let error = new Error('Forbidden');
|
||||
error.name = 'Forbidden';
|
||||
error.message = message || 'You do not have permission to perform this action.';
|
||||
error.status = 403;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Global-admin-only gate (user management, DNS providers, grant management).
|
||||
async function requireAdmin(req, res, next){
|
||||
try{
|
||||
let effective = await getEffective(req);
|
||||
if(effective.isAdmin) return next();
|
||||
return next(forbidden('Administrator access required.'));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require at least `minRole` on the domain resolved from the request.
|
||||
*
|
||||
* @param {string} minRole - 'viewer' | 'manager'
|
||||
* @param {Function} resolveDomain - (req) => host|domain string
|
||||
*/
|
||||
function requireDomainRole(minRole, resolveDomain){
|
||||
return async function(req, res, next){
|
||||
try{
|
||||
let effective = await getEffective(req);
|
||||
let domain = toDomain(resolveDomain(req));
|
||||
if(!domain) return next(forbidden('Could not determine the target domain.'));
|
||||
|
||||
if(Grant.allows(effective, minRole, domain)) return next();
|
||||
return next(forbidden(`You need '${minRole}' rights on ${domain}.`));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Common domain resolvers for route wiring.
|
||||
const resolve = {
|
||||
// A host lives in req.params.item (e.g. api.example.com -> example.com).
|
||||
hostParam: req => req.params.item,
|
||||
// A host being created lives in the request body.
|
||||
hostBody: req => req.body && req.body.host,
|
||||
// A domain (or provider domain) name in req.params.item.
|
||||
domainParam: req => req.params.item,
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter a list of records to those the request may at least view.
|
||||
* Admins and holders of a global role see everything; otherwise a record is
|
||||
* kept when its domain (via `getDomain`) is one the user has rights on.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @param {Array} records
|
||||
* @param {Function} getDomain - (record) => host|domain string
|
||||
*/
|
||||
async function filterViewable(req, records, getDomain){
|
||||
let effective = await getEffective(req);
|
||||
if(effective.isAdmin || Grant.rank(effective.global) >= Grant.rank('viewer')){
|
||||
return records;
|
||||
}
|
||||
return records.filter(function(record){
|
||||
let domain = toDomain(getDomain(record));
|
||||
return Grant.allows(effective, 'viewer', domain);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEffective,
|
||||
requireAdmin,
|
||||
requireDomainRole,
|
||||
filterViewable,
|
||||
resolve,
|
||||
toDomain,
|
||||
reqUsername,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bootstrap a global-admin Grant for a user so there is always someone who can
|
||||
* manage the system after per-domain authorization is enabled.
|
||||
*
|
||||
* Usage:
|
||||
* node migrations/grant_bootstrap.js [username]
|
||||
*
|
||||
* Defaults to the first entry in conf.auth.adminUsers (or 'proxyadmin2').
|
||||
* Note: members of conf.auth.adminUsers / conf.auth.adminGroups are already
|
||||
* treated as admins without a Grant; this just makes it explicit/visible in the
|
||||
* grant list and survives config changes.
|
||||
*/
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
require('../models'); // register all models
|
||||
const {Grant} = require('../models/grant');
|
||||
|
||||
(async function(){
|
||||
try{
|
||||
let username = process.argv[2]
|
||||
|| (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0])
|
||||
|| 'proxyadmin2';
|
||||
|
||||
let grant = await Grant.create({
|
||||
subjectType: 'user',
|
||||
subject: username,
|
||||
scope: 'global',
|
||||
role: 'admin',
|
||||
created_by: username,
|
||||
});
|
||||
|
||||
console.log(`Granted global admin to "${username}":`, grant.id);
|
||||
}catch(error){
|
||||
console.error('grant_bootstrap error', error);
|
||||
}finally{
|
||||
process.exit(0);
|
||||
}
|
||||
})();
|
||||
+23
-1
@@ -47,7 +47,10 @@ class Auth{
|
||||
static async login(data){
|
||||
try{
|
||||
let user = await User.login(data);
|
||||
let token = await AuthToken.create({username: user.username});
|
||||
// Backends may attach group membership to the user (LDAP); default
|
||||
// to none for local/redis users.
|
||||
let groups = Array.isArray(user.groups) ? user.groups : [];
|
||||
let token = await AuthToken.create({username: user.username, groups});
|
||||
|
||||
return {user, token}
|
||||
}catch(error){
|
||||
@@ -56,6 +59,25 @@ class Auth{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a session for an OIDC-authenticated identity: JIT-provision the
|
||||
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
|
||||
*
|
||||
* @param {Object} identity - {username, groups} from utils/oidc claims
|
||||
* @returns {Object} {user, token}
|
||||
*/
|
||||
static async oidcSession(identity){
|
||||
let user = typeof User.upsertOidc === 'function'
|
||||
? await User.upsertOidc(identity)
|
||||
: await User.get(identity.username);
|
||||
let token = await AuthToken.create({
|
||||
username: user.username,
|
||||
groups: identity.groups || [],
|
||||
});
|
||||
|
||||
return {user, token};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an authentication token.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const roles = require('../utils/roles');
|
||||
|
||||
/**
|
||||
* Grant
|
||||
*
|
||||
* Assigns a role to a subject (a user or a group), either globally or for a
|
||||
* single domain. Per-user overrides and group defaults both live here; group
|
||||
* defaults can also be seeded from conf.auth.groupRoleMap.
|
||||
*
|
||||
* subjectType : 'user' | 'group'
|
||||
* subject : username or group name
|
||||
* scope : 'global' | 'domain'
|
||||
* domain : domain name when scope==='domain' (else '*')
|
||||
* role : 'admin' | 'manager' | 'viewer'
|
||||
*
|
||||
* See Grant.effectiveFor() for how these, plus ownership (created_by) and
|
||||
* conf.auth, collapse into a request's effective rights.
|
||||
*/
|
||||
class Grant extends Table{
|
||||
static _key = 'id';
|
||||
static _keyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'id': {isRequired: true, type: 'string', min: 3, max: 1100},
|
||||
'subjectType': {isRequired: true, type: 'string'},
|
||||
'subject': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'scope': {default: 'domain', isRequired: true, type: 'string'},
|
||||
'domain': {default: '*', isRequired: false, type: 'string'},
|
||||
'role': {isRequired: true, type: 'string'},
|
||||
}
|
||||
|
||||
static roles = ['viewer', 'manager', 'admin'];
|
||||
// Re-export the pure helpers so existing callers (middleware/authz) can use
|
||||
// them off the model.
|
||||
static rank = roles.rank;
|
||||
static maxRole = roles.maxRole;
|
||||
static roleForDomain = roles.roleForDomain;
|
||||
static allows = roles.allows;
|
||||
static visibleDomains = roles.visibleDomains;
|
||||
|
||||
// Deterministic id so the same (subject, scope, domain) grant is a single
|
||||
// record — re-granting updates rather than duplicating.
|
||||
static mkId({subjectType, subject, scope, domain}){
|
||||
return `${subjectType}:${subject}:${scope || 'domain'}:${scope === 'global' ? '*' : (domain || '*')}`;
|
||||
}
|
||||
|
||||
static async create(data){
|
||||
if(!this.roles.includes(data.role)){
|
||||
throw this.errors.ObjectValidateError([{key: 'role', message: `role must be one of ${this.roles.join(', ')}`}]);
|
||||
}
|
||||
if(!['user', 'group'].includes(data.subjectType)){
|
||||
throw this.errors.ObjectValidateError([{key: 'subjectType', message: `subjectType must be 'user' or 'group'`}]);
|
||||
}
|
||||
if(data.scope === 'global') data.domain = '*';
|
||||
data.id = this.mkId(data);
|
||||
// Upsert: replace an existing identical-scoped grant instead of 409ing.
|
||||
try{
|
||||
let existing = await this.get(data.id);
|
||||
if(existing) await existing.remove();
|
||||
}catch(error){ /* not found is fine */ }
|
||||
|
||||
return super.create(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse conf.auth, grant records, and resource ownership into the
|
||||
* effective rights for a session identity.
|
||||
*
|
||||
* @param {Object} identity - {username, groups: string[]}
|
||||
* @returns {Object} { isAdmin, global: role|null, domains: {domain: role} }
|
||||
* - isAdmin: full access to everything.
|
||||
* - global: a non-admin global role (manager/viewer) applied to every
|
||||
* domain the user can see.
|
||||
* - domains: explicit per-domain roles (includes owned domains).
|
||||
*/
|
||||
static async effectiveFor(identity){
|
||||
let username = identity && identity.username;
|
||||
|
||||
// Fetch the redis-backed inputs, then hand off to the pure resolver.
|
||||
let grants = [];
|
||||
try{
|
||||
grants = await this.listDetail();
|
||||
}catch(error){ grants = []; }
|
||||
|
||||
// Ownership: a user has manager rights over every domain they (or the
|
||||
// DNS provider they created) own. Domain.created_by already carries the
|
||||
// provider creator, so a single Domain scan covers both.
|
||||
let ownedDomains = [];
|
||||
if(username){
|
||||
try{
|
||||
let Domain = require('.').models.Domain;
|
||||
ownedDomains = (await Domain.listDetail())
|
||||
.filter(d => d.created_by === username)
|
||||
.map(d => d.domain);
|
||||
}catch(error){ /* domains unavailable, skip ownership */ }
|
||||
}
|
||||
|
||||
return roles.resolveEffective(identity, {
|
||||
grants,
|
||||
ownedDomains,
|
||||
authConf: conf.auth || {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Grant.register();
|
||||
|
||||
module.exports = {Grant};
|
||||
@@ -10,3 +10,5 @@ require('./dns_provider');
|
||||
require('./host');
|
||||
require('./token');
|
||||
require('./user');
|
||||
require('./grant');
|
||||
require('./oidc_state');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
|
||||
/**
|
||||
* OidcState
|
||||
*
|
||||
* Short-lived store for an in-flight OpenID Connect authorization request.
|
||||
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
|
||||
* post-login redirect target until the SSO calls us back.
|
||||
*
|
||||
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
|
||||
* abandoned login attempt leaves nothing behind and there is no cleanup job.
|
||||
*/
|
||||
class OidcState extends Table{
|
||||
static _key = 'state';
|
||||
|
||||
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
|
||||
static _ttl = 300;
|
||||
|
||||
static _keyMap = {
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'state': {isRequired: true, type: 'string', min: 8, max: 500},
|
||||
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
|
||||
'redirect': {default: '/', isRequired: false, type: 'string'},
|
||||
}
|
||||
}
|
||||
|
||||
OidcState.register();
|
||||
|
||||
module.exports = {OidcState};
|
||||
@@ -33,13 +33,31 @@ class AuthToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
||||
// Group memberships captured at login (OIDC `groups` claim or LDAP
|
||||
// group membership), stored as a JSON string. Drives authorization for
|
||||
// the life of the session without re-querying the IdP on every request.
|
||||
groups: {default: '[]', isRequired: false, type: 'string'},
|
||||
}
|
||||
|
||||
static async create(data){
|
||||
data.created_by = data.username;
|
||||
if(Array.isArray(data.groups)){
|
||||
data.groups = JSON.stringify(data.groups);
|
||||
}
|
||||
return super.create(data)
|
||||
|
||||
}
|
||||
|
||||
// Parse the stored groups JSON back into an array, tolerating bad/missing
|
||||
// data so authorization never crashes on a malformed token.
|
||||
groupsArray(){
|
||||
try{
|
||||
let parsed = JSON.parse(this.groups);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}catch(error){
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthToken.register();
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ const client = new Client({
|
||||
});
|
||||
|
||||
|
||||
// Best-effort group extraction from a directory entry's `memberOf` values.
|
||||
// Turns `cn=dns-team,ou=groups,dc=...` into `dns-team`. Directories that don't
|
||||
// return memberOf simply yield no groups (see conf note); explicit group-search
|
||||
// can be added later if needed.
|
||||
const parse_groups = function(memberOf){
|
||||
if(!memberOf) return [];
|
||||
let values = Array.isArray(memberOf) ? memberOf : [memberOf];
|
||||
return values.map(function(dn){
|
||||
let match = /^cn=([^,]+)/i.exec(String(dn));
|
||||
return match ? match[1] : String(dn);
|
||||
});
|
||||
}
|
||||
|
||||
const user_parse = function(data){
|
||||
if(data[conf.userNameAttribute]){
|
||||
data.username = data[conf.userNameAttribute]
|
||||
@@ -20,6 +33,8 @@ const user_parse = function(data){
|
||||
delete data.uidNumber;
|
||||
}
|
||||
|
||||
data.groups = parse_groups(data.memberOf);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const Table = require('.');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const saltRounds = 10;
|
||||
|
||||
class User extends Table{
|
||||
@@ -40,6 +41,28 @@ class User extends Table{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Just-in-time provisioning for an OIDC-authenticated user. Creates the
|
||||
* local user on first login so relations (tokens, created_by, grants) have
|
||||
* something to point at. OIDC users get a random, unusable password — they
|
||||
* authenticate through the SSO, never the local password form.
|
||||
*
|
||||
* @param {Object} data - {username, ...} from the OIDC userinfo claims
|
||||
* @returns {User} the existing or newly created user
|
||||
*/
|
||||
static async upsertOidc(data){
|
||||
try{
|
||||
return await User.get(data.username);
|
||||
}catch(error){
|
||||
return await User.create({
|
||||
username: data.username,
|
||||
password: crypto.randomBytes(24).toString('hex'),
|
||||
created_by: data.username,
|
||||
backing: 'oidc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async login(data){
|
||||
try{
|
||||
let user = await User.get(data);
|
||||
|
||||
Generated
+28
@@ -18,6 +18,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"ejs": "^6.0.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.0.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -917,6 +918,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -1229,6 +1248,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
+4
-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/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/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/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/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/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/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -29,6 +29,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"ejs": "^6.0.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.0.1",
|
||||
"jquery": "^4.0.0",
|
||||
|
||||
@@ -76,7 +76,7 @@ app.api = (function(app){
|
||||
var baseURL = '/api/'
|
||||
|
||||
function post(url, data, callback){
|
||||
if(!$.isFunction(callback)) callback = callback2;
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
return $.ajax({
|
||||
type: 'POST',
|
||||
url: baseURL+url,
|
||||
@@ -97,7 +97,7 @@ app.api = (function(app){
|
||||
}
|
||||
|
||||
function put(url, data, callback){
|
||||
if(!$.isFunction(callback)) callback = callback2;
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
return $.ajax({
|
||||
type: 'PUT',
|
||||
url: baseURL+url,
|
||||
@@ -118,7 +118,7 @@ app.api = (function(app){
|
||||
}
|
||||
|
||||
function remove(url, callback, callback2){
|
||||
if(!$.isFunction(callback)) callback = callback2;
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
return $.ajax({
|
||||
type: 'delete',
|
||||
url: baseURL+url,
|
||||
@@ -191,7 +191,8 @@ app.auth = (function(app){
|
||||
function isLoggedIn(callback){
|
||||
if(getToken()){
|
||||
return app.api.get('user/me', function(error, data){
|
||||
if(!error) app.auth.user = data;
|
||||
// data now carries effective rights (isAdmin, global, domains).
|
||||
if(!error) app.auth.user = app.auth.perms = data;
|
||||
return callback(error, data);
|
||||
});
|
||||
}else{
|
||||
@@ -199,6 +200,41 @@ app.auth = (function(app){
|
||||
}
|
||||
}
|
||||
|
||||
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||
function safeInternalPath(path){
|
||||
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||
return '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Consume an app token handed back by the OIDC callback via the URL
|
||||
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||
// forwards to the intended page. Returns true if a token was consumed.
|
||||
function consumeTokenFragment(){
|
||||
if(!location.hash) return false;
|
||||
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||
var token = params.get('token');
|
||||
if(!token) return false;
|
||||
|
||||
setToken(token);
|
||||
// redirect comes from the URL fragment (attacker-controllable); only
|
||||
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||
// Drop the token from the address bar before navigating on.
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
window.location.href = redirect;
|
||||
return true;
|
||||
}
|
||||
|
||||
// True when the logged-in user is a global admin (per user/me).
|
||||
function isAdmin(){
|
||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||
}
|
||||
|
||||
function logIn(args, callback){
|
||||
app.api.post('auth/login', args, function(error, data){
|
||||
if(data.login){
|
||||
@@ -214,25 +250,27 @@ app.auth = (function(app){
|
||||
}
|
||||
|
||||
function forceLogin(){
|
||||
$.holdReady(true);
|
||||
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an
|
||||
// unauthenticated user off the page instead of pausing document ready.
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(error || !isLoggedIn){
|
||||
app.auth.logOut(function(){})
|
||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
||||
}else{
|
||||
$.holdReady(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function logInRedirect(){
|
||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
||||
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
|
||||
}
|
||||
|
||||
return {
|
||||
getToken: getToken,
|
||||
setToken: setToken,
|
||||
isLoggedIn: isLoggedIn,
|
||||
consumeTokenFragment: consumeTokenFragment,
|
||||
isAdmin: isAdmin,
|
||||
perms: null,
|
||||
logIn: logIn,
|
||||
logOut: logOut,
|
||||
forceLogin,
|
||||
@@ -270,6 +308,29 @@ app.user = (function(app){
|
||||
|
||||
})(app);
|
||||
|
||||
app.grant = (function(app){
|
||||
function list(callback){
|
||||
app.api.get('grant/', function(error, data){
|
||||
callback(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function add(args, callback){
|
||||
app.api.post('grant/', args, function(error, data){
|
||||
callback(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id, callback){
|
||||
app.api.delete('grant/' + encodeURIComponent(id), function(error, data){
|
||||
callback(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
return {list, add, remove};
|
||||
|
||||
})(app);
|
||||
|
||||
app.util = (function(app){
|
||||
|
||||
function getUrlParameter(name){
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const middleware = require('../middleware/auth');
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
// API routes for authentication.
|
||||
// API routes for authentication.
|
||||
router.use('/auth', require('./auth'));
|
||||
|
||||
// API routes for working with users. All endpoints need to be have valid user.
|
||||
// User management is admin-only; the router allows self-service exceptions
|
||||
// (GET /me, PUT /password) before its own admin gate.
|
||||
router.use('/user', middleware.auth, require('./user'));
|
||||
|
||||
// API routes for working with hosts. All endpoints need to be have valid user.
|
||||
// Per-domain authorization is enforced inside the host router.
|
||||
router.use('/host', middleware.auth, require('./host'));
|
||||
|
||||
router.use('/dns', middleware.auth, require('./dns'));
|
||||
@@ -18,4 +22,7 @@ router.use('/dns', middleware.auth, require('./dns'));
|
||||
// API routes for working with hosts. All endpoints need to be have valid user.
|
||||
router.use('/cert', middleware.auth, require('./cert'));
|
||||
|
||||
// Grant management (who can manage which domains) is global-admin-only.
|
||||
router.use('/grant', middleware.auth, authz.requireAdmin, require('./grant'));
|
||||
|
||||
module.exports = router;
|
||||
+80
-1
@@ -1,10 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const { rateLimit } = require('express-rate-limit');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Auth } = require('../models/auth');
|
||||
const { OidcState } = require('../models/oidc_state');
|
||||
const oidc = require('../utils/oidc');
|
||||
const { safeInternalPath } = require('../utils/safe_redirect');
|
||||
|
||||
// Throttle unauthenticated auth endpoints (credential login + the OIDC
|
||||
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 60, // 60 attempts per IP per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
|
||||
});
|
||||
|
||||
|
||||
router.post('/login', async function(req, res, next){
|
||||
router.post('/login', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
let auth = await Auth.login(req.body);
|
||||
return res.json({
|
||||
@@ -29,4 +44,68 @@ router.all('/logout', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
||||
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
||||
*/
|
||||
router.get('/oidc/start', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
if(!conf.oidc || !conf.oidc.enabled){
|
||||
let error = new Error('OidcDisabled');
|
||||
error.status = 404;
|
||||
error.message = 'OIDC login is not enabled.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||
await OidcState.create({
|
||||
state,
|
||||
codeVerifier,
|
||||
// Sanitize now so a hostile ?redirect= can't be stored and later
|
||||
// reflected into the login page's navigation.
|
||||
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||
});
|
||||
|
||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC callback: validate state (consuming the one-time record), exchange the
|
||||
* code for tokens, read identity from userinfo, establish a session, and hand
|
||||
* the app token back to the browser via a URL fragment for the login page to
|
||||
* store in localStorage.
|
||||
*/
|
||||
router.get('/oidc/callback', authLimiter, async function(req, res, next){
|
||||
try{
|
||||
let {code, state} = req.query;
|
||||
if(!code || !state){
|
||||
let error = new Error('OidcCallbackInvalid');
|
||||
error.status = 400;
|
||||
error.message = 'Missing code or state.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// get() throws if the state is unknown or has expired — this both binds
|
||||
// the callback to our request and bounds replay.
|
||||
let saved = await OidcState.get(state);
|
||||
await saved.remove();
|
||||
|
||||
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
|
||||
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
||||
let identity = oidc.claimsToIdentity(claims);
|
||||
|
||||
let {token} = await Auth.oidcSession(identity);
|
||||
|
||||
let redirect = safeInternalPath(saved.redirect || '/');
|
||||
return res.redirect(
|
||||
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
||||
);
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {getCert} = require('../models/cert');
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
|
||||
router.get('/:host', async function(req, res, next){
|
||||
router.get('/:host', authz.requireDomainRole('viewer', req => req.params.host), async function(req, res, next){
|
||||
try{
|
||||
return res.json(await getCert(req.params.host));
|
||||
}catch(error){
|
||||
|
||||
+21
-13
@@ -2,10 +2,14 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {DnsProvider, Domain} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
const Model = DnsProvider;
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
// Provider listing exposes credentials/config for every domain, so it is
|
||||
// admin-only. The creator of a provider still owns its domains (via created_by)
|
||||
// and manages hosts/records under them without being a global admin.
|
||||
router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model[req.query.detail ? "listDetail" : "list"]()
|
||||
@@ -15,7 +19,7 @@ router.get('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.options('/', async function(req, res, next){
|
||||
router.options('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model.listProviders()
|
||||
@@ -25,9 +29,9 @@ router.options('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username;
|
||||
req.body.created_by = authz.reqUsername(req);
|
||||
let item = await Model.create(req.body);
|
||||
|
||||
return res.json({
|
||||
@@ -41,15 +45,19 @@ router.post('/', async function(req, res, next){
|
||||
|
||||
router.get('/domain', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Domain[req.query.detail ? "listDetail" : "list"]()
|
||||
});
|
||||
let results = await Domain[req.query.detail ? "listDetail" : "list"]();
|
||||
|
||||
// Only surface domains the caller may view.
|
||||
results = await authz.filterViewable(req, results,
|
||||
item => (typeof item === 'string' ? item : item.domain));
|
||||
|
||||
return res.json({results});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/domain/refresh/:item', async function(req, res, next){
|
||||
router.post('/domain/refresh/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
return res.json({results: await item.updateDomains()});
|
||||
@@ -58,7 +66,7 @@ router.post('/domain/refresh/:item', async function(req, res, next){
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/domain/:item', async function(req, res, next){
|
||||
router.get('/domain/:item', authz.requireDomainRole('viewer', authz.resolve.domainParam), async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: [await Domain.get(req.params.item)]
|
||||
@@ -68,7 +76,7 @@ router.get('/domain/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:item', async function(req, res, next){
|
||||
router.get('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
|
||||
return res.json({
|
||||
@@ -80,9 +88,9 @@ router.get('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item', async function(req, res, next){
|
||||
router.put('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.updated_by = req.user.username;
|
||||
req.body.updated_by = authz.reqUsername(req);
|
||||
let item = await Model.get(req.params.item);
|
||||
item = await item.update(req.body);
|
||||
|
||||
@@ -98,7 +106,7 @@ router.put('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:item', async function(req, res, next){
|
||||
router.delete('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
let count = await item.remove();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const {Grant} = require('../models/grant');
|
||||
const {reqUsername} = require('../middleware/authz');
|
||||
|
||||
// All grant management is admin-only; the gate is applied where this router is
|
||||
// mounted (routes/api.js).
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await Grant.listDetail()});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = reqUsername(req);
|
||||
let grant = await Grant.create(req.body);
|
||||
return res.json({
|
||||
message: `Granted ${req.body.role} to ${req.body.subjectType} "${req.body.subject}"` +
|
||||
(req.body.scope === 'global' ? ' globally.' : ` on ${req.body.domain}.`),
|
||||
...grant,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async function(req, res, next){
|
||||
try{
|
||||
let grant = await Grant.get(req.params.id);
|
||||
await grant.remove();
|
||||
return res.json({message: `Grant ${req.params.id} removed.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+20
-13
@@ -2,22 +2,28 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {Host, Domain} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
const Model = Host;
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model[req.query.detail ? "listDetail" : "list"](),
|
||||
});
|
||||
let results = await Model[req.query.detail ? "listDetail" : "list"]();
|
||||
|
||||
// Restrict to hosts whose domain the caller may view. list() yields host
|
||||
// strings; listDetail() yields instances with a .host.
|
||||
results = await authz.filterViewable(req, results,
|
||||
item => (typeof item === 'string' ? item : item.host));
|
||||
|
||||
return res.json({results});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username;
|
||||
req.body.created_by = authz.reqUsername(req);
|
||||
let item = await Model.create(req.body);
|
||||
|
||||
return res.json({
|
||||
@@ -29,7 +35,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/lookup/:item', async function(req, res, next){
|
||||
router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
string: req.params.item,
|
||||
@@ -41,7 +47,8 @@ router.get('/lookup/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/lookupobj', async function(req, res, next){
|
||||
// The full lookup tree exposes every host, so restrict it to admins.
|
||||
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: Model.lookUpObj,
|
||||
@@ -52,7 +59,7 @@ router.get('/lookupobj', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/cache', async function(req, res, next){
|
||||
router.delete('/cache', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let count = await Model.clearCache();
|
||||
|
||||
@@ -65,7 +72,7 @@ router.delete('/cache', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:item', async function(req, res, next){
|
||||
router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
|
||||
return res.json({
|
||||
@@ -77,9 +84,9 @@ router.get('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item', async function(req, res, next){
|
||||
router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
req.body.updated_by = req.user.username;
|
||||
req.body.updated_by = authz.reqUsername(req);
|
||||
let item = await Model.get(req.params.item);
|
||||
item = await item.update(req.body);
|
||||
|
||||
@@ -95,7 +102,7 @@ router.put('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:item', async function(req, res, next){
|
||||
router.delete('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
let count = await item.remove();
|
||||
@@ -110,7 +117,7 @@ router.delete('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item/renew', async function(req, res, next){
|
||||
router.put('/:item/renew', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
item.createWildcardCert();
|
||||
|
||||
@@ -42,6 +42,15 @@ router.get('/users', async function(req, res, next) {
|
||||
res.render('users', {...values});
|
||||
});
|
||||
|
||||
router.get('/grants', async function(req, res, next) {
|
||||
res.render('grants', {...values});
|
||||
});
|
||||
|
||||
// Bare /login (the OIDC callback redirect target) and /login/<path>.
|
||||
router.get('/login', async function(req, res, next) {
|
||||
res.render('login', {...values, redirect: req.query.redirect});
|
||||
});
|
||||
|
||||
router.get('/login/*splat', async function(req, res, next) {
|
||||
res.render('login', {...values, redirect: req.query.redirect});
|
||||
});
|
||||
|
||||
+26
-9
@@ -1,9 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const {User} = require('../models').models;
|
||||
const {User} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
// 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.
|
||||
|
||||
router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await User[req.query.detail ? "listDetail" : "list"]()
|
||||
@@ -13,9 +18,9 @@ router.get('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username
|
||||
req.body.created_by = authz.reqUsername(req)
|
||||
|
||||
return res.json(await User.add(req.body));
|
||||
}catch(error){
|
||||
@@ -23,7 +28,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:username', async function(req, res, next){
|
||||
router.delete('/:username', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let user = await User.get(req.params.username);
|
||||
|
||||
@@ -33,14 +38,24 @@ router.delete('/:username', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: the caller's own identity and effective rights. Drives the
|
||||
// frontend's nav/button gating.
|
||||
router.get('/me', async function(req, res, next){
|
||||
try{
|
||||
return res.json({username: req.user.username});
|
||||
let effective = await authz.getEffective(req);
|
||||
return res.json({
|
||||
username: authz.reqUsername(req),
|
||||
groups: req.groups || [],
|
||||
isAdmin: effective.isAdmin,
|
||||
global: effective.global,
|
||||
domains: effective.domains,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: change your own password.
|
||||
router.put('/password', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await req.user.setPassword(req.body)})
|
||||
@@ -49,7 +64,8 @@ router.put('/password', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/password/:username', async function(req, res, next){
|
||||
// Admin: reset another user's password.
|
||||
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let user = await User.get(req.params.username);
|
||||
return res.json({results: await user.setPassword(req.body)});
|
||||
@@ -58,7 +74,7 @@ router.put('/password/:username', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/invite', async function(req, res, next){
|
||||
router.post('/invite', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let token = await req.user.invite();
|
||||
|
||||
@@ -68,10 +84,11 @@ router.post('/invite', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// 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: req.user.username,
|
||||
username: authz.reqUsername(req),
|
||||
key: req.body.key
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const oidc = require('../../utils/oidc');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
/**
|
||||
* Tests for the pure parts of the OIDC client (utils/oidc): PKCE/state
|
||||
* generation, authorize-URL construction, and claim mapping. Network calls
|
||||
* (exchangeCode/fetchUserInfo) are not exercised here.
|
||||
*/
|
||||
|
||||
describe('oidc PKCE / state', () => {
|
||||
test('createAuthRequest returns distinct high-entropy state and verifier', () => {
|
||||
const a = oidc.createAuthRequest();
|
||||
assert.ok(a.state.length >= 20);
|
||||
assert.ok(a.codeVerifier.length >= 20);
|
||||
assert.notStrictEqual(a.state, a.codeVerifier);
|
||||
|
||||
const b = oidc.createAuthRequest();
|
||||
assert.notStrictEqual(a.state, b.state);
|
||||
});
|
||||
|
||||
test('code challenge is the base64url S256 of the verifier', () => {
|
||||
const {codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
assert.strictEqual(codeChallenge, expected);
|
||||
});
|
||||
|
||||
test('challenge is base64url (no +, /, or = padding)', () => {
|
||||
const {codeChallenge} = oidc.createAuthRequest();
|
||||
assert.ok(!/[+/=]/.test(codeChallenge));
|
||||
});
|
||||
});
|
||||
|
||||
describe('oidc buildAuthUrl', () => {
|
||||
test('includes required authorization-code + PKCE params', () => {
|
||||
const url = new URL(oidc.buildAuthUrl('the-state', 'the-challenge'));
|
||||
assert.strictEqual(url.origin + url.pathname, conf.oidc.authorizationEndpoint);
|
||||
const p = url.searchParams;
|
||||
assert.strictEqual(p.get('response_type'), 'code');
|
||||
assert.strictEqual(p.get('client_id'), conf.oidc.clientId);
|
||||
assert.strictEqual(p.get('redirect_uri'), conf.oidc.redirectUri);
|
||||
assert.strictEqual(p.get('state'), 'the-state');
|
||||
assert.strictEqual(p.get('code_challenge'), 'the-challenge');
|
||||
assert.strictEqual(p.get('code_challenge_method'), 'S256');
|
||||
assert.ok(p.get('scope').includes('openid'));
|
||||
assert.ok(p.get('scope').includes('groups'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('oidc claimsToIdentity', () => {
|
||||
test('maps preferred_username and groups', () => {
|
||||
const id = oidc.claimsToIdentity({
|
||||
sub: 'abc',
|
||||
preferred_username: 'jane',
|
||||
groups: ['dns-team', 'proxy-admins'],
|
||||
});
|
||||
assert.strictEqual(id.username, 'jane');
|
||||
assert.deepStrictEqual(id.groups, ['dns-team', 'proxy-admins']);
|
||||
});
|
||||
|
||||
test('falls back to sub when no preferred_username', () => {
|
||||
const id = oidc.claimsToIdentity({sub: 'abc'});
|
||||
assert.strictEqual(id.username, 'abc');
|
||||
assert.deepStrictEqual(id.groups, []);
|
||||
});
|
||||
|
||||
test('coerces a single group value to an array', () => {
|
||||
const id = oidc.claimsToIdentity({sub: 'abc', groups: 'solo'});
|
||||
assert.deepStrictEqual(id.groups, ['solo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const roles = require('../../utils/roles');
|
||||
|
||||
/**
|
||||
* Tests for the pure authorization logic (utils/roles). No redis: grant rows,
|
||||
* owned domains, and conf.auth are passed in directly. This is the heart of the
|
||||
* per-domain rights model — see models/grant.js for the redis-backed wiring.
|
||||
*/
|
||||
|
||||
const authConf = {
|
||||
adminUsers: ['root'],
|
||||
adminGroups: ['proxy-admins'],
|
||||
groupRoleMap: {
|
||||
'global-viewers': {scope: 'global', role: 'viewer'},
|
||||
'foo-managers': {scope: 'domain', domain: 'foo.com', role: 'manager'},
|
||||
'super': {scope: 'global', role: 'admin'},
|
||||
},
|
||||
};
|
||||
|
||||
const effective = (identity, data) =>
|
||||
roles.resolveEffective(identity, {authConf, ...data});
|
||||
|
||||
describe('roles.resolveEffective', () => {
|
||||
|
||||
describe('admin', () => {
|
||||
test('conf adminUsers grants global admin', () => {
|
||||
const e = effective({username: 'root', groups: []});
|
||||
assert.strictEqual(e.isAdmin, true);
|
||||
});
|
||||
|
||||
test('conf adminGroups grants global admin', () => {
|
||||
const e = effective({username: 'bob', groups: ['proxy-admins']});
|
||||
assert.strictEqual(e.isAdmin, true);
|
||||
});
|
||||
|
||||
test('groupRoleMap admin role grants global admin', () => {
|
||||
const e = effective({username: 'bob', groups: ['super']});
|
||||
assert.strictEqual(e.isAdmin, true);
|
||||
});
|
||||
|
||||
test('a global admin Grant record grants admin', () => {
|
||||
const e = effective({username: 'bob', groups: []}, {
|
||||
grants: [{subjectType: 'user', subject: 'bob', scope: 'global', role: 'admin'}],
|
||||
});
|
||||
assert.strictEqual(e.isAdmin, true);
|
||||
});
|
||||
|
||||
test('admin passes every domain check', () => {
|
||||
const e = effective({username: 'root', groups: []});
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'anything.com'), true);
|
||||
assert.strictEqual(roles.roleForDomain(e, 'anything.com'), 'admin');
|
||||
});
|
||||
|
||||
test('a plain user is not admin', () => {
|
||||
const e = effective({username: 'nobody', groups: []});
|
||||
assert.strictEqual(e.isAdmin, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-domain grants', () => {
|
||||
test('user manager grant allows manage on that domain only', () => {
|
||||
const e = effective({username: 'jane', groups: []}, {
|
||||
grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'}],
|
||||
});
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'other.com'), false);
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'other.com'), false);
|
||||
});
|
||||
|
||||
test('viewer grant allows read but not manage', () => {
|
||||
const e = effective({username: 'jane', groups: []}, {
|
||||
grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'viewer'}],
|
||||
});
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), false);
|
||||
});
|
||||
|
||||
test('group grant applies to members', () => {
|
||||
const e = effective({username: 'jane', groups: ['dns-team']}, {
|
||||
grants: [{subjectType: 'group', subject: 'dns-team', scope: 'domain', domain: 'ex.com', role: 'manager'}],
|
||||
});
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), true);
|
||||
});
|
||||
|
||||
test('group grant does not apply to non-members', () => {
|
||||
const e = effective({username: 'jane', groups: []}, {
|
||||
grants: [{subjectType: 'group', subject: 'dns-team', scope: 'domain', domain: 'ex.com', role: 'manager'}],
|
||||
});
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), false);
|
||||
});
|
||||
|
||||
test('groupRoleMap domain default applies', () => {
|
||||
const e = effective({username: 'jane', groups: ['foo-managers']});
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'foo.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'bar.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('override precedence (strongest wins)', () => {
|
||||
test('a per-user manager grant beats a group viewer grant', () => {
|
||||
const e = effective({username: 'jane', groups: ['team']}, {
|
||||
grants: [
|
||||
{subjectType: 'group', subject: 'team', scope: 'domain', domain: 'ex.com', role: 'viewer'},
|
||||
{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'},
|
||||
],
|
||||
});
|
||||
assert.strictEqual(roles.roleForDomain(e, 'ex.com'), 'manager');
|
||||
});
|
||||
|
||||
test('grant order does not matter (max wins)', () => {
|
||||
const e = effective({username: 'jane', groups: []}, {
|
||||
grants: [
|
||||
{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'},
|
||||
{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'viewer'},
|
||||
],
|
||||
});
|
||||
assert.strictEqual(roles.roleForDomain(e, 'ex.com'), 'manager');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownership', () => {
|
||||
test('owned domains grant manager without an explicit grant', () => {
|
||||
const e = effective({username: 'owner', groups: []}, {
|
||||
ownedDomains: ['mine.com'],
|
||||
});
|
||||
assert.strictEqual(roles.roleForDomain(e, 'mine.com'), 'manager');
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'mine.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'notmine.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global (non-admin) roles', () => {
|
||||
test('global viewer sees every domain read-only', () => {
|
||||
const e = effective({username: 'v', groups: ['global-viewers']});
|
||||
assert.strictEqual(e.global, 'viewer');
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'a.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'viewer', 'b.com'), true);
|
||||
assert.strictEqual(roles.allows(e, 'manager', 'a.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleDomains', () => {
|
||||
test('lists domains with at least viewer', () => {
|
||||
const e = effective({username: 'jane', groups: []}, {
|
||||
grants: [
|
||||
{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'a.com', role: 'viewer'},
|
||||
{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'b.com', role: 'manager'},
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(roles.visibleDomains(e).sort(), ['a.com', 'b.com']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roles.rank / maxRole', () => {
|
||||
test('rank ordering', () => {
|
||||
assert.ok(roles.rank('admin') > roles.rank('manager'));
|
||||
assert.ok(roles.rank('manager') > roles.rank('viewer'));
|
||||
assert.ok(roles.rank('viewer') > roles.rank(null));
|
||||
});
|
||||
|
||||
test('maxRole returns the stronger role', () => {
|
||||
assert.strictEqual(roles.maxRole('viewer', 'manager'), 'manager');
|
||||
assert.strictEqual(roles.maxRole('manager', 'viewer'), 'manager');
|
||||
assert.strictEqual(roles.maxRole(null, 'viewer'), 'viewer');
|
||||
assert.strictEqual(roles.maxRole(null, null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const {describe, test} = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {safeInternalPath} = require('../../utils/safe_redirect');
|
||||
|
||||
/**
|
||||
* safeInternalPath guards the OIDC post-login redirect against open-redirect
|
||||
* and script-scheme (XSS) targets. Only same-origin "/path" values pass.
|
||||
*/
|
||||
describe('safeInternalPath', () => {
|
||||
|
||||
test('allows plain same-origin paths', () => {
|
||||
assert.strictEqual(safeInternalPath('/'), '/');
|
||||
assert.strictEqual(safeInternalPath('/hosts'), '/hosts');
|
||||
assert.strictEqual(safeInternalPath('/dns?x=1'), '/dns?x=1');
|
||||
assert.strictEqual(safeInternalPath('/a/b/c#frag'), '/a/b/c#frag');
|
||||
});
|
||||
|
||||
test('rejects absolute URLs', () => {
|
||||
assert.strictEqual(safeInternalPath('https://evil.com'), '/');
|
||||
assert.strictEqual(safeInternalPath('http://evil.com/x'), '/');
|
||||
});
|
||||
|
||||
test('rejects protocol-relative and backslash host tricks', () => {
|
||||
assert.strictEqual(safeInternalPath('//evil.com'), '/');
|
||||
assert.strictEqual(safeInternalPath('/\\evil.com'), '/');
|
||||
});
|
||||
|
||||
test('rejects script / data schemes', () => {
|
||||
assert.strictEqual(safeInternalPath('javascript:alert(1)'), '/');
|
||||
assert.strictEqual(safeInternalPath('data:text/html,<script>'), '/');
|
||||
});
|
||||
|
||||
test('rejects non-path and non-string input', () => {
|
||||
assert.strictEqual(safeInternalPath('hosts'), '/'); // no leading slash
|
||||
assert.strictEqual(safeInternalPath(''), '/');
|
||||
assert.strictEqual(safeInternalPath(undefined), '/');
|
||||
assert.strictEqual(safeInternalPath(null), '/');
|
||||
assert.strictEqual(safeInternalPath({}), '/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
/**
|
||||
* Minimal OpenID Connect authorization-code + PKCE client.
|
||||
*
|
||||
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
|
||||
* instead we treat the flow as opaque and read identity from the userinfo
|
||||
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
|
||||
* global fetch (Node 18+) and crypto — no external dependency.
|
||||
*
|
||||
* All endpoints and client config come from conf.oidc (+ clientSecret from
|
||||
* secrets.js, deep-merged by @simpleworkjs/conf).
|
||||
*/
|
||||
|
||||
const base64url = buf => buf.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
// A high-entropy random string for `state` / PKCE verifier.
|
||||
function randomToken(bytes = 32){
|
||||
return base64url(crypto.randomBytes(bytes));
|
||||
}
|
||||
|
||||
// PKCE S256 challenge derived from the verifier.
|
||||
function codeChallengeS256(verifier){
|
||||
return base64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
}
|
||||
|
||||
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
|
||||
function createAuthRequest(){
|
||||
let state = randomToken(32);
|
||||
let codeVerifier = randomToken(32);
|
||||
let codeChallenge = codeChallengeS256(codeVerifier);
|
||||
return {state, codeVerifier, codeChallenge};
|
||||
}
|
||||
|
||||
// Build the SSO authorize URL the browser is redirected to.
|
||||
function buildAuthUrl(state, codeChallenge){
|
||||
let o = conf.oidc;
|
||||
let params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: o.clientId,
|
||||
redirect_uri: o.redirectUri,
|
||||
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Exchange an authorization code for tokens at the token endpoint.
|
||||
async function exchangeCode(code, codeVerifier){
|
||||
let o = conf.oidc;
|
||||
let body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: o.redirectUri,
|
||||
client_id: o.clientId,
|
||||
client_secret: o.clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
let res = await fetch(o.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if(!res.ok){
|
||||
let text = await res.text().catch(() => '');
|
||||
let error = new Error('OidcTokenExchangeFailed');
|
||||
error.name = 'OidcTokenExchangeFailed';
|
||||
error.message = `Token exchange failed (${res.status}): ${text}`;
|
||||
error.status = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Fetch the userinfo claims for an access token.
|
||||
async function fetchUserInfo(accessToken){
|
||||
let o = conf.oidc;
|
||||
let res = await fetch(o.userinfoEndpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if(!res.ok){
|
||||
let error = new Error('OidcUserInfoFailed');
|
||||
error.name = 'OidcUserInfoFailed';
|
||||
error.message = `Userinfo request failed (${res.status})`;
|
||||
error.status = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Pull the app username and group list out of userinfo claims per conf.
|
||||
function claimsToIdentity(claims){
|
||||
let o = conf.oidc;
|
||||
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
|
||||
let groups = claims[o.groupsClaim || 'groups'] || [];
|
||||
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
|
||||
return {username, groups, claims};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomToken,
|
||||
codeChallengeS256,
|
||||
createAuthRequest,
|
||||
buildAuthUrl,
|
||||
exchangeCode,
|
||||
fetchUserInfo,
|
||||
claimsToIdentity,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pure authorization role logic — no redis, no I/O — so it can be unit tested
|
||||
* in isolation. models/grant.js supplies the data (grant records, owned
|
||||
* domains, conf.auth) and this module collapses it into effective rights and
|
||||
* answers allow/deny questions.
|
||||
*
|
||||
* Roles rank: admin > manager (owner/full over a domain) > viewer.
|
||||
*/
|
||||
|
||||
const ROLE_RANK = {viewer: 1, manager: 2, admin: 3};
|
||||
|
||||
function rank(role){
|
||||
return ROLE_RANK[role] || 0;
|
||||
}
|
||||
|
||||
// Whichever of two roles is stronger; either may be null/undefined.
|
||||
function maxRole(a, b){
|
||||
if(rank(a) >= rank(b)) return a || b || null;
|
||||
return b || a || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse config, grants, and ownership into effective rights.
|
||||
*
|
||||
* @param {Object} identity - {username, groups: string[]}
|
||||
* @param {Object} data
|
||||
* - grants: [{subjectType, subject, scope, domain, role}]
|
||||
* - ownedDomains: string[] (domains the user owns via created_by)
|
||||
* - authConf: conf.auth ({adminUsers, adminGroups, groupRoleMap})
|
||||
* @returns {Object} { isAdmin, global: role|null, domains: {domain: role} }
|
||||
*/
|
||||
function resolveEffective(identity, data){
|
||||
let username = identity && identity.username;
|
||||
let groups = (identity && identity.groups) || [];
|
||||
let grants = (data && data.grants) || [];
|
||||
let ownedDomains = (data && data.ownedDomains) || [];
|
||||
let authConf = (data && data.authConf) || {};
|
||||
|
||||
let result = {isAdmin: false, global: null, domains: {}};
|
||||
|
||||
// 1) Config-driven global admin (anti-lockout bootstrap).
|
||||
if((authConf.adminUsers || []).includes(username)) result.isAdmin = true;
|
||||
for(let g of groups){
|
||||
if((authConf.adminGroups || []).includes(g)) result.isAdmin = true;
|
||||
}
|
||||
|
||||
// 2) Config-driven group role defaults.
|
||||
let groupRoleMap = authConf.groupRoleMap || {};
|
||||
for(let g of groups){
|
||||
let m = groupRoleMap[g];
|
||||
if(!m) continue;
|
||||
if(m.role === 'admin' && (m.scope === 'global' || !m.scope)){
|
||||
result.isAdmin = true;
|
||||
}else if(m.scope === 'global'){
|
||||
result.global = maxRole(result.global, m.role);
|
||||
}else if(m.domain){
|
||||
result.domains[m.domain] = maxRole(result.domains[m.domain], m.role);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Grant records for this user or any of their groups.
|
||||
for(let grant of grants){
|
||||
let matches = (grant.subjectType === 'user' && grant.subject === username)
|
||||
|| (grant.subjectType === 'group' && groups.includes(grant.subject));
|
||||
if(!matches) continue;
|
||||
|
||||
if(grant.scope === 'global'){
|
||||
if(grant.role === 'admin') result.isAdmin = true;
|
||||
else result.global = maxRole(result.global, grant.role);
|
||||
}else{
|
||||
result.domains[grant.domain] = maxRole(result.domains[grant.domain], grant.role);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Ownership: manager rights over every owned domain.
|
||||
for(let domain of ownedDomains){
|
||||
result.domains[domain] = maxRole(result.domains[domain], 'manager');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Effective role on one domain, folding in admin and any global role.
|
||||
function roleForDomain(effective, domain){
|
||||
if(effective.isAdmin) return 'admin';
|
||||
return maxRole(effective.global, effective.domains[domain]);
|
||||
}
|
||||
|
||||
// Does `effective` meet or exceed `minRole` for `domain`?
|
||||
function allows(effective, minRole, domain){
|
||||
return rank(roleForDomain(effective, domain)) >= rank(minRole);
|
||||
}
|
||||
|
||||
// Domain names the identity can at least view (excludes the global-role case,
|
||||
// which callers treat as "sees everything").
|
||||
function visibleDomains(effective){
|
||||
return Object.keys(effective.domains).filter(d => rank(effective.domains[d]) >= rank('viewer'));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROLE_RANK,
|
||||
rank,
|
||||
maxRole,
|
||||
resolveEffective,
|
||||
roleForDomain,
|
||||
allows,
|
||||
visibleDomains,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Constrain a post-login redirect target to a same-origin path.
|
||||
*
|
||||
* Rejects anything that could leave the site or execute script:
|
||||
* - absolute URLs ("https://evil.com") -> not a "/" path
|
||||
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
|
||||
* - scheme targets ("javascript:...", "data:...") -> XSS
|
||||
* Anything not a plain "/path" falls back to "/".
|
||||
*
|
||||
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
|
||||
* two in sync.
|
||||
*/
|
||||
function safeInternalPath(path){
|
||||
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||
return '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
module.exports = {safeInternalPath};
|
||||
@@ -0,0 +1,138 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
// Require login to see this page. The API is admin-only; non-admins get 403s.
|
||||
app.auth.forceLogin();
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
label.control-label{
|
||||
font-weight: bold;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.card-title{
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
function populateGrants(){
|
||||
app.grant.list(function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
|
||||
for(let grant of data.results){
|
||||
$.scope.grants.push(grant);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeGrant(id){
|
||||
app.grant.remove(id, function(error, data){
|
||||
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
|
||||
$.scope.grants.remove(id);
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
populateGrants();
|
||||
|
||||
$.scope.grants.__setTake(function($el, item, list){
|
||||
$el.addClass('bg-danger');
|
||||
$el.fadeOut(1000, function(){ $el.remove(); });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-lg">
|
||||
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
</span>
|
||||
<span class="card-title">Add Grant</span>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="grant/" onsubmit="formAJAX(this)" evalAJAX="
|
||||
$.scope.grants.remove(data.id);
|
||||
$.scope.grants.splice(0, 0, data);
|
||||
">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Subject type</label>
|
||||
<select class="form-control" name="subjectType">
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Subject (username or group)</label>
|
||||
<input type="text" class="form-control" name="subject" placeholder="alice or dns-team" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Scope</label>
|
||||
<select class="form-control" name="scope">
|
||||
<option value="domain">Domain</option>
|
||||
<option value="global">Global</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Domain (for domain scope)</label>
|
||||
<input type="text" class="form-control" name="domain" placeholder="example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">Role</label>
|
||||
<select class="form-control" name="role">
|
||||
<option value="viewer">Viewer (read)</option>
|
||||
<option value="manager">Manager (full over domain)</option>
|
||||
<option value="admin">Admin (global only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<hr />
|
||||
<button type="submit" class="btn btn-info">Add Grant</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg">
|
||||
|
||||
<div class="card-header text-center">
|
||||
<span class="card-icon float-start">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</span>
|
||||
<span class="card-title">Grants</span>
|
||||
</div>
|
||||
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Type</th>
|
||||
<th>Subject</th>
|
||||
<th>Scope</th>
|
||||
<th>Domain</th>
|
||||
<th>Role</th>
|
||||
<th>Delete</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="grants" jq-repeat-index="id" style="display:none">
|
||||
<td class="align-middle">{{ subjectType }}</td>
|
||||
<td class="align-middle">{{ subject }}</td>
|
||||
<td class="align-middle">{{ scope }}</td>
|
||||
<td class="align-middle">{{ domain }}</td>
|
||||
<td class="align-middle">{{ role }}</td>
|
||||
<td class="align-middle">
|
||||
<button type="button" class="btn btn-danger" onclick="removeGrant('{{id}}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
+20
-6
@@ -1,15 +1,22 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(isLoggedIn){
|
||||
app.auth.logInRedirect();
|
||||
}
|
||||
})
|
||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||
// store it and forward on before doing anything else.
|
||||
if(!app.auth.consumeTokenFragment()){
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(isLoggedIn){
|
||||
app.auth.logInRedirect();
|
||||
}else{
|
||||
// Reveal the login card once we know the user is not logged in.
|
||||
document.getElementById('login-card-row').style.display = '';
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="row" style="display: none;">
|
||||
<div id="login-card-row" class="row" style="display: none;">
|
||||
<div class="col-md-4">
|
||||
<div class="shadow-lg card">
|
||||
|
||||
@@ -74,6 +81,13 @@
|
||||
<hr />
|
||||
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
<div class="d-grid">
|
||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-2
@@ -51,11 +51,16 @@
|
||||
DNS
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<li class="nav-item nav-admin" style="display: none;">
|
||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item nav-admin" style="display: none;">
|
||||
<a class="nav-link" href="/grants"><i class="fa-solid fa-user-shield"></i>
|
||||
Grants
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/theta42/proxy" target="_blank">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
@@ -87,10 +92,13 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Set the correct login/logout button
|
||||
// Set the correct login/logout button, and reveal admin-only nav
|
||||
// items for global admins.
|
||||
app.auth.isLoggedIn(function(error, data){
|
||||
if(data) $('#cl-logout-button').show();
|
||||
else $('#cl-login-button').show();
|
||||
|
||||
if(data && data.isAdmin) $('.nav-admin').css('display', '');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -13,7 +13,14 @@ print("In targetInfo module")
|
||||
|
||||
-- Main function of the module
|
||||
function M.get(ngx, domain, targetInfo)
|
||||
if targetInfo then
|
||||
-- Reuse a previously-resolved target ONLY when it was resolved for this
|
||||
-- exact host. HTTP/2 connection coalescing lets a browser serve several
|
||||
-- hostnames that share one wildcard cert (e.g. *.718it.biz) over a single
|
||||
-- connection; the SSL phase (request_domain) resolves and caches the
|
||||
-- connection's first host in ngx.ctx.targetInfo. Without the domain check
|
||||
-- below, every coalesced request on that connection would be handed the
|
||||
-- first host's target -- e.g. hassio.718it.biz served from metrics.718it.biz.
|
||||
if targetInfo and ngx.ctx.targetInfo_domain == domain then
|
||||
return targetInfo
|
||||
end
|
||||
|
||||
@@ -68,6 +75,10 @@ function M.get(ngx, domain, targetInfo)
|
||||
end
|
||||
|
||||
ngx.ctx.targetInfo = res
|
||||
-- Remember which host this target was resolved for, so the reuse guard at
|
||||
-- the top can tell a genuine cache hit from a coalesced request for a
|
||||
-- different host on the same connection.
|
||||
ngx.ctx.targetInfo_domain = domain
|
||||
ngx.ctx.toAllow = true
|
||||
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user