chore: add core authentication flow with acceptance tests
This commit is contained in:
14 files changed
+290
-13
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([]));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user