Objectives

Refactor the donation-android client to use the JWT secured routes.

Exercises

This is the completed project from the last android app:

Report

Try the report now before making any changes.

You will notice that it seems to list the donations. Where are these coming from? We havent made any changes to the Report activity yet...

We can now adjust this - and have the report display ALL donations (not just the donations made on this device).

Before we start, make the following changes to the Report class:

Introduce a new private member:

  private DonationAdapter adapter;

.. and change the onCreate method to initialise this member:

  public void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_report);

    app = (DonationApp) getApplication();

    listView = (ListView) findViewById(R.id.reportList);
    adapter = new DonationAdapter (this, app.donations);
    listView.setAdapter(adapter);
  }

(we are just making the adapter a class member, previously it was local)

Now, import the libraries we need:

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import android.widget.Toast;

Then implement the callback:

public class Report extends AppCompatActivity implements Callback<Donation>

... and these are the required method overrides:

  @Override
  public void onResponse(Call<List<Donation>> call, Response<List<Donation>> response) {
    adapter.donations = response.body();
    adapter.notifyDataSetChanged();
  }

  @Override
  public void onFailure(Call<List<Donation>> call, Throwable t) {
    Toast toast = Toast.makeText(this, "Error retrieving donations", Toast.LENGTH_LONG);
    toast.show();
  }

and finally, in onCreate - make the call to retrieve the donations:

    Call<List<Donation>> call = (Call<List<Donation>>) app.donationService.getAllDonations();
    call.enqueue(this);

Try this now - you should see a list of all dontations.

Complete version of Report class at this stage:

package app.donation.activity;

import app.donation.R;
import app.donation.main.DonationApp;
import app.donation.model.Donation;

import android.content.Context;
import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import android.widget.Toast;

import java.util.List;

public class Report extends AppCompatActivity implements Callback<List<Donation>>
{
  private ListView listView;
  private DonationApp app;
  private DonationAdapter adapter;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_report);

    app = (DonationApp) getApplication();

    listView = (ListView) findViewById(R.id.reportList);
    adapter = new DonationAdapter (this, app.donations);
    listView.setAdapter(adapter);

    Call<List<Donation>> call = (Call<List<Donation>>) app.donationService.getAllDonations();
    call.enqueue(this);
  }

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_report, menu);
    return true;
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
      case R.id.menuDonate:
        startActivity(new Intent(this, Donate.class));
        break;
      case R.id.menuLogout:
        startActivity(new Intent(this, Welcome.class));
        break;
    }
    return true;
  }

  @Override
  public void onResponse(Call<List<Donation>> call, Response<List<Donation>> response) {
    adapter.donations = response.body();
    adapter.notifyDataSetChanged();
  }

  @Override
  public void onFailure(Call<List<Donation>> call, Throwable t) {
    Toast toast = Toast.makeText(this, "Error retrieving donations", Toast.LENGTH_LONG);
    toast.show();
  }
}

class DonationAdapter extends ArrayAdapter<Donation>
{
  private Context context;
  public List<Donation> donations;

  public DonationAdapter(Context context, List<Donation> donations) {
    super(context, R.layout.row_layout, donations);
    this.context = context;
    this.donations = donations;
  }

  @Override
  public View getView(int position, View convertView, ViewGroup parent) {
    LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    View view = inflater.inflate(R.layout.row_layout, parent, false);
    Donation donation = donations.get(position);
    TextView amountView = (TextView) view.findViewById(R.id.row_amount);
    TextView methodView = (TextView) view.findViewById(R.id.row_method);

    amountView.setText("" + donation.amount);
    methodView.setText(donation.method);

    return view;
  }

  @Override
  public int getCount() {
    return donations.size();
  }
}

Token Model + Authenticate Method

To support Tokens, we need a new model class which will hold the Json Web Tokens in our app:

app.donation.model

package app.donation.model;

public class Token
{
  public boolean success;
  public String token;
  public User user;

  public Token(boolean success, String token)
  {
    this.success = success;
    this.token = token;
  }
}

donation-web

Back on the server side we need to make some adjustments in our auth implementation:

app/api/candidatesapi.js

The get all candidates route should be made open:

exports.find = {

  auth: false,

  ...

};

This will allow an unauthenticated client to retrieve the candidates

We need to augment the authenticate method to pass user details back on a successful authentication:

app/api/usersapi.js

exports.authenticate = {

  auth: false,

  handler: function (request, reply) {
    const user = request.payload;
    User.findOne({ email: user.email }).then(foundUser => {
      if (foundUser && foundUser.password === user.password) {
        const token = utils.createToken(foundUser);
        reply({ success: true, token: token, user: foundUser }).code(201);
      } else {
        reply({ success: false, message: 'Authentication failed. User not found.' }).code(201);
      }
    }).catch(err => {
      reply(Boom.notFound('internal db failure'));
    });
  },

};

Also, the create user should be open to unauthenticated users:

exports.create = {

  auth: false,

  ...
};

Record Donor when donation is made

