Merge pull request #360 from Indemnity83/ip-access-control

Client Access Lists
This commit is contained in:
jc21 2020-04-15 08:29:40 +10:00 committed by GitHub
commit a9f068daa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 440 additions and 72 deletions

View File

@ -5,6 +5,7 @@ const logger = require('../logger').access;
const error = require('../lib/error');
const accessListModel = require('../models/access_list');
const accessListAuthModel = require('../models/access_list_auth');
const accessListClientModel = require('../models/access_list_client');
const proxyHostModel = require('../models/proxy_host');
const internalAuditLog = require('./audit-log');
const internalNginx = require('./nginx');
@ -29,14 +30,16 @@ const internalAccessList = {
.omit(omissions())
.insertAndFetch({
name: data.name,
satify_any: data.satify_any,
owner_user_id: access.token.getUserId(1)
});
})
.then((row) => {
data.id = row.id;
// Now add the items
let promises = [];
// Now add the items
data.items.map((item) => {
promises.push(accessListAuthModel
.query()
@ -48,13 +51,27 @@ const internalAccessList = {
);
});
// Now add the clients
if (typeof data.clients !== 'undefined' && data.clients) {
data.clients.map((client) => {
promises.push(accessListClientModel
.query()
.insert({
access_list_id: row.id,
address: client.address,
directive: client.directive
})
);
});
}
return Promise.all(promises);
})
.then(() => {
// re-fetch with expansions
return internalAccessList.get(access, {
id: data.id,
expand: ['owner', 'items']
expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.clients']
}, true /* <- skip masking */);
})
.then((row) => {
@ -64,7 +81,7 @@ const internalAccessList = {
return internalAccessList.build(row)
.then(() => {
if (row.proxy_host_count) {
return internalNginx.reload();
return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
}
})
.then(() => {
@ -109,7 +126,8 @@ const internalAccessList = {
.query()
.where({id: data.id})
.patch({
name: data.name
name: data.name,
satify_any: data.satify_any,
});
}
})
@ -153,6 +171,38 @@ const internalAccessList = {
});
}
})
.then(() => {
// Check for clients and add/update/remove them
if (typeof data.clients !== 'undefined' && data.clients) {
let promises = [];
data.clients.map(function (client) {
if (client.address) {
promises.push(accessListClientModel
.query()
.insert({
access_list_id: data.id,
address: client.address,
directive: client.directive
})
);
}
});
let query = accessListClientModel
.query()
.delete()
.where('access_list_id', data.id);
return query
.then(() => {
// Add new items
if (promises.length) {
return Promise.all(promises);
}
});
}
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
@ -166,14 +216,14 @@ const internalAccessList = {
// re-fetch with expansions
return internalAccessList.get(access, {
id: data.id,
expand: ['owner', 'items']
expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.clients']
}, true /* <- skip masking */);
})
.then((row) => {
return internalAccessList.build(row)
.then(() => {
if (row.proxy_host_count) {
return internalNginx.reload();
return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
}
})
.then(() => {
@ -204,7 +254,7 @@ const internalAccessList = {
.joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0')
.where('access_list.is_deleted', 0)
.andWhere('access_list.id', data.id)
.allowEager('[owner,items,proxy_hosts]')
.allowEager('[owner,items,clients,proxy_hosts,proxy_hosts.access_list.clients]')
.omit(['access_list.is_deleted'])
.first();
@ -246,7 +296,7 @@ const internalAccessList = {
delete: (access, data) => {
return access.can('access_lists:delete', data.id)
.then(() => {
return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items']});
return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items', 'clients']});
})
.then((row) => {
if (!row) {
@ -330,7 +380,7 @@ const internalAccessList = {
.where('access_list.is_deleted', 0)
.groupBy('access_list.id')
.omit(['access_list.is_deleted'])
.allowEager('[owner,items]')
.allowEager('[owner,items,clients]')
.orderBy('access_list.name', 'ASC');
if (access_data.permission_visibility !== 'all') {

View File

@ -73,7 +73,7 @@ const internalProxyHost = {
// re-fetch with cert
return internalProxyHost.get(access, {
id: row.id,
expand: ['certificate', 'owner', 'access_list']
expand: ['certificate', 'owner', 'access_list.clients']
});
})
.then((row) => {
@ -186,7 +186,7 @@ const internalProxyHost = {
.then(() => {
return internalProxyHost.get(access, {
id: data.id,
expand: ['owner', 'certificate', 'access_list']
expand: ['owner', 'certificate', 'access_list.clients']
})
.then((row) => {
// Configure nginx
@ -219,7 +219,7 @@ const internalProxyHost = {
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowEager('[owner,access_list,certificate]')
.allowEager('[owner,access_list,access_list.clients,certificate]')
.first();
if (access_data.permission_visibility !== 'all') {

View File

@ -0,0 +1,53 @@
const migrate_name = 'access_list_client';
const logger = require('../logger').migrate;
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.up = function (knex/*, Promise*/) {
logger.info('[' + migrate_name + '] Migrating Up...');
return knex.schema.createTable('access_list_client', (table) => {
table.increments().primary();
table.dateTime('created_on').notNull();
table.dateTime('modified_on').notNull();
table.integer('access_list_id').notNull().unsigned();
table.string('address').notNull();
table.string('directive').notNull();
table.json('meta').notNull();
})
.then(function () {
logger.info('[' + migrate_name + '] access_list_client Table created');
return knex.schema.table('access_list', function (access_list) {
access_list.integer('satify_any').notNull().defaultTo(0);
});
})
.then(() => {
logger.info('[' + migrate_name + '] access_list Table altered');
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.down = function (knex/*, Promise*/) {
logger.info('[' + migrate_name + '] Migrating Down...');
return knex.schema.dropTable('access_list_client')
.then(() => {
logger.info('[' + migrate_name + '] access_list_client Table dropped');
});
};

View File

@ -5,6 +5,7 @@ const db = require('../db');
const Model = require('objection').Model;
const User = require('./user');
const AccessListAuth = require('./access_list_auth');
const AccessListClient = require('./access_list_client');
Model.knex(db);
@ -62,6 +63,17 @@ class AccessList extends Model {
qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']);
}
},
clients: {
relation: Model.HasManyRelation,
modelClass: AccessListClient,
join: {
from: 'access_list.id',
to: 'access_list_client.access_list_id'
},
modify: function (qb) {
qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']);
}
},
proxy_hosts: {
relation: Model.HasManyRelation,
modelClass: ProxyHost,
@ -76,6 +88,10 @@ class AccessList extends Model {
}
};
}
get satisfy() {
return this.satify_any ? 'satisfy any' : 'satisfy all';
}
}
module.exports = AccessList;

View File

@ -0,0 +1,58 @@
// Objection Docs:
// http://vincit.github.io/objection.js/
const db = require('../db');
const Model = require('objection').Model;
Model.knex(db);
class AccessListClient extends Model {
$beforeInsert () {
this.created_on = Model.raw('NOW()');
this.modified_on = Model.raw('NOW()');
// Default for meta
if (typeof this.meta === 'undefined') {
this.meta = {};
}
}
$beforeUpdate () {
this.modified_on = Model.raw('NOW()');
}
static get name () {
return 'AccessListClient';
}
static get tableName () {
return 'access_list_client';
}
static get jsonAttributes () {
return ['meta'];
}
static get relationMappings () {
return {
access_list: {
relation: Model.HasOneRelation,
modelClass: require('./access_list'),
join: {
from: 'access_list_client.access_list_id',
to: 'access_list.id'
},
modify: function (qb) {
qb.where('access_list.is_deleted', 0);
qb.omit(['created_on', 'modified_on', 'is_deleted', 'access_list_id']);
}
}
};
}
get rule() {
return `${this.directive} ${this.address}`;
}
}
module.exports = AccessListClient;

View File

@ -19,6 +19,29 @@
"type": "string",
"description": "Name of the Access List"
},
"directive": {
"type": "string",
"enum": ["allow", "deny"]
},
"address": {
"oneOf": [
{
"type": "string",
"pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$"
},
{
"type": "string",
"pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"
},
{
"type": "string",
"pattern": "^any$"
}
]
},
"satify_any": {
"type": "boolean"
},
"meta": {
"type": "object"
}
@ -78,9 +101,12 @@
"name": {
"$ref": "#/definitions/name"
},
"satify_any": {
"$ref": "#/definitions/satify_any"
},
"items": {
"type": "array",
"minItems": 1,
"minItems": 0,
"items": {
"type": "object",
"additionalProperties": false,
@ -96,6 +122,22 @@
}
}
},
"clients": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"address": {
"$ref": "#/definitions/address"
},
"directive": {
"$ref": "#/definitions/directive"
}
}
}
},
"meta": {
"$ref": "#/definitions/meta"
}
@ -124,9 +166,12 @@
"name": {
"$ref": "#/definitions/name"
},
"satify_any": {
"$ref": "#/definitions/satify_any"
},
"items": {
"type": "array",
"minItems": 1,
"minItems": 0,
"items": {
"type": "object",
"additionalProperties": false,
@ -141,6 +186,22 @@
}
}
}
},
"clients": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"address": {
"$ref": "#/definitions/address"
},
"directive": {
"$ref": "#/definitions/directive"
}
}
}
}
}
},

View File

@ -21,11 +21,21 @@ server {
{% if use_default_location %}
location / {
{%- if access_list_id > 0 -%}
# Access List
{% if access_list_id > 0 %}
# Authorization
auth_basic "Authorization required";
auth_basic_user_file /data/access/{{ access_list_id }};
{%- endif %}
# Access Rules
{% for client in access_list.clients %}
{{- client.rule -}};
{% endfor %}deny all;
# Access checks must...
{{ access_list.satisfy }};
{% endif %}
{% include "_forced_ssl.conf" %}
{% include "_hsts.conf" %}

View File

@ -3,8 +3,17 @@
<h5 class="modal-title"><%- i18n('access-lists', 'form-title', {id: id}) %></h5>
<button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
</div>
<div class="modal-body">
<div class="modal-body has-tabs">
<form>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active show" aria-selected="true"><i class="fe fe-zap"></i> <%- i18n('access-lists', 'details') %></a></li>
<li role="presentation" class="nav-item"><a href="#auth" aria-controls="tab4" role="tab" data-toggle="tab" class="nav-link" aria-selected="false"><i class="fe fe-users"></i> <%- i18n('access-lists', 'authorization') %></a></li>
<li role="presentation" class="nav-item"><a href="#access" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link" aria-selected="false"><i class="fe fe-radio"></i> <%- i18n('access-lists', 'access') %></a></li>
</ul>
<div class="tab-content">
<!-- Details -->
<div role="tabpanel" class="tab-pane active show" id="details">
<div class="row">
<div class="col-sm-12 col-md-12">
<div class="form-group">
@ -12,6 +21,22 @@
<input type="text" name="name" class="form-control" value="<%- name %>" required>
</div>
</div>
<div class="col-sm-6 col-md-6">
<div class="form-group">
<label class="custom-switch">
<input type="checkbox" class="custom-switch-input" name="satify_any" value="1"<%- typeof satify_any !== 'undefined' && satify_any ? ' checked' : '' %>>
<span class="custom-switch-indicator"></span>
<span class="custom-switch-description"><%- i18n('access-lists', 'satisfy-any') %></span>
</label>
</div>
</div>
</div>
</div>
<!-- Authorization -->
<div class="tab-pane" id="auth">
<div class="row">
<div class="col-sm-6 col-md-6">
<div class="form-group">
<label class="form-label"><%- i18n('str', 'username') %></label>
@ -25,6 +50,27 @@
</div>
<div class="items"><!-- items --></div>
</div>
<!-- Access -->
<div class="tab-pane" id="access">
<div class="clients"><!-- clients --></div>
<div class="row">
<div class="col-sm-3 col-md-3">
<div class="form-group">
<input type="text" class="form-control disabled" value="deny" disabled>
</div>
</div>
<div class="col-sm-9 col-md-9">
<div class="form-group">
<input type="text" class="form-control disabled" value="all" disabled>
</div>
</div>
</div>
<div class="text-muted">Note that the <code>allow</code> and <code>deny</code> directives will be applied in the order they are defined.</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">

View File

@ -3,6 +3,7 @@ const App = require('../../main');
const AccessListModel = require('../../../models/access-list');
const template = require('./form.ejs');
const ItemView = require('./form/item');
const ClientView = require('./form/client');
require('jquery-serializejson');
@ -10,12 +11,17 @@ const ItemsView = Mn.CollectionView.extend({
childView: ItemView
});
const ClientsView = Mn.CollectionView.extend({
childView: ClientView
});
module.exports = Mn.View.extend({
template: template,
className: 'modal-dialog',
ui: {
items_region: '.items',
clients_region: '.clients',
form: 'form',
buttons: '.modal-footer button',
cancel: 'button.cancel',
@ -23,7 +29,8 @@ module.exports = Mn.View.extend({
},
regions: {
items_region: '@ui.items_region'
items_region: '@ui.items_region',
clients_region: '@ui.clients_region'
},
events: {
@ -38,6 +45,7 @@ module.exports = Mn.View.extend({
let view = this;
let form_data = this.ui.form.serializeJSON();
let items_data = [];
let clients_data = [];
form_data.username.map(function (val, idx) {
if (val.trim().length) {
@ -48,16 +56,29 @@ module.exports = Mn.View.extend({
}
});
if (!items_data.length) {
alert('You must specify at least 1 Username and Password combination');
form_data.address.map(function (val, idx) {
if (val.trim().length) {
clients_data.push({
address: val.trim(),
directive: form_data.directive[idx]
})
}
});
if (!items_data.length && !clients_data.length) {
alert('You must specify at least 1 Authorization or Access rule');
return;
}
let data = {
name: form_data.name,
items: items_data
satify_any: !!form_data.satify_any,
items: items_data,
clients: clients_data
};
console.log(data);
let method = App.Api.Nginx.AccessLists.create;
let is_new = true;
@ -88,6 +109,7 @@ module.exports = Mn.View.extend({
onRender: function () {
let items = this.model.get('items');
let clients = this.model.get('clients');
// Add empty items to the end of the list. This is cheating but hey I don't have the time to do it right
let items_to_add = 5 - items.length;
@ -97,9 +119,20 @@ module.exports = Mn.View.extend({
}
}
let clients_to_add = 4 - clients.length;
if (clients_to_add) {
for (let i = 0; i < clients_to_add; i++) {
clients.push({});
}
}
this.showChildView('items_region', new ItemsView({
collection: new Backbone.Collection(items)
}));
this.showChildView('clients_region', new ClientsView({
collection: new Backbone.Collection(clients)
}));
},
initialize: function (options) {

View File

@ -0,0 +1,13 @@
<div class="col-sm-3 col-md-3">
<div class="form-group">
<select name="directive[]" class="form-control custom-select" placeholder="http">
<option value="allow" <%- typeof directive == 'undefined' || directive === 'allow' ? 'selected' : '' %>>allow</option>
<option value="deny" <%- typeof directive !== 'undefined' && directive === 'deny' ? 'selected' : '' %>>deny</option>
</select>
</div>
</div>
<div class="col-sm-9 col-md-9">
<div class="form-group">
<input type="text" name="address[]" class="form-control" value="<%- typeof address !== 'undefined' ? address : '' %>" value="">
</div>
</div>

View File

@ -0,0 +1,7 @@
const Mn = require('backbone.marionette');
const template = require('./client.ejs');
module.exports = Mn.View.extend({
template: template,
className: 'row'
});

View File

@ -14,6 +14,16 @@
<td>
<%- i18n('access-lists', 'item-count', {count: items.length || 0}) %>
</td>
<td>
<%- i18n('access-lists', 'client-count', {count: clients.length || 0}) %>
</td>
<td>
<% if (satify_any) { %>
<%- i18n('str', 'any') %>
<%} else { %>
<%- i18n('str', 'all') %>
<% } %>
</td>
<td>
<%- i18n('access-lists', 'proxy-host-count', {count: proxy_host_count}) %>
</td>

View File

@ -1,7 +1,9 @@
<thead>
<th width="30">&nbsp;</th>
<th><%- i18n('str', 'name') %></th>
<th><%- i18n('users', 'title') %></th>
<th><%- i18n('access-lists', 'authorization') %></th>
<th><%- i18n('access-lists', 'access') %></th>
<th><%- i18n('access-lists', 'satisfy') %></th>
<th><%- i18n('proxy-hosts', 'title') %></th>
<% if (canManage) { %>
<th>&nbsp;</th>

View File

@ -40,7 +40,7 @@ module.exports = Mn.View.extend({
onRender: function () {
let view = this;
App.Api.Nginx.AccessLists.getAll(['owner', 'items'])
App.Api.Nginx.AccessLists.getAll(['owner', 'items', 'clients'])
.then(response => {
if (!view.isDestroyed()) {
if (response && response.length) {

View File

@ -3,7 +3,7 @@
<div class="title">
<i class="fe fe-lock text-teal"></i> <%- name %>
</div>
<span class="description"><%- i18n('access-lists', 'item-count', {count: items.length || 0}) %> &ndash; Created: <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %></span>
<span class="description"><%- i18n('access-lists', 'item-count', {count: items.length || 0}) %>, <%- i18n('access-lists', 'client-count', {count: clients.length || 0}) %> &ndash; Created: <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %></span>
<% } else { %>
<div class="title">
<i class="fe fe-unlock text-yellow"></i> <%- i18n('access-lists', 'public') %>

View File

@ -222,7 +222,7 @@ module.exports = Mn.View.extend({
}
},
load: function (query, callback) {
App.Api.Nginx.AccessLists.getAll(['items'])
App.Api.Nginx.AccessLists.getAll(['items', 'clients'])
.then(rows => {
callback(rows);
})

View File

@ -33,7 +33,9 @@
"unknown": "Unknown",
"expires": "Expires",
"value": "Value",
"please-wait": "Please wait..."
"please-wait": "Please wait...",
"all": "All",
"any": "Any"
},
"login": {
"title": "Login to your account"
@ -184,10 +186,16 @@
"public": "Publicly Accessible",
"public-sub": "No Access Restrictions",
"help-title": "What is an Access List?",
"help-content": "Access Lists provide authentication for the Proxy Hosts via Basic HTTP Authentication.\nYou can configure multiple usernames and passwords for a single Access List and then apply that to a Proxy Host.\nThis is most useful for forwarded web services that do not have authentication mechanisms built in.",
"help-content": "Access Lists provide a blacklist or whitelist of specific client IP addresses along with authentication for the Proxy Hosts via Basic HTTP Authentication.\nYou can configure multiple client rules, usernames and passwords for a single Access List and then apply that to a Proxy Host.\nThis is most useful for forwarded web services that do not have authentication mechanisms built in or that you want to protect from access by unknown clients.",
"item-count": "{count} {count, select, 1{User} other{Users}}",
"client-count": "{count} {count, select, 1{Rule} other{Rules}}",
"proxy-host-count": "{count} {count, select, 1{Proxy Host} other{Proxy Hosts}}",
"delete-has-hosts": "This Access List is associated with {count} Proxy Hosts. They will become publicly available upon deletion."
"delete-has-hosts": "This Access List is associated with {count} Proxy Hosts. They will become publicly available upon deletion.",
"details": "Details",
"authorization": "Authorization",
"access": "Access",
"satisfy": "Satisfy",
"satisfy-any": "Satify Any"
},
"users": {
"title": "Users",

View File

@ -10,6 +10,7 @@ const model = Backbone.Model.extend({
modified_on: null,
name: '',
items: [],
clients: [],
// The following are expansions:
owner: null
};