diff --git a/backend/migrations/20200522113248_openid_connect.js b/backend/migrations/20200522113248_openid_connect.js new file mode 100644 index 00000000..6054e1a1 --- /dev/null +++ b/backend/migrations/20200522113248_openid_connect.js @@ -0,0 +1,48 @@ +const migrate_name = 'openid_connect'; +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.table('proxy_host', function (proxy_host) { + proxy_host.integer('openidc_enabled').notNull().unsigned().defaultTo(0); + proxy_host.text('openidc_redirect_uri').notNull().defaultTo(''); + proxy_host.text('openidc_discovery').notNull().defaultTo(''); + proxy_host.text('openidc_auth_method').notNull().defaultTo(''); + proxy_host.text('openidc_client_id').notNull().defaultTo(''); + proxy_host.text('openidc_client_secret').notNull().defaultTo(''); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex/*, Promise*/) { + return knex.schema.table('proxy_host', function (proxy_host) { + proxy_host.dropColumn('openidc_enabled'); + proxy_host.dropColumn('openidc_redirect_uri'); + proxy_host.dropColumn('openidc_discovery'); + proxy_host.dropColumn('openidc_auth_method'); + proxy_host.dropColumn('openidc_client_id'); + proxy_host.dropColumn('openidc_client_secret'); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); +}; diff --git a/backend/migrations/20200522144240_openid_allowed_users.js b/backend/migrations/20200522144240_openid_allowed_users.js new file mode 100644 index 00000000..9b4e0aad --- /dev/null +++ b/backend/migrations/20200522144240_openid_allowed_users.js @@ -0,0 +1,40 @@ +const migrate_name = 'openid_allowed_users'; +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.table('proxy_host', function (proxy_host) { + proxy_host.integer('openidc_restrict_users_enabled').notNull().unsigned().defaultTo(0); + proxy_host.json('openidc_allowed_users').notNull().defaultTo([]); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex/*, Promise*/) { + return knex.schema.table('proxy_host', function (proxy_host) { + proxy_host.dropColumn('openidc_restrict_users_enabled'); + proxy_host.dropColumn('openidc_allowed_users'); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + }); +}; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index a7583088..30d4c73b 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -20,12 +20,23 @@ class ProxyHost extends Model { this.domain_names = []; } + // Default for openidc_allowed_users + if (typeof this.openidc_allowed_users === 'undefined') { + this.openidc_allowed_users = []; + } + // Default for meta if (typeof this.meta === 'undefined') { this.meta = {}; } + // Openidc defaults + if (typeof this.openidc_auth_method === 'undefined') { + this.openidc_auth_method = 'client_secret_post'; + } + this.domain_names.sort(); + this.openidc_allowed_users.sort(); } $beforeUpdate () { @@ -35,6 +46,11 @@ class ProxyHost extends Model { if (typeof this.domain_names !== 'undefined') { this.domain_names.sort(); } + + // Sort openidc_allowed_users + if (typeof this.openidc_allowed_users !== 'undefined') { + this.openidc_allowed_users.sort(); + } } static get name () { @@ -46,7 +62,7 @@ class ProxyHost extends Model { } static get jsonAttributes () { - return ['domain_names', 'meta', 'locations']; + return ['domain_names', 'meta', 'locations', 'openidc_allowed_users']; } static get relationMappings () { diff --git a/backend/schema/definitions.json b/backend/schema/definitions.json index 9895b87e..eda36050 100644 --- a/backend/schema/definitions.json +++ b/backend/schema/definitions.json @@ -235,6 +235,43 @@ "description": "Should we cache assets", "example": true, "type": "boolean" + }, + "openidc_enabled": { + "description": "Is OpenID Connect authentication enabled", + "example": true, + "type": "boolean" + }, + "openidc_redirect_uri": { + "type": "string" + }, + "openidc_discovery": { + "type": "string" + }, + "openidc_auth_method": { + "type": "string", + "pattern": "^(client_secret_basic|client_secret_post)$" + }, + "openidc_client_id": { + "type": "string" + }, + "openidc_client_secret": { + "type": "string" + }, + "openidc_restrict_users_enabled": { + "description": "Only allow a specific set of OpenID Connect emails to access the resource", + "example": true, + "type": "boolean" + }, + "openidc_allowed_users": { + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "description": "Email Address", + "example": "john@example.com", + "format": "email", + "minLength": 1 + } } } } diff --git a/backend/schema/endpoints/proxy-hosts.json b/backend/schema/endpoints/proxy-hosts.json index 9a3fff2f..11862256 100644 --- a/backend/schema/endpoints/proxy-hosts.json +++ b/backend/schema/endpoints/proxy-hosts.json @@ -64,6 +64,30 @@ "advanced_config": { "type": "string" }, + "openidc_enabled": { + "$ref": "../definitions.json#/definitions/openidc_enabled" + }, + "openidc_redirect_uri": { + "$ref": "../definitions.json#/definitions/openidc_redirect_uri" + }, + "openidc_discovery": { + "$ref": "../definitions.json#/definitions/openidc_discovery" + }, + "openidc_auth_method": { + "$ref": "../definitions.json#/definitions/openidc_auth_method" + }, + "openidc_client_id": { + "$ref": "../definitions.json#/definitions/openidc_client_id" + }, + "openidc_client_secret": { + "$ref": "../definitions.json#/definitions/openidc_client_secret" + }, + "openidc_restrict_users_enabled": { + "$ref": "../definitions.json#/definitions/openidc_restrict_users_enabled" + }, + "openidc_allowed_users": { + "$ref": "../definitions.json#/definitions/openidc_allowed_users" + }, "enabled": { "$ref": "../definitions.json#/definitions/enabled" }, @@ -161,6 +185,30 @@ "advanced_config": { "$ref": "#/definitions/advanced_config" }, + "openidc_enabled": { + "$ref": "#/definitions/openidc_enabled" + }, + "openidc_redirect_uri": { + "$ref": "#/definitions/openidc_redirect_uri" + }, + "openidc_discovery": { + "$ref": "#/definitions/openidc_discovery" + }, + "openidc_auth_method": { + "$ref": "#/definitions/openidc_auth_method" + }, + "openidc_client_id": { + "$ref": "#/definitions/openidc_client_id" + }, + "openidc_client_secret": { + "$ref": "#/definitions/openidc_client_secret" + }, + "openidc_restrict_users_enabled": { + "$ref": "#/definitions/openidc_restrict_users_enabled" + }, + "openidc_allowed_users": { + "$ref": "#/definitions/openidc_allowed_users" + }, "enabled": { "$ref": "#/definitions/enabled" }, @@ -251,6 +299,30 @@ "advanced_config": { "$ref": "#/definitions/advanced_config" }, + "openidc_enabled": { + "$ref": "#/definitions/openidc_enabled" + }, + "openidc_redirect_uri": { + "$ref": "#/definitions/openidc_redirect_uri" + }, + "openidc_discovery": { + "$ref": "#/definitions/openidc_discovery" + }, + "openidc_auth_method": { + "$ref": "#/definitions/openidc_auth_method" + }, + "openidc_client_id": { + "$ref": "#/definitions/openidc_client_id" + }, + "openidc_client_secret": { + "$ref": "#/definitions/openidc_client_secret" + }, + "openidc_restrict_users_enabled": { + "$ref": "#/definitions/openidc_restrict_users_enabled" + }, + "openidc_allowed_users": { + "$ref": "#/definitions/openidc_allowed_users" + }, "enabled": { "$ref": "#/definitions/enabled" }, @@ -324,6 +396,30 @@ "advanced_config": { "$ref": "#/definitions/advanced_config" }, + "openidc_enabled": { + "$ref": "#/definitions/openidc_enabled" + }, + "openidc_redirect_uri": { + "$ref": "#/definitions/openidc_redirect_uri" + }, + "openidc_discovery": { + "$ref": "#/definitions/openidc_discovery" + }, + "openidc_auth_method": { + "$ref": "#/definitions/openidc_auth_method" + }, + "openidc_client_id": { + "$ref": "#/definitions/openidc_client_id" + }, + "openidc_client_secret": { + "$ref": "#/definitions/openidc_client_secret" + }, + "openidc_restrict_users_enabled": { + "$ref": "#/definitions/openidc_restrict_users_enabled" + }, + "openidc_allowed_users": { + "$ref": "#/definitions/openidc_allowed_users" + }, "enabled": { "$ref": "#/definitions/enabled" }, diff --git a/backend/templates/_openid_connect.conf b/backend/templates/_openid_connect.conf new file mode 100644 index 00000000..e5c2c798 --- /dev/null +++ b/backend/templates/_openid_connect.conf @@ -0,0 +1,47 @@ +{% if openidc_enabled == 1 or openidc_enabled == true -%} + access_by_lua_block { + local openidc = require("resty.openidc") + local opts = { + redirect_uri = "{{- openidc_redirect_uri -}}", + discovery = "{{- openidc_discovery -}}", + token_endpoint_auth_method = "{{- openidc_auth_method -}}", + client_id = "{{- openidc_client_id -}}", + client_secret = "{{- openidc_client_secret -}}", + scope = "openid email profile" + } + + local res, err = openidc.authenticate(opts) + + if err then + ngx.status = 500 + ngx.say(err) + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + {% if openidc_restrict_users_enabled == 1 or openidc_restrict_users_enabled == true -%} + local function contains(table, val) + for i=1,#table do + if table[i] == val then + return true + end + end + return false + end + + local allowed_users = { + {% for user in openidc_allowed_users %} + "{{ user }}", + {% endfor %} + } + + if not contains(allowed_users, res.id_token.email) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif -%} + + + ngx.req.set_header("X-OIDC-SUB", res.id_token.sub) + ngx.req.set_header("X-OIDC-EMAIL", res.id_token.email) + ngx.req.set_header("X-OIDC-NAME", res.id_token.name) + } +{% endif %} \ No newline at end of file diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index ec30cca0..5629694e 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -51,7 +51,8 @@ proxy_http_version 1.1; {% endif %} -{% include "_hsts.conf" %} + {% include "_openid_connect.conf" %} + {% include "_hsts.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index 4d5ee901..0a58cdbf 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -43,6 +43,16 @@ http { proxy_cache_path /var/lib/nginx/cache/public levels=1:2 keys_zone=public-cache:30m max_size=192m; proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m; + lua_package_path '~/lua/?.lua;;'; + + lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + lua_ssl_verify_depth 5; + + # cache for discovery metadata documents + lua_shared_dict discovery 1m; + # cache for JWKs + lua_shared_dict jwks 1m; + log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"'; log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"'; diff --git a/docs/advanced-config/README.md b/docs/advanced-config/README.md index c7b51a84..20271783 100644 --- a/docs/advanced-config/README.md +++ b/docs/advanced-config/README.md @@ -172,3 +172,26 @@ value by specifying it as a Docker environment variable. The default if not spec X_FRAME_OPTIONS: "sameorigin" ... ``` + +## OpenID Connect SSO + +You can secure any of your proxy hosts with OpenID Connect authentication, providing SSO support from an identity provider like Azure AD or KeyCloak. OpenID Connect support is provided through the [`lua-resty-openidc`](https://github.com/zmartzone/lua-resty-openidc) library of [`OpenResty`](https://github.com/openresty/openresty). + +You will need a few things to get started with OpenID Connect: + +- A registered application with your identity provider, they will provide you with a `Client ID` and a `Client Secret`. Public OpenID Connect applications (without a client secret) are not yet supported. + +- A redirect URL to send the users to after they login with the identity provider, this can be any unused URL under the proxy host, like `https:///private/callback`, the server will take care of capturing that URL and redirecting you to the proxy host root. You will need to add this URL to the list of allowed redirect URLs for the application you registered with your identity provider. + +- The well-known discovery endpoint of the identity provider you want to use, this is an URL usually with the form `https:///.well-known/openid-configuration`. + +After you have all this you can proceed to configure the proxy host with OpenID Connect authentication. + +You can also add some rudimentary access control through a list of allowed emails in case your identity provider doesn't let you do that, if this option is enabled, any email not on that list will be denied access to the proxied host. + +The proxy adds some headers based on the authentication result from the identity provider: + + - `X-OIDC-SUB`: The subject identifier, according to the OpenID Coonect spec: `A locally unique and never reassigned identifier within the Issuer for the End-User`. + - `X-OIDC-EMAIL`: The email of the user that logged in, as specified in the `id_token` returned from the identity provider. The same value that will be checked for the email whitelist. + - `X-OIDC-NAME`: The user's name claim from the `id_token`, please note that not all id tokens necessarily contain this claim. + diff --git a/frontend/js/app/nginx/proxy/form.ejs b/frontend/js/app/nginx/proxy/form.ejs index 1a498301..6d8b484e 100644 --- a/frontend/js/app/nginx/proxy/form.ejs +++ b/frontend/js/app/nginx/proxy/form.ejs @@ -11,6 +11,7 @@ +
@@ -270,6 +271,71 @@
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
diff --git a/frontend/js/app/nginx/proxy/form.js b/frontend/js/app/nginx/proxy/form.js index 1dfb5c18..3a99c4c1 100644 --- a/frontend/js/app/nginx/proxy/form.js +++ b/frontend/js/app/nginx/proxy/form.js @@ -21,29 +21,34 @@ module.exports = Mn.View.extend({ locationsCollection: new ProxyLocationModel.Collection(), ui: { - form: 'form', - domain_names: 'input[name="domain_names"]', - forward_host: 'input[name="forward_host"]', - buttons: '.modal-footer button', - cancel: 'button.cancel', - save: 'button.save', - add_location_btn: 'button.add_location', - locations_container: '.locations_container', - le_error_info: '#le-error-info', - certificate_select: 'select[name="certificate_id"]', - access_list_select: 'select[name="access_list_id"]', - ssl_forced: 'input[name="ssl_forced"]', - hsts_enabled: 'input[name="hsts_enabled"]', - hsts_subdomains: 'input[name="hsts_subdomains"]', - http2_support: 'input[name="http2_support"]', - dns_challenge_switch: 'input[name="meta[dns_challenge]"]', - dns_challenge_content: '.dns-challenge', - dns_provider: 'select[name="meta[dns_provider]"]', - credentials_file_content: '.credentials-file-content', - dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]', - propagation_seconds: 'input[name="meta[propagation_seconds]"]', - forward_scheme: 'select[name="forward_scheme"]', - letsencrypt: '.letsencrypt' + form: 'form', + domain_names: 'input[name="domain_names"]', + forward_host: 'input[name="forward_host"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + add_location_btn: 'button.add_location', + locations_container: '.locations_container', + le_error_info: '#le-error-info', + certificate_select: 'select[name="certificate_id"]', + access_list_select: 'select[name="access_list_id"]', + ssl_forced: 'input[name="ssl_forced"]', + hsts_enabled: 'input[name="hsts_enabled"]', + hsts_subdomains: 'input[name="hsts_subdomains"]', + http2_support: 'input[name="http2_support"]', + dns_challenge_switch: 'input[name="meta[dns_challenge]"]', + dns_challenge_content: '.dns-challenge', + dns_provider: 'select[name="meta[dns_provider]"]', + credentials_file_content: '.credentials-file-content', + dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]', + propagation_seconds: 'input[name="meta[propagation_seconds]"]', + forward_scheme: 'select[name="forward_scheme"]', + letsencrypt: '.letsencrypt', + openidc_enabled: 'input[name="openidc_enabled"]', + openidc_restrict_users_enabled: 'input[name="openidc_restrict_users_enabled"]', + openidc_allowed_users: 'input[name="openidc_allowed_users"]', + openidc: '.openidc', + openidc_users: '.openidc_users', }, regions: { @@ -113,7 +118,7 @@ module.exports = Mn.View.extend({ } else { this.ui.dns_provider.prop('required', false); this.ui.dns_provider_credentials.prop('required', false); - this.ui.dns_challenge_content.hide(); + this.ui.dns_challenge_content.hide(); } }, @@ -125,13 +130,34 @@ module.exports = Mn.View.extend({ this.ui.credentials_file_content.show(); } else { this.ui.dns_provider_credentials.prop('required', false); - this.ui.credentials_file_content.hide(); + this.ui.credentials_file_content.hide(); + } + }, + + 'change @ui.openidc_enabled': function () { + let checked = this.ui.openidc_enabled.prop('checked'); + + if (checked) { + this.ui.openidc.show().find('input').prop('disabled', false); + } else { + this.ui.openidc.hide().find('input').prop('disabled', true); + } + + this.ui.openidc_restrict_users_enabled.trigger('change'); + }, + + 'change @ui.openidc_restrict_users_enabled': function () { + let checked = this.ui.openidc_restrict_users_enabled.prop('checked'); + if (checked) { + this.ui.openidc_users.show().find('input').prop('disabled', false); + } else { + this.ui.openidc_users.hide().find('input').prop('disabled', true); } }, 'click @ui.add_location_btn': function (e) { e.preventDefault(); - + const model = new ProxyLocationModel.Model(); this.locationsCollection.add(model); }, @@ -167,17 +193,25 @@ module.exports = Mn.View.extend({ data.hsts_enabled = !!data.hsts_enabled; data.hsts_subdomains = !!data.hsts_subdomains; data.ssl_forced = !!data.ssl_forced; - + data.openidc_enabled = data.openidc_enabled === '1'; + data.openidc_restrict_users_enabled = data.openidc_restrict_users_enabled === '1'; + + if (data.openidc_restrict_users_enabled) { + if (typeof data.openidc_allowed_users === 'string' && data.openidc_allowed_users) { + data.openidc_allowed_users = data.openidc_allowed_users.split(','); + } + } + if (typeof data.meta === 'undefined') data.meta = {}; data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1; data.meta.dns_challenge = data.meta.dns_challenge == 1; - + if(!data.meta.dns_challenge){ data.meta.dns_provider = undefined; data.meta.dns_provider_credentials = undefined; data.meta.propagation_seconds = undefined; } else { - if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; + if(data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined; } if (typeof data.domain_names === 'string' && data.domain_names) { @@ -185,7 +219,7 @@ module.exports = Mn.View.extend({ } // Check for any domain names containing wildcards, which are not allowed with letsencrypt - if (data.certificate_id === 'new') { + if (data.certificate_id === 'new') { let domain_err = false; if (!data.meta.dns_challenge) { data.domain_names.map(function (name) { @@ -203,6 +237,12 @@ module.exports = Mn.View.extend({ data.certificate_id = parseInt(data.certificate_id, 10); } + // OpenID Connect won't work with multiple domain names because the redirect URL has to point to a specific one + if (data.openidc_enabled && data.domain_names.length > 1) { + alert('Cannot use mutliple domain names when OpenID Connect is enabled'); + return; + } + let method = App.Api.Nginx.ProxyHosts.create; let is_new = true; @@ -344,6 +384,23 @@ module.exports = Mn.View.extend({ view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id')); } }); + + // OpenID Connect + this.ui.openidc_allowed_users.selectize({ + delimiter: ',', + persist: false, + maxOptions: 15, + create: function (input) { + return { + value: input, + text: input + }; + } + }); + this.ui.openidc.hide().find('input').prop('disabled', true); + this.ui.openidc_users.hide().find('input').prop('disabled', true); + this.ui.openidc_enabled.trigger('change'); + this.ui.openidc_restrict_users_enabled.trigger('change'); }, initialize: function (options) { diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 9feb82d2..bfdc0725 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -130,7 +130,16 @@ "access-list": "Access List", "allow-websocket-upgrade": "Websockets Support", "ignore-invalid-upstream-ssl": "Ignore Invalid SSL", - "custom-forward-host-help": "Use 1.1.1.1/path for sub-folder forwarding" + "custom-forward-host-help": "Use 1.1.1.1/path for sub-folder forwarding", + "oidc": "OpenID Connect", + "oidc-enabled": "Use OpenID Connect authentication", + "oidc-redirect-uri": "Redirect URI", + "oidc-discovery-endpoint": "Well-known discovery endpoint", + "oidc-token-auth-method": "Token endpoint auth method", + "oidc-client-id": "Client ID", + "oidc-client-secret": "Client secret", + "oidc-allow-only-emails": "Allow only these user emails", + "oidc-allowed-emails": "Allowed email addresses" }, "redirection-hosts": { "title": "Redirection Hosts", diff --git a/frontend/js/models/proxy-host.js b/frontend/js/models/proxy-host.js index b82d09fe..85429d18 100644 --- a/frontend/js/models/proxy-host.js +++ b/frontend/js/models/proxy-host.js @@ -22,6 +22,14 @@ const model = Backbone.Model.extend({ block_exploits: false, http2_support: false, advanced_config: '', + openidc_enabled: false, + openidc_redirect_uri: '', + openidc_discovery: '', + openidc_auth_method: 'client_secret_post', + openidc_client_id: '', + openidc_client_secret: '', + openidc_restrict_users_enabled: false, + openidc_allowed_users: [], enabled: true, meta: {}, // The following are expansions: