This is part 2 of our series on developing a Decoupled Drupal Client Application with Ember. If you haven't yet read Part 1, it would be best to review Part1 first, as this article continues on with adding authentication and login form to our application. Shortly, we will explore how to create a new article but for that we will need to have authentication working so that we can pass in our credentials when posting our new article.

There are different authentication methods that we could implement but we are going to add Cookie based authentication, which is part of Drupal 8 core. Other authentication methods are provided via contrib for Drupal - refer to the D8 REST documentation pages and the page on Using other Authentication protocols.

The ember add on that we will be using is Ember-Simple-Auth. This addon is well maintained and provides the main building blocks that will allow us to implement a custom authenticate method with a client session store. The session store will be used to persist the account login tokens and our login state information between page refreshes of the ember client using local browser storage. The session store, this module provides is a implemented as an Ember.Service that is injected into our application components and controllers. This provided service is also very useful for persisting other application related data.

We will also need the Ember-Ajax addon which is a wrapper around the jQuery ajax method. It's also implemented as an Ember.Service and we will extend the ajax service to define our host URL, but we also have access to custom error handing, customizing the endpoints or headers as well - refer to the addon git page for more info.

Ok, so let's look at the code and how we install and setup these addon's using the Ember CLI.

Install the two required addons using the Ember CLI

  • ember install ember-ajax
  • ember install ember-simple-auth

Generate the ajax service that we will use to customize the provided ember-ajax service

  • ember generate service ajax

Modify the generated service/ajax.js file as per below but be sure to enter the URL for the correct host: property so the AJAX requests are being sent to your server. We had to do the same in Part 1 for the Ember-Data JSONAPI Adapter. Note: I will show you shortly how to setup a ember app config file to have one place to setup these environment variables. 

import Ember from 'ember';
import AjaxService from 'ember-ajax/services/ajax';

export default AjaxService.extend({
    host: '<enter the url for your site>',

    isSuccess(status, headers, payload ) {
        let isSuccess = this._super(...arguments);
        if (isSuccess && status === 200) {
            console.log('Ajax Service success 200', status, headers, payload);
        }
        else {
            console.log('Ajax Service NOT successful', status, headers, payload);
        }
        return isSuccess;
    }
});

Now, we need to add our custom authenticator for the ember-simple-auth addon. The authenticator will implement the authenticate method to login, restore method that will restore our saved login state information between requests and client page refreshes as well as the invalidate method that is called on logout to destroy our session. We will use the Ember CLI to generate the stub for us and update it's registry but then modify it. The code below is a complete replacement for the drupal.js file - let's call our authenticator drupal. 

  • ember generate authenticator drupal
// app/authenticators/drupal.js
import Ember from 'ember';
import Base from 'ember-simple-auth/authenticators/base';
import {isAjaxError, isBadRequestError, isNotFoundError, isForbiddenError} from 'ember-ajax/errors';

export default Base.extend({
 ajax: Ember.inject.service(),
 username: '',
 
 restore: function(data) {
   console.log('authenticators/drupal.js - restore action', data);
   return new Ember.RSVP.Promise(function (resolve, reject) {
     if (!Ember.isEmpty(Ember.get(data, 'name'))
        && !Ember.isEmpty(Ember.get(data, 'logout_token'))
        && !Ember.isEmpty(Ember.get(data, 'csrf_token'))
      ) {
       resolve(data);
      }
     else {
       reject();
     }
   });
 },

 authenticate(name, pass) {
   const loginData = JSON.stringify({name: name, pass: pass});
   console.log('authenticators/drupal.js - loginData', loginData);
   return this.get('ajax').request('/user/login?_format=json', {
     method: 'post',
     data: loginData,
     xhrFields: {
       withCredentials: true,
     },

   }).then(function (response) {
     console.log('authenticators/drupal.js - successful result', response);
     return { name, logout_token: response.logout_token, csrf_token: response.csrf_token };

   }).catch(function (error) {
     if(isBadRequestError(error)) {
       console.log('authenticators/drupal.js - Bad Login - 400');
       return;
     }
     if(isAjaxError(error)) {
       console.log('authenticators/drupal.js - Ajax Error');
       return;
     }
     console.log('authenticators/drupal.js - error', error);
   });
 },

 invalidate(data) {
   // This is where I add the call to user/logout
   console.log('authenticators/drupal.js - invalidate action', data);
   let logout_token = data.logout_token;
   let csrf_token = data.csrf_token;
   console.log('logout token is', logout_token);
   if(logout_token != null) {
     let result =  this.get('ajax').request('/user/logout?_format=json&token=' + logout_token + '&csrf_token=' + csrf_token, {
       method: 'post',
       xhrFields: {
         withCredentials: true,
       },
     }).then(function (response) {
       // Not executing when we get a 200 response - possibly related to: https://github.com/ember-cli/ember-ajax/issues/101
       // But we are expecting a 204 from Drupal on a successfull logout so we are fine
       console.log('authenticators/drupal.js - logout successful result', response);
     }).catch(function (error) {
       console.log('authenticators/drupal.js - Logout Request Error', error);
     });
     return result;
   }
   else {
     console.log('Logout Token is not set');
     this._super(...arguments);
   }
 },
});

A few notes about the above authenticator code

  • We implemented the three methods and have injected the ajax service which is used in the authenticate and invalidate methods to login and logout. The ajax service returns a promise that creates an async request to the server and will await for the response from the server. The response is then tested and if success, we execute the then() or use the AJAX returned result to report the error in the catch() functions. The Drupal /user/login REST API will return two tokens that need to be saved in our session store. A session cookie is also returned for the browser to send automatically along for subsequent requests to keep the app logged in. Well it should except we will see shortly there is an issue but more about that in a min. Let's finish wiring this up so we can test out the login and see the issue.
  • For both ajax requests this.get('ajax').request, we are passing in the URL for the request as well as an settings array for the jQuery AJAX function. We need to pass in the option to set extra fields on the XHR request for cross-domain-requests. Since we are (in my case and maybe your case) running the local ember app on localhost:4200 and the website is accessed on http://d8site6.dd:8083 (using devdesktop). So different domain and different port. You will have a CORS issue if either port or domain are different.

