A login page for the frontend

Let us start by add a login page to our VueJS-based frontend application. Copy the following snippet into file login.vue.

<template>
<div class="row">
<div class="well col-sm-offset-3 col-sm-6">
<h2>Please log in</h2>
<div>
  <div class="form-group">
    <label for="username">Username:</label>
    <input type="text" class="form-control" id="username" v-model="username">
  </div>
  <div class="form-group">
    <label for="password">Password:</label>
    <input type="text" class="form-control" id="password" v-model="password">
  </div>
  <button type="submit" class="btn btn-default" v-on:click="login">Submit</button>
</div>
</div>
</div>
</template>
<script>
// import auth from "./auth";

export default {
    data: function () {
        return {
            username: "",
            password: ""
        }
    },
    methods: {
        login: function() {
            console.log(`Username ${this.username}, password ${this.password}`);
            // auth.login(this, {username: this.username, password: this.password}, "/");
        }
    }
}
</script>

You can clearly see that the code above refers to another component, namely auth, which will introduced later. We will uncomment the corresponding lines later on.

Just to have a glimpse of the log in page, let us add it as a VueJS component and also include it into the EEX template. To that end, update the file web/templates/page/index.html.eex with the following snippet:

<div id="takso-app">
  <login></login>
  <customer></customer>
  <driver></driver>
</div>

Now, update the file app.js as follows:

import Vue from "vue";

import "axios";
import "./socket";
// import auth from "./auth";

import customer from "./customer";
import driver from "./driver";
import login from "./login";

Vue.component("customer", customer);
Vue.component("driver", driver);
Vue.component("login", login);

new Vue({}).$mount("#takso-app");

Authentication for our frontend/REST API

The approach to authentication in the frontend application is very similar to the one used for the EEX-based application. We would send the user credentials (i.e. username and password) to the backend using an HTTP POST, but this time using the URL /api/sessions. To this end, we will use again our good friend axios. The code below provides a first approximation to the required code.

import axios from "axios";

export default {
    user: { role: "", username: "" },
    login: function (context, creds, redirect) {
      axios.post("/api/sessions", creds)
        .then(response => {
          console.log(response);
        })
        .catch( error => {
            console.log(error);
        });
    }
}

Do not forget to uncomment the lines of code refering to auth within files login.vue and app.js. If you try the code, however, we will see that it does not work. Why? The reason is that we have not a controller to process the request in the backend. Let us fix this problem:

  • Add the route into web/router.ex as follows:

    scope "/api", Takso do
      pipe_through :api
      post "/bookings", BookingAPIController, :create
      post "/sessions", SessionAPIController, :create
    end
    
  • Add also the implementation of web/controllers/session_api_controller.ex

    defmodule Takso.SessionAPIController do
      use Takso.Web, :controller
      alias Takso.{Repo,User,Authentication}
    
      def create(conn, %{"username" => username, "password" => password}) do
        user = Repo.get_by(User, username: username)    
        case Authentication.check_credentials(conn, user, password) do
        {:ok, conn} ->
            {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token)
            conn
            |> put_status(201)
            |> json(%{token: jwt, role: user.role})
        {:error, conn} ->
            conn
            |> put_status(400)
            |> json(%{message: "Bad credentials"})
        end
      end
    
      def delete(conn, _params) do
        {:ok, claims} = Guardian.Plug.claims(conn)
        conn
        |> Guardian.Plug.current_token
        |> Guardian.revoke!(claims)
    
        conn
        |> put_status(200)
        |> json(%{msg: "Good bye"})
      end
    
      def unauthenticated(conn, _params) do
        conn
        |> put_status(403)
        |> json(%{msg: "You are not logged in"})
      end
    end  
    

If you compare the code above with the one of web/controllers/session_controller.ex you will notice that we changed the call to Guardian. Instead of signing in the user, we are going to ask Guardian to generate a JSON Web token (JWT) with the call Guardian.encode_and_sign(user, :token). Later we send the JWT back to the front end as part of the json generated by the controller from %{token: jwt, role: user.role}. Note that, in addition to the JWT, the controller returns the role associated to the recently authenticated user. The user role will be later used within the frontend application to implement a very simple role-based access scheme.

The code above also includes the function that implements the code to destroy the session, that is, to log out. Almost all the lines in function delete/2 are devoted to retrieve the token for the currently logged in user and for later revoking his/her access to the system.

We still need to update the router file to connect Guardian plugs in a pipeline that we will associate to the scope api. Please consider the following snippet to that end:

  pipeline :api do
    plug :accepts, ["json"]
    plug Guardian.Plug.VerifyHeader
    plug Guardian.Plug.LoadResource
  end

  pipeline :auth_api do
    plug Guardian.Plug.EnsureAuthenticated, handler: Takso.SessionAPIController    
  end

  scope "/api", Takso do
    pipe_through :api
    post "/sessions", SessionAPIController, :create
  end

  scope "/api", Takso do
    pipe_through [:api, :auth_api]
    delete "/sessions/:id", SessionAPIController, :delete
    post "/bookings", BookingAPIController, :create
  end

