diff --git a/TODO.text b/TODO.text index f132b08..fda9c1d 100644 --- a/TODO.text +++ b/TODO.text @@ -1,4 +1,3 @@ -- App setup wizard - Cobalt form JSON field type - Setting resource - MFA recovery codes handling - Forgot password handling diff --git a/app/assets/app/cobalt/Listing.component.js b/app/assets/app/cobalt/Listing.component.js index 5bcb203..dfd0bac 100644 --- a/app/assets/app/cobalt/Listing.component.js +++ b/app/assets/app/cobalt/Listing.component.js @@ -14,10 +14,10 @@ const template = `
-

{{ resource_class.plural }}

-
+

{{ resource_class.plural }}

+
+ + +
+
+

We're going to create an OAuth2 client for {{ name }}. This client will have the credentials that {{ name }} will use to authenticate users against {{ app_name }}'s API.

+

By default, the OAuth2 client will be able to fetch information about individual users and groups. You can adjust this in the future by navigating to the OAuth2 Clients page.

+

Please provide the OAuth2 callback URL. This is where {{ app_name }} will redirect users after they have been authenticated.

+ +

{{ app_name }} only supports the authorization_code grant type.

+
+
+

We're going to register {{ name }} as a SAML2.0 service provider. This will allow it to interface with {{ app_name }}.

+

To do this, you need to provide {{ name }}'s entity ID, assertion consumer service URL, and single-logout URL (if supported).

+ Entity ID: + +
+ Assertion Consumer Service URL: + +
+ Single-Logout URL (optional): + +
+
+

Success! {{ name }} was added to {{ app_name }}'s records, and a SAML2.0 service provider was created.

+

The next step is to configure {{ name }} to redirect users to {{ app_name }} to log on. Here's some information on getting it set up:

+

Configuring the Identity Provider

+

{{ app_name }} is the SAML2.0 identity provider in this case. To set it up, you'll need the following info:

+
    +
  • Entity ID/Metadata: {{ make_url('/saml/metadata.xml') }}
  • +
  • Sign-On URL: {{ make_url('/saml/sso') }}
  • +
  • Single-Log-Out URL (if supported): {{ make_url('/saml/logout') }}
  • +
+
+
+

We're going to register {{ name }} as an LDAP auth client. To do this, you'll need to specify an LDAP username and password that {{ name }} will use to authenticate users.

+ Username: + +
+ Password: + + +
+
+

Success! {{ name }} was added to {{ app_name }}'s records, and an LDAP client was created.

+

The next step is to configure {{ name }} to use {{ app_name }} to log on. Here's some information on getting it set up:

+

LDAP Credentials

+

If {{ name }} requires a bind user to query the LDAP server against, you can use these credentials:

+
    +
  • Server address: {{ host }}
  • +
  • Server port: {{ ldap_config.port }}
  • +
  • Bind User DN: {{ ldap_config.login_field }}={{ ldap_username }},{{ ldap_config.authentication_base }},{{ ldap_config.base_dc }}
  • +
  • Password: the password you just set
  • +
+ +

User Searching

+
    +
  • User search base: {{ ldap_config.authentication_base }},{{ ldap_config.base_dc }}
  • +
  • Group search base: {{ ldap_config.group_base }},{{ ldap_config.base_dc }}
  • +
  • Search filter: (&(objectClass=inetOrgPerson)(iamTarget={{ app.id }})({{ ldap_config.login_field }}=username_substituted_here))
  • +
+ +

Group Membership

+

Groups are made available in a manner compatible with OpenLDAP's memberOf overlay.

+

That means that groups are objectClass: groupOfNames and can be found in the memberOf attribute of the user object.

+

Groups have the form cn=group_name,{{ ldap_config.group_base }},{{ ldap_config.base_dc }}.

+ +

Other Considerations

+

{{ app_name }}'s built-in LDAP server provides the minimum-viable level of functionality required to authenticate users. That means it sometimes lacks features that more sophisticated LDAP clients expect.

+

Here are a few settings to tweak:

+
    +
  • User display name field: gecos (this is the full name of the user)
  • +
  • Paging chunk size: 0 (disable - {{ app_name }} does not support LDAP paging)
  • +
  • User e-mail field: mail
  • +
  • UUID attribute for users: {{ ldap_config.login_field }}
  • +
  • UUID attribute for groups: cn
  • +
+
+
+

Success! {{ name }} was added to {{ app_name }}'s records, and an OAuth2 client was created.

+

The next step is to configure {{ name }} to redirect users to {{ app_name }} to log on. Here's some information on getting it set up:

+

User Authorization

+

First, redirect the user to {{ app_name }}. Configure {{ name }} to use this URL:

+ + {{ make_url('/auth/service/oauth2/authorize') }}?client_id={{ oauth_client.uuid }}&redirect_uri={{ oauth_client.redirect_url }} + +

+

Once the user authenticates successfully, {{ app_name }} will redirect them back to {{ name }}.

+ +

Auth Code Redemption

+

Once the user is redirected back, {{ name }} will be given an authorization code which can be redeemed for a bearer token.

+

To redeem this code, {{ name }} should make a POST request to:

+ + {{ make_url('/auth/service/oauth2/redeem') }} + +

+

It should have the following body fields:

+
    +
  • code - the authorization code that was returned
  • +
  • client_id - {{ oauth_client.uuid }}
  • +
  • client_secret - {{ oauth_client.secret }}
  • +
  • grant_type - authorization_code
  • +
+

This will return an access_token that can be used to fetch user information from the {{ app_name }} API.

+ +

Fetching User Info

+

Once the auth code has been redeemed for a bearer token, that token can be used to make requests to the {{ app_name }} API.

+

Primarily, it can be used to fetch user information by making a GET request to the following URL:

+ {{ make_url('/api/v1/auth/users/me') }} +

+

and including the bearer token in the headers like so: Authorization: Bearer AbCdEf124

+ +
Making Test Requests
+

To test out the API integration, you can generate API tokens for {{ name }}. You can do that by clicking on the User Menu > API Tokens.

+
+
+ +
+` + +export default class AppSetupComponent extends Component { + static get selector() { return 'coreid-app-setup' } + static get template() { return template } + static get props() { return [] } + + step = 0 + btn_disabled = true + btn_back = false + btn_hidden = false + btn_listing = false + + name = '' + identifier = '' + type = '' // ldap | saml | oauth + oauth_redirect_uri = '' + + saml_entity_id = '' + saml_acs_url = '' + saml_slo_url = '' + + ldap_username = '' + ldap_password = '' + ldap_password_confirm = '' + ldap_config = {} + + error_message = '' + + app = {} + oauth_client = {} + saml_provider = {} + ldap_client = {} + + app_name = '' + host = '' + + make_url(path) { + return session.url(path) + } + + async vue_on_create() { + this.app_name = session.get('app.name') + this.host = session.host() + } + + on_key_up(event) { + if ( this.step === 0 ) { + this.btn_disabled = !this.name.trim() || !this.identifier.trim() || !this.identifier.match(/^([a-z]|[A-Z]|[0-9]|_)+$/) + } else if ( this.step === 2 && this.type === 'oauth' ) { + this.btn_disabled = !this.oauth_redirect_uri.trim() + } else if ( this.step === 2 && this.type === 'saml' ) { + this.btn_disabled = !this.saml_entity_id.trim() || !this.saml_acs_url.trim() + } else if ( this.step === 2 && this.type === 'ldap' ) { + this.btn_disabled = !this.ldap_username.trim() || !this.ldap_username.match(/^([a-z]|[A-Z]|[0-9]|_)+$/) || !this.ldap_password || this.ldap_password !== this.ldap_password_confirm + } + + if ( event.keyCode === 13 && !this.btn_disabled ) { + // Enter was pressed + event.preventDefault() + event.stopPropagation() + return this.on_next_click() + } + } + + async on_back_click() { + this.step -= 1 + if ( this.step === 0 ) this.btn_back = false + if ( this.step === 0 || this.step === 2 ) this.btn_hidden = false + } + + async create_app(merge_params = {}) { + const params = { + ...{ + name: this.name, + identifier: this.identifier, + description: '', + }, + ...merge_params + } + + const app_rsc = await resource_service.get('App') + return app_rsc.create(params) + } + + async on_next_click() { + this.error_message = '' + try { + if (this.step === 0) { + this.step += 1 + this.btn_hidden = true + this.btn_back = true + } else if (this.step === 2 && this.type === 'oauth') { + const client_rsc = await resource_service.get('oauth/Client') + const oauth_client_params = { + name: this.name, + redirect_url: this.oauth_redirect_uri, + api_scopes: ['v1:auth:users:get', 'v1:auth:groups:get'], + } + + this.oauth_client = await client_rsc.create(oauth_client_params) + this.app = await this.create_app({ + oauth_client_ids: [this.oauth_client.id] + }) + } else if (this.step === 2 && this.type === 'saml') { + const provider_rsc = await resource_service.get('saml/Provider') + const provider_params = { + name: this.name, + acs_url: this.saml_acs_url, + entity_id: this.saml_entity_id, + } + + if (this.saml_slo_url) + provider_params.slo_url = this.saml_slo_url + + this.saml_provider = await provider_rsc.create(provider_params) + this.app = await this.create_app({ + saml_service_provider_ids: [this.saml_provider.id] + }) + } else if (this.step === 2 && this.type === 'ldap') { + const client_rsc = await resource_service.get('ldap/Client') + const client_params = { + name: this.name, + uid: this.ldap_username, + password: this.ldap_password, + } + + this.ldap_config = await client_rsc.server_config() + this.ldap_client = await client_rsc.create(client_params) + this.app = await this.create_app({ + ldap_client_ids: [this.ldap_client.id] + }) + } + + if ( this.step === 2 ) { + this.step += 1 + this.btn_disabled = true + this.btn_hidden = true + this.btn_listing = true + this.btn_back = false + } + } catch (e) { + if ( e.response && e.response.data && e.response.data.message ) + this.error_message = e.response.data.message + else this.error_message = 'An unknown error occurred while saving the form.' + } + } + + async on_type_click(type) { + this.type = type + this.step += 1 + this.btn_hidden = false + this.btn_disabled = true + } + + async on_listing_click() { + await location_service.back() + } +} diff --git a/app/assets/app/resource/App.resource.js b/app/assets/app/resource/App.resource.js index 0978e5e..7a3022e 100644 --- a/app/assets/app/resource/App.resource.js +++ b/app/assets/app/resource/App.resource.js @@ -32,8 +32,15 @@ class AppResource extends CRUDBase { type: 'resource', position: 'main', action: 'insert', - text: 'Create New', + text: 'Manual Setup', + color: 'outline-success', + }, + { + position: 'main', + action: 'redirect', + text: 'Setup Wizard', color: 'success', + next: '/dash/app/setup', }, { type: 'resource', diff --git a/app/assets/app/resource/ldap/Client.resource.js b/app/assets/app/resource/ldap/Client.resource.js index aa51c58..609bcc0 100644 --- a/app/assets/app/resource/ldap/Client.resource.js +++ b/app/assets/app/resource/ldap/Client.resource.js @@ -9,6 +9,11 @@ class ClientResource extends CRUDBase { item = 'LDAP Client' plural = 'LDAP Clients' + async server_config() { + const results = await axios.get('/api/v1/ldap/config') + if ( results && results.data && results.data.data ) return results.data.data + } + listing_definition = { display: ` LDAP Clients are special user accounts that external applications can use to bind to ${session.get('app.name')}'s built-in LDAP server to allow these applications to authenticate users. diff --git a/app/assets/app/service/Session.service.js b/app/assets/app/service/Session.service.js index 1421e01..3699db3 100644 --- a/app/assets/app/service/Session.service.js +++ b/app/assets/app/service/Session.service.js @@ -32,6 +32,22 @@ class Session { if ( permissions.length === 1 ) return result.data.data[permissions[0]] return result.data.data } + + url(path) { + if ( !path.startsWith('/') ) path = `/${path}` + + let url = this.get('app.url') + if ( url.endsWith('/') ) url = url.slice(0, -1) + + return `${url}${path}` + } + + host() { + let url = this.get('app.url') + if ( url.startsWith('http://') ) url = url.substr(7) + else if ( url.startsWith('https://') ) url = url.substr(8) + return url.split('/')[0].split(':')[0] + } } const session = new Session() diff --git a/app/controllers/api/v1/LDAP.controller.js b/app/controllers/api/v1/LDAP.controller.js index a082d7b..e691791 100644 --- a/app/controllers/api/v1/LDAP.controller.js +++ b/app/controllers/api/v1/LDAP.controller.js @@ -3,7 +3,21 @@ const zxcvbn = require('zxcvbn') class LDAPController extends Controller { static get services() { - return [...super.services, 'models', 'utility'] + return [...super.services, 'models', 'utility', 'configs'] + } + + async get_config(req, res, next) { + // ldap port + // user base dn + // group base dn + const config = this.configs.get('ldap:server') + return res.api({ + port: config.port, + base_dc: config.schema.base_dc, + authentication_base: config.schema.authentication_base, + group_base: config.schema.group_base, + login_field: config.schema.auth.user_id, + }) } async get_clients(req, res, next) { diff --git a/app/controllers/dash/Misc.controller.js b/app/controllers/dash/Misc.controller.js new file mode 100644 index 0000000..300af13 --- /dev/null +++ b/app/controllers/dash/Misc.controller.js @@ -0,0 +1,16 @@ +const { Controller } = require('libflitter') + +class MiscController extends Controller { + static get services() { + return [...super.services, 'Vue'] + } + + get_app_setup(req, res, next) { + return res.page('dash:app_setup', { + ...this.Vue.session(req), + ...this.Vue.data() + }) + } +} + +module.exports = exports = MiscController diff --git a/app/routing/routers/api/v1/ldap.routes.js b/app/routing/routers/api/v1/ldap.routes.js index 78fb5af..4a6b529 100644 --- a/app/routing/routers/api/v1/ldap.routes.js +++ b/app/routing/routers/api/v1/ldap.routes.js @@ -22,6 +22,10 @@ const ldap_routes = { ['middleware::api:Permission', { check: 'v1:ldap:groups:get' }], 'controller::api:v1:LDAP.get_group', ], + '/config': [ + ['middleware::api:Permission', { check: 'v1:ldap:config:get' }], + 'controller::api:v1:LDAP.get_config', + ], }, post: { diff --git a/app/routing/routers/auth/saml.routes.js b/app/routing/routers/auth/saml.routes.js index 3d76fcd..01b3482 100644 --- a/app/routing/routers/auth/saml.routes.js +++ b/app/routing/routers/auth/saml.routes.js @@ -5,8 +5,6 @@ const saml_routes = { ], - // TODO SLO - get: { '/metadata.xml': ['controller::saml:SAML.get_metadata'], '/sso': [ diff --git a/app/routing/routers/dash/misc.routes.js b/app/routing/routers/dash/misc.routes.js new file mode 100644 index 0000000..056dd4a --- /dev/null +++ b/app/routing/routers/dash/misc.routes.js @@ -0,0 +1,13 @@ +const misc_routes = { + prefix: '/dash', + + middleware: ['auth:UserOnly'], + + get: { + '/app/setup': [ + 'controller::dash:Misc.get_app_setup' + ], + }, +} + +module.exports = exports = misc_routes diff --git a/app/views/dash/app_setup.pug b/app/views/dash/app_setup.pug new file mode 100644 index 0000000..bd3e65b --- /dev/null +++ b/app/views/dash/app_setup.pug @@ -0,0 +1,7 @@ +extends ../theme/dash/base + +block content + .cobalt-container + .row.pad-top + .col-12 + coreid-app-setup