Per-host HTTP basic auth (#57)
Adds opt-in basic auth per Host, following the existing per-host controls
pattern:
- Host fields basicauth_enabled / basicauth_realm / basicauth_users
({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure
host_features normalizer and hashed at the route layer (utils/basicauth.js),
so plaintext never reaches Redis.
- ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the
Authorization header against base64(sha1(password)), fails closed with a 401
WWW-Authenticate challenge.
- hosts.ejs gains an enable toggle, realm, and a username:password textarea
(passwords never echoed back; blank keeps the current set).
Unit tests cover hashing (matches the htpasswd {SHA} vector), credential
parsing, and normalization. Note: the Lua path needs verification on a live
OpenResty box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,12 @@ class Host extends Table{
|
|||||||
'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000},
|
'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000},
|
||||||
'respcache_enabled': {default: false, isRequired: false, type: 'boolean',},
|
'respcache_enabled': {default: false, isRequired: false, type: 'boolean',},
|
||||||
'hsts_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',},
|
||||||
'req_headers': {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',},
|
'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
|
||||||
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
|
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
|
||||||
|
|||||||
+3
-3
@@ -11,10 +11,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./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/password_policy.test.js test/unit/unix_socket.test.js test/integration/dns_provider.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/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/unix_socket.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/unix_socket.test.js",
|
||||||
"test:integration": "node --test test/integration/dns_provider.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/password_policy.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/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const {Host, Domain} = require('../models').models;
|
|||||||
const authz = require('../middleware/authz');
|
const authz = require('../middleware/authz');
|
||||||
const {normalizeHostFeatures} = require('../utils/host_features');
|
const {normalizeHostFeatures} = require('../utils/host_features');
|
||||||
const {collectHostFieldErrors} = require('../utils/hostname_validate');
|
const {collectHostFieldErrors} = require('../utils/hostname_validate');
|
||||||
|
const {hashBasicAuthUsers} = require('../utils/basicauth');
|
||||||
|
|
||||||
const Model = Host;
|
const Model = Host;
|
||||||
|
|
||||||
@@ -15,6 +16,15 @@ function validateHostFields(body){
|
|||||||
if(errors.length) throw Model.errors.ObjectValidateError(errors);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async function(req, res, next){
|
router.get('/', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let results = await Model[req.query.detail ? "listDetail" : "list"]();
|
let results = await Model[req.query.detail ? "listDetail" : "list"]();
|
||||||
@@ -35,6 +45,7 @@ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), asy
|
|||||||
req.body.created_by = authz.reqUsername(req);
|
req.body.created_by = authz.reqUsername(req);
|
||||||
validateHostFields(req.body);
|
validateHostFields(req.body);
|
||||||
normalizeHostFeatures(req.body);
|
normalizeHostFeatures(req.body);
|
||||||
|
hashHostSecrets(req.body);
|
||||||
let item = await Model.create(req.body);
|
let item = await Model.create(req.body);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -100,6 +111,7 @@ router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam)
|
|||||||
req.body.updated_by = authz.reqUsername(req);
|
req.body.updated_by = authz.reqUsername(req);
|
||||||
validateHostFields(req.body);
|
validateHostFields(req.body);
|
||||||
normalizeHostFeatures(req.body);
|
normalizeHostFeatures(req.body);
|
||||||
|
hashHostSecrets(req.body);
|
||||||
let item = await Model.get(req.params.item);
|
let item = await Model.get(req.params.item);
|
||||||
item = await item.update(req.body);
|
item = await item.update(req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {describe, test} = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const {hashPassword, hashBasicAuthUsers} = require('../../utils/basicauth');
|
||||||
|
const {
|
||||||
|
parseBasicAuthLines,
|
||||||
|
sanitizeBasicAuthObject,
|
||||||
|
sanitizeRealm,
|
||||||
|
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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-only hashing for per-host basic-auth credentials. Kept out of the pure,
|
||||||
|
* browser-mirrored utils/host_features.js because it needs Node crypto.
|
||||||
|
*
|
||||||
|
* Passwords are stored as base64(SHA-1(password)) — the Apache htpasswd "{SHA}"
|
||||||
|
* scheme — so plaintext never lands in Redis. OpenResty verifies with the same
|
||||||
|
* hash (ops/nginx_conf/hostfeatures.lua): base64(sha1(password)).
|
||||||
|
*
|
||||||
|
* SHA-1 is weak for password storage in general, but this is a lightweight proxy
|
||||||
|
* gate (not the app's own accounts) and matches htpasswd; upgrading the scheme is
|
||||||
|
* a follow-up. Enforce strong passwords operationally.
|
||||||
|
*/
|
||||||
|
function hashPassword(password){
|
||||||
|
return crypto.createHash('sha1').update(String(password)).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// { username: plaintext } -> { username: base64sha1 }. Skips empty passwords.
|
||||||
|
function hashBasicAuthUsers(users){
|
||||||
|
let out = {};
|
||||||
|
if(!users || typeof users !== 'object') return out;
|
||||||
|
for(let user of Object.keys(users)){
|
||||||
|
let pass = users[user];
|
||||||
|
if(pass === undefined || pass === null || pass === '') continue;
|
||||||
|
out[user] = hashPassword(pass);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {hashPassword, hashBasicAuthUsers};
|
||||||
@@ -11,6 +11,13 @@
|
|||||||
const MAX_HEADERS = 50; // per direction (req/resp)
|
const MAX_HEADERS = 50; // per direction (req/resp)
|
||||||
const MAX_HEADER_VALUE = 2048; // chars
|
const MAX_HEADER_VALUE = 2048; // chars
|
||||||
const MAX_CIDRS = 200; // per list (allow/deny)
|
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.
|
// RFC 7230 header field-name token characters.
|
||||||
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||||
@@ -125,6 +132,56 @@ function stringifyCidrs(arr){
|
|||||||
return arr.join('\n');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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){
|
function toBool(v){
|
||||||
return v === true || v === 'true';
|
return v === true || v === 'true';
|
||||||
}
|
}
|
||||||
@@ -151,6 +208,23 @@ function normalizeHostFeatures(body){
|
|||||||
if('ratelimit_enabled' in body) body.ratelimit_enabled = toBool(body.ratelimit_enabled);
|
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('respcache_enabled' in body) body.respcache_enabled = toBool(body.respcache_enabled);
|
||||||
if('hsts_enabled' in body) body.hsts_enabled = toBool(body.hsts_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('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
||||||
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
||||||
@@ -181,8 +255,9 @@ function normalizeHostFeatures(body){
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS,
|
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS, MAX_BASICAUTH_USERS,
|
||||||
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
|
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
|
||||||
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
|
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
|
||||||
|
parseBasicAuthLines, sanitizeBasicAuthObject, sanitizeRealm,
|
||||||
normalizeHostFeatures,
|
normalizeHostFeatures,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,6 +127,12 @@
|
|||||||
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
|
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
|
||||||
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
|
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
|
||||||
|
|
||||||
|
// Never echo basic-auth passwords back to the form; show the current
|
||||||
|
// usernames as a hint and leave the textarea blank (blank = keep).
|
||||||
|
$(".hostEditPanel textarea[name='basicauth_users']").val('');
|
||||||
|
$(".hostEditPanel .basicauth-current").text(
|
||||||
|
Object.keys(host.basicauth_users || {}).join(', ') || 'none');
|
||||||
|
|
||||||
$('.hostEditPanel').scrollTo();
|
$('.hostEditPanel').scrollTo();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -532,6 +538,37 @@
|
|||||||
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
|
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Basic authentication</label>
|
||||||
|
<div class="radio">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-false" value="false" checked>
|
||||||
|
Off
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="radio">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-true" value="true">
|
||||||
|
Require username / password
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="basicauth_realm" class="form-label">Realm</label>
|
||||||
|
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="basicauth_users" class="form-label">Users</label>
|
||||||
|
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
|
||||||
|
<small class="text-muted">
|
||||||
|
Current: <span class="basicauth-current">none</span>.
|
||||||
|
Passwords are stored hashed and never shown here. Leave blank to
|
||||||
|
keep the current users; entering any lines replaces the whole list.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="buttonBreak" />
|
<hr class="buttonBreak" />
|
||||||
<button type="submit" class="btn btn-success">
|
<button type="submit" class="btn btn-success">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
-- req_headers (JSON object) -- added to the upstream request
|
-- req_headers (JSON object) -- added to the upstream request
|
||||||
-- resp_headers (JSON object) -- added to the client response
|
-- resp_headers (JSON object) -- added to the client response
|
||||||
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
||||||
|
-- basicauth_enabled / basicauth_realm
|
||||||
|
-- basicauth_users (JSON object {username: base64(sha1(password))})
|
||||||
|
|
||||||
local cjson = require "cjson.safe"
|
local cjson = require "cjson.safe"
|
||||||
|
|
||||||
@@ -80,6 +82,47 @@ local function apply_ratelimit(res, host, ip)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Per-host HTTP basic auth. Credentials are stored as {user: base64(sha1(pw))}
|
||||||
|
-- (htpasswd "{SHA}" scheme; hashed server-side in nodejs). Fails closed: any
|
||||||
|
-- misconfig or bad credential returns 401 with a WWW-Authenticate challenge.
|
||||||
|
local function apply_basicauth(res)
|
||||||
|
if res["basicauth_enabled"] ~= "true" then return end
|
||||||
|
|
||||||
|
local realm = res["basicauth_realm"]
|
||||||
|
if not realm or realm == "" then realm = "Restricted" end
|
||||||
|
realm = realm:gsub('[\r\n"]', "") -- defense in depth for the header
|
||||||
|
|
||||||
|
local function deny()
|
||||||
|
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
|
||||||
|
return ngx.exit(401)
|
||||||
|
end
|
||||||
|
|
||||||
|
local users = decode_table(res["basicauth_users"])
|
||||||
|
if not users then return deny() end -- enabled but no users -> deny all
|
||||||
|
|
||||||
|
local header = ngx.var.http_authorization
|
||||||
|
if not header then return deny() end
|
||||||
|
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
|
||||||
|
if not b64 then return deny() end
|
||||||
|
|
||||||
|
local decoded = ngx.decode_base64(b64)
|
||||||
|
if not decoded then return deny() end
|
||||||
|
local user, pass = decoded:match("^([^:]*):(.*)$")
|
||||||
|
if not user or user == "" then return deny() end
|
||||||
|
|
||||||
|
local stored = users[user]
|
||||||
|
if not stored then return deny() end
|
||||||
|
|
||||||
|
local sha1 = require "resty.sha1"
|
||||||
|
local hasher = sha1:new()
|
||||||
|
if not hasher then return deny() end
|
||||||
|
hasher:update(pass or "")
|
||||||
|
local computed = ngx.encode_base64(hasher:final())
|
||||||
|
|
||||||
|
if computed ~= stored then return deny() end
|
||||||
|
-- authenticated: fall through to the rest of the request
|
||||||
|
end
|
||||||
|
|
||||||
-- Extra request headers sent to the upstream.
|
-- Extra request headers sent to the upstream.
|
||||||
local function apply_req_headers(res)
|
local function apply_req_headers(res)
|
||||||
local headers = decode_table(res["req_headers"])
|
local headers = decode_table(res["req_headers"])
|
||||||
@@ -97,6 +140,7 @@ function M.access(ngx_, res)
|
|||||||
|
|
||||||
apply_ip_access(res, ip)
|
apply_ip_access(res, ip)
|
||||||
apply_ratelimit(res, host, ip)
|
apply_ratelimit(res, host, ip)
|
||||||
|
apply_basicauth(res)
|
||||||
apply_req_headers(res)
|
apply_req_headers(res)
|
||||||
|
|
||||||
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
||||||
|
|||||||
Reference in New Issue
Block a user