This was an exercise in the last lab.

First, bring in a new utility method. which will make decoding tokens a little more convenient:

app/api/utils.js

exports.getUserIdFromRequest = function (request) {
  var userId = null;
  try {
    const authorization = request.headers.authorization;
    var token = authorization.split(' ')[1];
    var decodedToken = jwt.verify(token, 'secretpasswordnotrevealedtoanyone');
    userId = decodedToken.id;
  } catch (e) {
    userId = null;
  }

  return userId;
};

app/api/donationsapi.js

Use this function to initialise the donor field in a new donation. Also , make sure, when we are creating a donation, we return the new donation including populated donor and candidate fields:

const utils = require('./utils.js');

...

exports.makeDonation = {

  auth: {
    strategy: 'jwt',
  },

  handler: function (request, reply) {
    const donation = new Donation(request.payload);
    donation.candidate = request.params.id;
    donation.donor = utils.getUserIdFromRequest(request);
    donation.save().then(newDonation => {
      return Donation.findOne(newDonation).populate('candidate').populate('donor');
    }).then(newDonation => {
      reply(newDonation).code(201);
    }).catch(err => {
      reply(Boom.badImplementation('error making donation'));
    });
  },

};

RetrofitServiceFctory

Back in the donation-android app, We need to change how we are creating the proxies to the service. Specifically we need to compose a factory class which will create 2 separate donation-web api proxies:

  • without jwt support for 'open' apis
  • with jwt support for 'closed' apis

The open version is created more or less the same way as before. The closed one will need a valid token, and will include this token with api calls makes.

app.donation.main

package app.donation.main;

import java.io.IOException;

import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
import retrofit2.Retrofit.Builder;

public class RetrofitServiceFactory
{
  public static final String API_BASE_URL = "http://10.0.2.2:4000";

  private static OkHttpClient.Builder httpClient = new OkHttpClient.Builder();

  private static Builder builder = new Builder()
                                        .baseUrl(API_BASE_URL)
                                        .addConverterFactory(GsonConverterFactory.create());

  public static <S> S createService(Class<S> serviceClass)
  {
    Retrofit retrofit = builder.client(httpClient.build()).build();
    return retrofit.create(serviceClass);
  }

  public static <S> S createService(Class<S> serviceClass, final String authToken)
  {
    if (authToken != null)
    {
      httpClient.addInterceptor(new Interceptor()
      {
        @Override
        public Response intercept(Interceptor.Chain chain) throws IOException
        {
          Request original = chain.request();
          Request.Builder requestBuilder = original.newBuilder()
              .header("Authorization", "bearer " + authToken)
              .method(original.method(), original.body());

          Request request = requestBuilder.build();
          return chain.proceed(request);
        }
      });
    }

    OkHttpClient client = httpClient.build();
    Retrofit retrofit = builder.client(client).build();
    return retrofit.create(serviceClass);
  }
}

Donation Service Interfaces

We propose 2 interfaces, one we regard as open (without an auth requirement):

app.donation.main.DonationServiceOpen

package app.donation.main;

import java.util.List;

import app.donation.model.Token;
import app.donation.model.Candidate;
import app.donation.model.User;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;

public interface DonationServiceOpen
{
  @GET("/api/candidates")
  Call<List<Candidate>> getAllCandidates();

  @POST("/api/users")
  Call<User> createUser(@Body User User);

  @POST("/api/users/authenticate")
  Call<Token> authenticate(@Body User user);
}

.. and one we consider to be closed:

app.donation.main.DonationService

package app.donation.main;

import java.util.List;

import app.donation.model.Donation;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Path;

public interface DonationService
{
  @GET("/api/donations")
  Call<List<Donation>> getAllDonations();

  @POST("/api/candidates/{id}/donations")
  Call<Donation> createDonation(@Path("id") String id, @Body Donation donation);
}

There is no real difference in how we declare these interfaces - but separating them out means we can use a different service creation mechanism for each (with or without jwt token), as presented in the last step.

Authenticate

app.main.DonationApp

The DonationApp class in restructured now to use the new authenticate route - encapsulated in the validUser and onResponse methods below:

package app.donation.main;

import java.util.ArrayList;
import java.util.List;

import android.app.Application;
import android.util.Log;
import android.widget.Toast;

import app.donation.model.Token;
import app.donation.model.Candidate;
import app.donation.model.User;
import app.donation.model.Donation;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

public class DonationApp extends Application implements Callback<Token>
{
  public DonationServiceOpen donationServiceOpen;
  public DonationService     donationService;

  public boolean         donationServiceAvailable = false;
  public String          service_url  = "http://10.0.2.2:4000";   // Standard Emulator IP Address

  public final int       target       = 10000;
  public int             totalDonated = 0;

  public User             currentUser;
  public List <Donation>  donations    = new ArrayList<Donation>();
  public List <Candidate> candidates   = new ArrayList<Candidate>();