As for the version for EEX-based interaction, we have to define separated pipelines: one is to be defined so as to check the presence of the JWS on the headers of the HTTP request and to add the information of the user (i.e. current_resource) to the connection. The second one is to instruct Guardian to enforce access to some controller actions to happen only after the user is authenticated. We have also separated the routes accordingly.

It is a good time to restart the application, enter some valid credentials (remember that we have a seed file) and verify that the frontend and backend are communicating as expected. (Check the javascript console on your browser to see that the value of JWT and the user’s role are printed out there).

Well, you will notice that the login page is working but all the other interactions are broken. This is normal, because now all the other interactions must include the JWS because the REST API is secured by Guardian.

To fix this problem we need to complete auth.js. Replace the current content of that file with following code:

import axios from "axios";

export default {
  user: { role: "", username: "" }, 
  login: function (context, creds, redirect) {
    axios.post("/api/sessions", creds)
      .then(response => {
        this.username = creds.username;
        this.user.role = response.data.role;
        window.localStorage.setItem('token-'+this.username, response.data.token);

        if (redirect)
          context.$router.push({path: redirect});
      })
      .catch(error => {
        console.log(error);
      });
    },
    logout: function(context, options) {
      axios.delete("/api/sessions/1", options)
        .then(response => {
          window.localStorage.removeItem('token-'+this.username);
          this.user.authenticated = false;
          this.user.username = "";
          context.$router.push({path: '/login'});
        }).catch(error => {
          console.log(error)
        });
    },
    authenticated: function() {
      const jwt = window.localStorage.getItem('token-'+this.username);
      return !!jwt;
    },
    getAuthHeader: function() {
      return {
        'Authorization': window.localStorage.getItem('token-'+this.username)
      }
    }
}

As you can see, we are copying the username and the role of the user to a local object. In addition, we are storing the JWT using HTML5 localStorage. The latter can be thought as a sort of persistent association table (a map) available to the javascript frontend application to store application data. We store there the JWT using the concatenation of “token-“ + this.username as a key. That way, we can retrieve the value of the JWT whenever we need to check if the user is authenticated or when we want to add the authentication header to any REST interaction. That said, we need to fix the REST interactions. To illustrate the changes that we need to apply consider the patched function call used for submitting a booking request (file customer.vue):

submitBookingRequest: function() {
  axios.post("/api/bookings", 
      {pickup_address: this.pickup_address, dropoff_address: this.dropoff_address},
      { headers: auth.getAuthHeader() })
      .then(response => {
          this.messages = response.data.msg;
      }).catch(error => {
          console.log(error);
      });
}

As you can see, the HTTP POST request includes, in addition to the booking information, the authentication header (i.e. { headers: auth.getAuthHeader() }).

VueJS router

Let us now introduce a VueJS related tool that will become handy to organize pages/components in large applications, namely the vue-router. In a nutshell, this tool provides a similar functionality as the one provided by Phoenix’s router.

First, replace fully the content of file app.js with the following javascript code.

import Vue from "vue";
import VueRouter from "vue-router";

import login from "./login";
import customer from "./customer";
import driver from "./driver";
import main from "./main";

import "phoenix";
import "axios";
import "./socket";

Vue.use(VueRouter);

Vue.component("customer", customer);
Vue.component("driver", driver);

var router = new VueRouter({
    routes: [
        { path: '/login', component: login},
        { path: '/', component: main},
        { path: '*', redirect: '/login' }
    ]
});

new Vue({
    router
}).$mount("#takso-app");

I consider that the syntax to define routes is very intuitive such that no additional explanation is required, except for the use of path “*” that serves to specify where to go whenever the application or user specify something not defined as recognized route. Apart from that, you can easily see that we defined routes for only two components: one that renders the login page and a new one called main.vue. The code code corresponding to this component is as follows:

<template>
<div>
  <customer></customer>
  <driver></driver>
  <div>
    <p class="pull-right">Already tired? <a href="/login" @click.prevent="logout">Log out!</a></p>
  </div>
</div>  
</template>

<script>
import auth from "./auth";

export default {
    methods: {
        logout: function() {
            auth.logout(this, { headers: auth.getAuthHeader() });
        }
    }
}
</script>