Our app needs a login form so the user can enter their login credentials. Modify the app/templates/application.hbs file as per below. For now we will keep it simple but once we have the login working, we will come back and improve this so the logout button will render instead of the login form.

You will notice that as soon as you save the changes to the application.hbs file, the form appears but you will see an error in the browser console because, On Submit, this form is calling an 'authenticate' action which we need to implement. 

{{! app/templates/application.hbs }}
<h1>My Sample Ember App using Drupal</h1>

<div style="margin-top:20px;padding:20px;">
  <form {{action 'authenticate' on='submit'}}>
      {{input value=name placeholder='Login'}}
      <div style="margin-top:5px;">{{input value=pass placeholder='Password' type='password'}}</div>
      <div style="padding-top:10px;"><button type="submit">Login</button></div>
  </form>
</div>

{{outlet}}

We haven't yet discussed Ember Controllers but this is where we will add our logic to handle the login form actions. Our application as it is now has a default route when we access http://localhost:4200 and the app renders the application.hbs template. By default then Ember will be looking for a controller called application as well. Use the ember cli to generate the application controller and then modify as below which shows the code for the authenticate action.

  • ember generate controller application
import Ember from 'ember';
import {isAjaxError, isBadRequestError, isNotFoundError, isForbiddenError} from 'ember-ajax/errors';

export default Ember.Controller.extend({
    ajax: Ember.inject.service(),
    session: Ember.inject.service('session'),

    actions: {

        authenticate() {

            // Use the ember-simple-auth addon authenticate action in authenticator/drupal.js
            let { name, pass } = this.getProperties('name', 'pass');
            console.log('call the authenticator');
            var router = Ember.getOwner(this).lookup('router:main');
            this.get('session').authenticate('authenticator:drupal', name, pass).then(function () {
                console.log('authentication completed successfully')
                router.transitionTo('/');
            }).catch((reason) => {
                this.set('authenticate errorMessage', reason.error || reason);
            });

        },

    }

});

Test out the login

You will now see the login form and we can test out our app. If your watching the browser debugger, you will see the debugging information we are sending out in the console.log statements.

Chrome Debugger showing REST login API results.

Oh no, we have an error

But we have a 403 error and an Error Message complaining about the Access-Controll-Allow-Credentials header.  Our application is sending the correct request and asking the server to allow a cross domain request but the server is not sending back the expected headers acknowledging to the browser that it accepts the request. The reason is drupal, we need to enable this ability via the services.yml file. We need to tell Drupal to set this header. Set this option TRUE and then be sure to clear your site drupal cache or the change won't be picked up. In the cors.config: section (currently last line in the services.yml file)

    # Sets the Access-Control-Allow-Credentials header.
    supportsCredentials: false

 

Let's try the login now with Drupal site CORS enabled

Success, we get a HTTP 200 from the login request and the tokens are returned. If you explore the browser debugger further, under Network tab, you can view the request and response headers and see the Drupal site session cookie is also sent. Drupal now sets the headers correctly on the response to satisfy the browser requirements for CORS.

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:4200

Screenshot of devtools showing successful login

If the login did not work

  • Be sure you cleared the Drupal site cache after making any changes to services.yml
  • If your using the same browser but different tabs to run the ember app and as well as logged into the drupal site, then you will not be able to login because the browser tabs are using the same session and drupal will think your already logged. Logout of the drupal site and try the ember app login again. I recently created a drupal issue about this and Wim Leers as already submitted a working patch to improve the message returned.
  • If need, purge the sessions table for the site and clear out any active or stale sessions.

Now let's add the Logout functionality

Modify the application.hbs template to test if we are logged in by checking the session store isAuthenticated property and if TRUE then show a logout button that will fire the logout action that we will add to the application controller.

{{! app/templates/application.hbs }}
<h1>My Sample Ember App using Drupal</h1>

<div style="margin-top:20px;padding:20px;">
    {{#if session.isAuthenticated}}
        <button {{action "logout"}}>Logout</button>
    {{else}}
        <form {{action 'authenticate' on='submit'}}>
            {{input value=name placeholder='Login'}}
            <div style="margin-top:5px;">{{input value=pass placeholder='Password' type='password'}}</div>
            <div style="padding-top:10px;"><button type="submit">Login</button></div>
        </form>
    {{/if}}
</div>

{{outlet}}

Add the logout action now to the controllers/application.js file

        logout() {
            // Use the ember-simple-auth addon invalidate action in authenticator/drupal.js
            let isAuthenticated = this.get('session.isAuthenticated');
            console.log('Logout Action - isAuthenticated:', isAuthenticated);

            this.get('session').invalidate().then(function (response) {
                    console.log('logout promise successful response ', response);
                }
            ).catch(function (error) {
                    console.log('logout promise Error ', error);
                }
            );
            return;

        },

Final Testing

You should now be able to login and upon a success, the front page login form will change to a logout button. Clicking on the logout button will fire the logout action in application.js that will call the invalidate method in authenticators/drupal.js which makes the server request to /user/logout and passes the logout token and csrf_token. I found that it was necessary to send the csrf_token as well.

Next in Part 3 of this series, we will refactor our app and create a separate component for the login and apply some basic theming to our app with a menu. In Part 4, lets add the ability to create a new article.

Additional background references: