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:
@@ -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'});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user