In fact, we are basically moving the <div/> element that was defined within web/templates/page/index.html.eex. Well, we have also added a hyperlink to let the users log out the application. Note that the function logout simple calls the function defined within `auth.js. Note that in order to log out we would need to include the authentication header as part of the REST interaction.

Since the content of web/templates/page/index.html.eex has moved elsewere, we would ne to update it as follows:

<div id="takso-app">
  <router-view></router-view>
</div>

The tag “router-view” is a placeholder where VueJS router will insert one VueJS component according to the selected route.

Completing the round-trip

We basically have most of the components. Let me propose you to change a little bit the driver’s interface. Change the current content of web/static/js/driver.vue with the following code:

<template>
<div class="well">
  <div class="alert alert-info" v-if="visible">
    New booking request.
    <li>Pickup address: <strong></strong>,</li>
    <li>dropoff address: <strong></strong></li>
    <button class="btn btn-default" v-on:click="submitDecision({status: 'accepted'})">Accept</button>
    <button class="btn btn-danger" v-on:click="submitDecision({status: 'rejected'})">Reject</button>
  </div>
  <div id="map-driver" style="width:100%;height:300px"></div>
</div>
</template>
<script>
import axios from "axios";
import auth from "./auth";
import socket from "./socket";

export default {
  data: function() {
    return {
      pickup_address: "",
      dropoff_address: "",
      visible: false
    }
  },
  methods: {
    submitDecision: function (decision) {
      if (this.message) {
        axios.patch("http://localhost:4000/api/bookings/" +this.message.booking_id, 
            {status: decision.status}, {headers: auth.getAuthHeader()})
        .then( response => {
            console.log("Received:", response );
        }).catch( e => console.log("Oops"));
        this.message = null;
        this.visible = false;
      }
    }
  },
  mounted: function() {
    var channel = socket.channel("driver:lobby", {});
    channel.join()
        .receive("ok", resp => { console.log("Joined successfully", resp) })
        .receive("error", resp => { console.log("Unable to join", resp) });
    channel.on("requests", payload => {
        this.visible = true;
        this.pickup_address = payload.pickup_address;
        this.dropoff_address = payload.dropoff_address;
        this.message = payload;
    });
  }
}
</script>

As you can see, we added a nicely formatted dialog to notify the taxi driver about a ride request. The dialog provides also buttons to let the driver decide whether to accept or reject the request. Note that I decided to use only one function to handle both cases. The function receives the driver’s decision as a parameter. Then function then notifies the backend with the decision using an HTTP PATCH on "/api/bookings/:booking_id" (well, booking_id is set to the right value according to the parameter received before). We will then need to add the function in the backend to process this action. Let me propose you the following code. Please update the backend accordingly (add routes, etc.)

def update(conn, params) do
  Takso.Endpoint.broadcast("customer:lobby", "requests", %{msg: "Your taxi will arrive in 5 minutes"})
  conn
  |> put_status(200)
  |> json(%{msg: "Notification sent to the customer"})
end

Well, the code above is simplified as it will simulate the case where the driver accepts the booking: it simply notifies the customer that the taxi will arrive in 5 minutes. Of course, we would need to update the customer component to properly handling the push notification. Copy then the following code into web/static/js/customer.vue.

export default {
  ...
  mounted: function() {
    var channel = socket.channel("customer:lobby", {});
    channel.join()
      .receive("ok", resp => { console.log("Joined successfully", resp) })
      .receive("error", resp => { console.log("Unable to join", resp) });

    channel.on("requests", payload => {
      this.message += "\n" + payload.msg;
    });
  }
}

Authentication on VueJS routes

You can check that all the vue components are accessible no matter whether the user is logged in or not. To solve this issue, we will need to add some guards to the VueJS routes. Copy the following code into the file web/static/js/app.js (be careful with the code that you remove or keep).

import auth from './auth'

const requireAuth = (to, _from, next) => {
  if (!auth.authenticated()) {
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    });
  } else {
    next();
  }
}

const afterAuth = (_to, from, next) => {
  if (auth.authenticated()) {
    next(from.path);
  } else {
    next();
  }
}

Vue.use(VueRouter);

Vue.component("customer", customer);
Vue.component("driver", driver);

var router = new VueRouter({
    routes: [
        { path: '/login', component: login, beforeEnter: afterAuth },
        { path: '/', component: main, beforeEnter: requireAuth },
        { path: '*', redirect: '/' }
    ]
});

You will notice now that you will not be allowed to see the booking form nor the driver’s view unless authenticated. However, once authenticated the user can access to both customer and driver’s components. Let us use the role information to change this situation. Update main.vue as follows:

<template>
<div>
  <customer v-if="getUserRole() === 'customer'"></customer>
  <driver v-if="getUserRole() === 'taxi-driver'"></driver>
  <div>
    <p class="pull-right">Already tired? <a href="/login" @click.prevent="logout">Log out!</a></p>
  </div>
</div>  
</template>
<script>
import auth from "./auth";

export default {
    methods: {
        logout: function() {
            auth.logout(this, { headers: auth.getAuthHeader() });
        },
        getUserRole: () => auth.user.role
    }
}
</script>

In the code, the attribute v-if="getUserRole() === 'customer'" does the magic: it will render only one component depending on the role of the user. Witht this, we have completed our simple role-based access.

Authenticating Phoenix channels

Gardian is versatile library that can be used for authenticating EEX-based applications, REST APIs and also phoenix channels. In this section, we will update the code to that end. By the same token, we will learn how to allocate private channels for drivers and customers.

The idea is to renew the socket connection whenever the user logs in the system. Similarly, we will dereferrence the socket to let the garbage collector to destroy it after the user logs out. Hence forth, let us migrate the code for initializing/dereferrencing the socket to the file auth.js.

import axios from "axios";
import {Socket} from "phoenix";

export default {
  user: { role: "", username: "" },
  socket: null, 
  login: function (context, creds, redirect) {
    axios.post("/api/sessions", creds)
      .then(response => {
        this.username = creds.username;
        this.user.role = response.data.role;
        window.localStorage.setItem('token-'+this.username, response.data.token);

        this.socket = new Socket("/socket", {params: {token: response.data.token}});
        this.socket.connect();
        if (redirect)
          context.$router.push({path: redirect});
      })
      .catch(error => {
        console.log(error);
      });
  },
  logout: function(context, options) {
    axios.delete("/api/sessions/1", options)
      .then(response => {
        window.localStorage.removeItem('token-'+this.username);
        this.user.authenticated = false;
        this.user.username = "";
        this.socket = null;
        context.$router.push({path: '/login'});
      }).catch(error => {
        console.log(error)
      });
  },
  getChannel: function(prefix) {
    var token = window.localStorage.getItem('token-'+this.username);
    var channel = this.socket.channel(prefix + this.username, { guardian_token: token });return channel;
  },
  ...
}

It is worth noting that we are adding the JWT to the calls for creating the socket and channel. That is the reason why I decided to migrate the code to this file. It also important to note that we have added one additional function which creates a channel with a name composed by a prefix (e.g. “driver:” or “customer:”) concatenated with the user username. This is the way we create/refer to “private” channels.

Let us also update driver.vue to use the private channels as follows:

<script>
import axios from "axios";
import auth from "./auth";
// import socket from "./socket"; This line is not longer required

export default {
  ...
  mounted: function() {
    if (auth.socket) {
      var channel = auth.getChannel("driver:");
      channel.join()
          .receive("ok", resp => { console.log("Joined successfully", resp) })
          .receive("error", resp => { console.log("Unable to join", resp) });
      channel.on("requests", payload => {
          this.visible = true;
          this.pickup_address = payload.pickup_address;
          this.dropoff_address = payload.dropoff_address;
          this.message = payload;
      });
    }
  }
}

In the backend, however, we still refer only the default channel for drivers and the one for customer (e.g. “driver:lobby”). Let us then update the definition of channels. In fact, I propose you to replace entirely the code for Takso.DriverChannel with the following one:

defmodule Takso.DriverChannel do
  use Takso.Web, :channel
  use Guardian.Channel

  def join("driver:"<>owner, %{claims: _, resource: %{username: username}}, socket)
  when username == owner do
    {:ok, socket}
  end
  
  def join(_room, _, socket) do
    {:error, :unauthorized}
  end
end

Note that the code above include Guardian.Channel. For that reason, the function join/3 can be redefined so as to refer to the map %{claims: _, resource: %{username: username}}, which corresponds to information derived by Guardian from the JWT. Note that the name of the channel is pattern matched with the expression "driver:"<>owner. Remember that in the frontend, we are using "driver:" concatenated with the username. It is for this reason that we can match the owner of the channel with the currently logged in user’s username.

From the code above, I will ask you to infer the new version of web/channels/customer_channel.ex. Do not forget that you will need to update several places in the project where we use channels. For instance, we would need to update the following function on web/controllers/bookings_api_controller.ex.

  def create(conn, params) do
    user = Guardian.Plug.current_resource(conn)
    query = from t in Taxi, where: t.status == "available", select: t
    available_taxis = Repo.all(query)
    if length(available_taxis) > 0 do
      taxi = List.first(available_taxis)
      Takso.Endpoint.broadcast("driver:"<>taxi.username, "requests", params |> Map.put(:booking_id, 1))
      Takso.TaxiAllocator.start_link(params)
      
      conn
      |> put_status(201)
      |> json(%{msg: "We are processing your request"})
    else
      conn
      |> put_status(406)
      |> json(%{msg: "Our apologies, we cannot serve your request in this moment"})
    end
  end