chore: add core authentication flow with acceptance tests

This commit is contained in:
Randall Morey committed 2020-07-24 17:05:55 -04:00
1 parent dba62d8161
commit 99b5290570
14 files changed
+290 -13

No files matched your search

+2 -2
View File
@@ -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');
+3
View File
@@ -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');
+20
View File
@@ -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([]));
}
}
+8 -9
View File
@@ -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 });
});
}
}
@@ -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'
// }])
});
}
}
@@ -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 });
}
}
}
+2 -2
View File
@@ -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;
}
}
@@ -0,0 +1 @@
{{outlet}}
@@ -0,0 +1,26 @@
<Rose::Form
@onSubmit={{route-action "authenticate" (hash
identification=this.identification
password=this.password
)}}
@cancel={{false}}
as |form|
>
<form.input
name="identification"
@type="text"
@value={{this.identification}}
@label={{t "form.label.identification"}} />
<form.input
name="password"
@type="password"
@value={{this.password}}
@label={{t "form.label.password"}} />
<form.actions
@submitText={{t "actions.authenticate"}}
@showCancel={{false}} />
</Rose::Form>
+4
View File
@@ -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');
+60
View File
@@ -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);
}
}
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});