From 99b529057060bb470ade2aee007adf373a3e5d73 Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Fri, 24 Jul 2020 17:05:55 -0400 Subject: [PATCH] chore: add core authentication flow with acceptance tests --- ui/core/app/authenticators/password.js | 4 +- ui/core/app/router.js | 3 + ui/core/app/routes/scopes.js | 20 ++++++ ui/core/app/routes/scopes/scope.js | 17 +++-- .../app/routes/scopes/scope/authenticate.js | 26 ++++++++ .../scopes/scope/authenticate/method.js | 63 +++++++++++++++++++ ui/core/app/session-stores/application.js | 4 +- .../templates/scopes/scope/authenticate.hbs | 1 + .../scopes/scope/authenticate/method.hbs | 26 ++++++++ ui/core/mirage/config.js | 4 ++ ui/core/mirage/route-handlers/auth.js | 60 ++++++++++++++++++ .../tests/acceptance/authentication-test.js | 53 ++++++++++++++++ .../routes/scopes/scope/authenticate-test.js | 11 ++++ .../scopes/scope/authenticate/method-test.js | 11 ++++ 14 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 ui/core/app/routes/scopes/scope/authenticate.js create mode 100644 ui/core/app/routes/scopes/scope/authenticate/method.js create mode 100644 ui/core/app/templates/scopes/scope/authenticate.hbs create mode 100644 ui/core/app/templates/scopes/scope/authenticate/method.hbs create mode 100644 ui/core/mirage/route-handlers/auth.js create mode 100644 ui/core/tests/acceptance/authentication-test.js create mode 100644 ui/core/tests/unit/routes/scopes/scope/authenticate-test.js create mode 100644 ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js diff --git a/ui/core/app/authenticators/password.js b/ui/core/app/authenticators/password.js index 041d5b9dc..3224130a3 100644 --- a/ui/core/app/authenticators/password.js +++ b/ui/core/app/authenticators/password.js @@ -23,7 +23,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator { * @return {string} */ buildAuthEndpointURL({ - scope: { scope_id: scopeID }, + scope: { id: scopeID }, authMethod: { id: authMethodID } }) { const adapter = this.store.adapterFor('application'); @@ -38,7 +38,7 @@ export default class PasswordAuthenticator extends BasePasswordAuthenticator { * @param {string} scopeID * @return {string} */ - buildDeauthEndpointURL({ scope: { scope_id: scopeID } }) { + buildDeauthEndpointURL({ scope: { id: scopeID } }) { const adapter = this.store.adapterFor('application'); const options = { adapterOptions: { method: 'deauthenticate' } }; return adapter.buildURL('scope', scopeID, options, 'findRecord'); diff --git a/ui/core/app/router.js b/ui/core/app/router.js index e07a9ee94..ef3536c60 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -10,6 +10,9 @@ Router.map(function () { this.route('scopes', function() { this.route('scope', { path: ':scope_id' }, function() { + this.route('authenticate', function() { + this.route('method', { path: ':auth_method_id'}); + }); this.route('projects', function() { this.route('project', { path: ':project_id' }, function() {}); this.route('new'); diff --git a/ui/core/app/routes/scopes.js b/ui/core/app/routes/scopes.js index 5fff74b2e..a849878ba 100644 --- a/ui/core/app/routes/scopes.js +++ b/ui/core/app/routes/scopes.js @@ -1,4 +1,24 @@ import Route from '@ember/routing/route'; +import { A } from '@ember/array'; export default class ScopesRoute extends Route { + + // =methods + + /** + * Attempt to load the specified scope from the API. This is allowed + * to fail, since in some cases the user may not have permission to read a + * scope directly, but may have permission to read resources under it. + * If the scope fails to load, we still proceed using a temporary scope object + * consisting of only the specified ID. + * @param {object} params + * @param {string} params.scope_id + * @return {Promise{ScopeModel}} + */ + model() { + // NOTE: In the absence of a `scope_id` query parameter, this endpoint is + // expected to default to the global scope, thus returning org scopes. + return this.store.findAll('scope').catch(() => A([])); + } + } diff --git a/ui/core/app/routes/scopes/scope.js b/ui/core/app/routes/scopes/scope.js index 8cde487bd..c9ecb358f 100644 --- a/ui/core/app/routes/scopes/scope.js +++ b/ui/core/app/routes/scopes/scope.js @@ -14,15 +14,14 @@ export default class ScopesScopeRoute extends Route { * @param {string} params.scope_id * @return {Promise{ScopeModel}} */ - async model({ scope_id: id }) { - try { - return await this.store.findRecord('scope', id); - } catch { - const maybeExistingScope = this.store.peekRecord('scope', id); - // TODO it's unclear if creating a temporary unsaved scope is reliable... - // we may need to revert to a POJO. - return maybeExistingScope || this.store.createRecord('scope', { id }); - } + model({ scope_id: id }) { + return this.store.findRecord('scope', id) + .catch(() => { + const maybeExistingScope = this.store.peekRecord('scope', id); + // TODO it's unclear if creating a temporary unsaved scope is reliable + // we may need to revert to a POJO. + return maybeExistingScope || this.store.createRecord('scope', { id }); + }); } } diff --git a/ui/core/app/routes/scopes/scope/authenticate.js b/ui/core/app/routes/scopes/scope/authenticate.js new file mode 100644 index 000000000..3ae8edbcf --- /dev/null +++ b/ui/core/app/routes/scopes/scope/authenticate.js @@ -0,0 +1,26 @@ +import Route from '@ember/routing/route'; +import { hash } from 'rsvp'; + +export default class ScopesScopeAuthenticateRoute extends Route { + + /** + * Returns all auth methods for the current scope, along with the current + * scope and all scopes (for org navigation). + * @return {Promise} `{scope, scopes, authMethods}` + */ + model() { + const adapterOptions = { scopeID: this.modelFor('scopes.scope').id }; + return hash({ + scope: this.modelFor('scopes.scope'), + scopes: this.modelFor('scopes'), + authMethods: this.store.findAll('auth-method', { adapterOptions }), + + // for integration testing: + // authMethods: A([{ + // id: 'am_1234567890', + // type: 'password' + // }]) + }); + } + +} diff --git a/ui/core/app/routes/scopes/scope/authenticate/method.js b/ui/core/app/routes/scopes/scope/authenticate/method.js new file mode 100644 index 000000000..c4ce43a11 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/authenticate/method.js @@ -0,0 +1,63 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ScopesScopeAuthenticateMethodRoute extends Route { + + // =services + + @service session; + @service notify; + @service intl; + + // =methods + + /** + * Returns an auth method by id. + * @param {object} params + * @return {Promise{AuthMethodModel}} + */ + model({ auth_method_id: id }) { + const adapterOptions = { scopeID: this.modelFor('scopes.scope').id }; + return this.store.findRecord('auth-method', id, adapterOptions); + } + + /** + * Resets the password field on the controller so it is not + * inadvertantly retained in memory. + */ + resetCredentials() { + this.controller.setProperties({ + password: null, + }); + } + + // =actions + + /** + * Delegates authentication to the `password` authenticator. + * If authentication fails, notifies with an alert. + */ + @action + async authenticate(creds) { + this.resetCredentials(); + const authenticatorName = 'authenticator:password'; + const scope = this.modelFor('scopes.scope'); + const authMethod = this.currentModel; + const authMethodId = authMethod.id; + const requestCookies = false; + try { + await this.session.authenticate( + authenticatorName, + creds, + requestCookies, + { scope, authMethod } + ); + this.transitionTo('scopes.scope.projects'); + } catch (e) { + const errorMessage = this.intl.t('errors.titles.authentication-failed'); + this.notify.error(errorMessage, { closeAfter: null }); + } + } + +} diff --git a/ui/core/app/session-stores/application.js b/ui/core/app/session-stores/application.js index 555804f12..80d0603db 100644 --- a/ui/core/app/session-stores/application.js +++ b/ui/core/app/session-stores/application.js @@ -37,9 +37,9 @@ export default class ApplicationSessionStore extends LocalStorageSessionStore { * @param {object} sessionData */ addScopeToSession(sessionData) { - const scopeID = get(this.scope, 'scope.id'); + const scope = get(this, 'scope'); const authenticated = get(sessionData, 'authenticated'); - if (scopeID && authenticated) authenticated.scopeID = scopeID; + if (scope && authenticated) authenticated.scope = scope; } } diff --git a/ui/core/app/templates/scopes/scope/authenticate.hbs b/ui/core/app/templates/scopes/scope/authenticate.hbs new file mode 100644 index 000000000..e2147cab0 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/authenticate.hbs @@ -0,0 +1 @@ +{{outlet}} \ No newline at end of file diff --git a/ui/core/app/templates/scopes/scope/authenticate/method.hbs b/ui/core/app/templates/scopes/scope/authenticate/method.hbs new file mode 100644 index 000000000..60057d773 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/authenticate/method.hbs @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js index ed6859c83..aa0b6e14a 100644 --- a/ui/core/mirage/config.js +++ b/ui/core/mirage/config.js @@ -1,4 +1,5 @@ import config from '../config/environment'; +import { authHandler } from './route-handlers/auth'; import { Response } from 'miragejs'; export default function() { @@ -55,6 +56,9 @@ export default function() { this.patch('/scopes/:scope_id/auth-methods/:id'); this.del('/scopes/:scope_id/auth-methods/:id'); + // Authenticate/deauthenticate route + this.post('/scopes/:scope_id/auth-methods/:id_method', authHandler); + // IAM : Users this.get('/scopes/:scope_id/users'); this.post('/scopes/:scope_id/users'); diff --git a/ui/core/mirage/route-handlers/auth.js b/ui/core/mirage/route-handlers/auth.js new file mode 100644 index 000000000..8bedb771c --- /dev/null +++ b/ui/core/mirage/route-handlers/auth.js @@ -0,0 +1,60 @@ +// import config from '../../config/environment'; +// import { v1 } from 'ember-uuid'; +import { Response } from 'miragejs'; + +// /** +// * Simulates a cookie-based response to authentication requests. Any user/pass +// * values are allowed *except* when `username` equals `error`, in which case a +// * simulated error response is returned. +// * @param {object} schema +// * @param {object} request +// * @return {Response} +// */ +// export default function authenticateHandler(schema, request) { +// const payload = JSON.parse(request.requestBody); +// if (payload.credentials.name === 'error') { +// return new Response(400); +// } else { +// const cookieName = config.auth.passwordCookieName; +// const cookieValue = v1(); +// const cookiePath = config.rootURL; +// document.cookie = `${cookieName}=${cookieValue};path=${cookiePath}`; +// return new Response(200); +// } +// } + +/** + * Mirage doesn't distinguish between `/path:authenticate` and + * `/path:deauthenticate`, so we use the presence of a request body to indicate + * an authentication request and its absence to indicate deauthentication. + */ +export function authHandler({ scopes }, request) { + const [ , method ] = request.params.id_method.split(':'); + const payload = JSON.parse(request.requestBody); + const isAuthenticationRequest = method === 'authenticate'; + if (isAuthenticationRequest) { + // this is an auth request + if (payload.credentials.name === 'error') { + return new Response(400); + } else { + console.log(request); + const scopeAttrs = + this.serialize(scopes.find(request.params.scope_id)); + const scope = scopeAttrs; + return new Response(200, {}, { + scope, + id: 'token123', + token: 'thetokenstring', + user_id: 'user123', + auth_method_id: 'authmethod123', + created_time: '', + updated_time: '', + last_used_time: '', + expiration_time: '' + }); + } + } else { + // this is a deauth request + return new Response(200); + } +} diff --git a/ui/core/tests/acceptance/authentication-test.js b/ui/core/tests/acceptance/authentication-test.js new file mode 100644 index 000000000..89c9b842b --- /dev/null +++ b/ui/core/tests/acceptance/authentication-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, fillIn, click, find } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; + +module('Acceptance | authentication', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let orgScope; + let orgScopeID; + let authMethod; + let authMethodID; + let authMethodAuthenticateURL; + let projectsURL; + + hooks.beforeEach(function () { + orgScope = this.server.create('scope', {type: 'org'}, 'withChildren'); + const scope = { id: orgScope.id, type: orgScope.type }; + authMethod = server.create('auth-method', { scope }); + orgScopeID = orgScope.id; + authMethodID = authMethod.id; + authMethodAuthenticateURL = `/scopes/${orgScopeID}/authenticate/${authMethodID}`; + projectsURL = `/scopes/${orgScopeID}/projects`; + }); + + test('visiting auth method authenticate route', async function(assert) { + assert.expect(1); + await visit(authMethodAuthenticateURL); + await a11yAudit(); + assert.equal(currentURL(), authMethodAuthenticateURL); + }); + + test('failed authentication shows a notification message', async function(assert) { + assert.expect(1); + await visit(authMethodAuthenticateURL); + await fillIn('[name="identification"]', 'error'); + await click('[type="submit"]'); + assert.ok(find('.rose-notification.is-error')); + }); + + test('successful authentication redirects to projects', async function(assert) { + assert.expect(2); + await visit(authMethodAuthenticateURL); + assert.equal(currentURL(), authMethodAuthenticateURL); + await fillIn('[name="identification"]', 'test'); + await fillIn('[name="password"]', 'test'); + await click('[type="submit"]'); + assert.equal(currentURL(), projectsURL); + }); + +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js b/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js new file mode 100644 index 000000000..2ee818453 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/authenticate', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/authenticate'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js b/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js new file mode 100644 index 000000000..5aed2aefe --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/authenticate/method', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/authenticate/method'); + assert.ok(route); + }); +});