  public boolean newDonation(Donation donation)
  {
    boolean targetAchieved = totalDonated > target;
    if (!targetAchieved)
    {
      donations.add(donation);
      totalDonated += donation.amount;
    }
    else
    {
      Toast toast = Toast.makeText(this, "Target Exceeded!", Toast.LENGTH_SHORT);
      toast.show();
    }
    return targetAchieved;
  }

  @Override
  public void onCreate()
  {
    super.onCreate();
    donationServiceOpen = RetrofitServiceFactory.createService(DonationServiceOpen.class);
    Log.v("Donation", "Donation App Started");
  }

  public boolean validUser (String email, String password)
  {
    User user = new User ("", "", email, password);
    donationServiceOpen.authenticate(user);
    Call<Token> call = (Call<Token>) donationServiceOpen.authenticate (user);
    call.enqueue(this);
    return true;
  }

  @Override
  public void onResponse(Call<Token> call, Response<Token> response) {
    Token auth = response.body();
    currentUser = auth.user;
    donationService =  RetrofitServiceFactory.createService(DonationService.class, auth.token);
    Log.v("Donation", "Authenticated " + currentUser.firstName + ' ' + currentUser.lastName);
  }

  @Override
  public void onFailure(Call<Token> call, Throwable t) {
    Toast toast = Toast.makeText(this, "Unable to authenticate with Donation Service", Toast.LENGTH_SHORT);
    toast.show();
    Log.v("Donation", "Failed to Authenticated!");
  }
}

In onResponse above, we are creating the closed route proxy using the token we have just received (if we had the correct credentials).

Welcome

The welcome activity can be simplified, as we are no longer downloading a full list of users...

app.activities.Welcome

package app.donation.activity;

import app.donation.R;
import app.donation.main.DonationApp;
import app.donation.model.Candidate;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

import android.content.Intent;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Toast;

import java.util.List;

public class Welcome extends AppCompatActivity
{
  private DonationApp app;

  @Override
  public void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_welcome);

    app = (DonationApp) getApplication();
  }

  @Override
  public void onResume()
  {
    super.onResume();
    app.currentUser = null;;

    Call<List<Candidate>> call = (Call<List<Candidate>>) app.donationServiceOpen.getAllCandidates();
    call.enqueue(new Callback<List<Candidate>>() {
      @Override
      public void onResponse(Call<List<Candidate>> call, Response<List<Candidate>> response) {
        serviceAvailableMessage();
        app.candidates = response.body();
      }

      @Override
      public void onFailure(Call<List<Candidate>> call, Throwable t) {
        serviceUnavailableMessage();
      }
    });
  }

  public void loginPressed (View view)
  {
    if (app.donationServiceAvailable)
    {
      startActivity (new Intent(this, Login.class));
    }
    else
    {
      serviceUnavailableMessage();
    }
  }

  public void signupPressed (View view)
  {
    if (app.donationServiceAvailable)
    {
      startActivity (new Intent(this, Signup.class));
    }
    else
    {
      serviceUnavailableMessage();
    }
  }

  void serviceUnavailableMessage()
  {
    app.donationServiceAvailable = false;
    Toast toast = Toast.makeText(this, "Donation Service Unavailable. Try again later", Toast.LENGTH_LONG);
    toast.show();
  }

  void serviceAvailableMessage()
  {
    app.donationServiceAvailable = true;
    Toast toast = Toast.makeText(this, "Donation Contacted Successfully", Toast.LENGTH_LONG);
    toast.show();
  }
}

Signup

Signup has minor changes to use the open api proxy:

app.activities.Signup

package app.donation.activity;

import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

import app.donation.R;
import app.donation.main.DonationApp;
import app.donation.model.User;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

public class Signup extends AppCompatActivity implements Callback<User>
{
  private DonationApp app;

  @Override
  protected void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_signup);
    app = (DonationApp) getApplication();
  }

  public void signupPressed (View view)
  {
    TextView firstName = (TextView)  findViewById(R.id.firstName);
    TextView lastName  = (TextView)  findViewById(R.id.lastName);
    TextView email     = (TextView)  findViewById(R.id.Email);
    TextView password  = (TextView)  findViewById(R.id.Password);

    User user = new User(firstName.getText().toString(), lastName.getText().toString(), email.getText().toString(), password.getText().toString());

    DonationApp app = (DonationApp) getApplication();
    Call<User> call = (Call<User>) app.donationServiceOpen.createUser(user);
    call.enqueue(this);
  }

  @Override
  public void onResponse(Call<User> call, Response<User> response)
  {
    Toast toast = Toast.makeText(this, "Signup Successful", Toast.LENGTH_LONG);
    toast.show();
    startActivity(new Intent(this, Welcome.class));
  }

  @Override
  public void onFailure(Call<User> call, Throwable t)
  {
    app.donationServiceAvailable = false;
    Toast toast = Toast.makeText(this, "Donation Service Unavailable. Try again later", Toast.LENGTH_LONG);
    toast.show();
    startActivity (new Intent(this, Welcome.class));
  }
}