L'objectif est de créer une API Rails connectée à une application Ionic2. L'identification se fait côté client via un plugin natif, il faut donc que l'API ne gère pas l'identification mais garantisse le cloisonnement et la sécurité des utilisateurs.

En règle générale l'API devrait être apatride et ne devrait pas donc avoir de connaissance des cookies ou des sessions. Il ne stocke rien sur le serveur, mais crée un jeton codé unique qui est vérifié chaque fois qu'une demande est faite.

Pour se faire, le client va s'identifier à Facebook qui va lui retourner un token. Ce token va ensuite être utilisé dans chaque requête utilisateur. Le serveur Rails doit donc pour chaque requête, valider le token auprès de Facebook afin de garantir la sécurité des utilisateurs.

Transmission du tocken Facebook

Nous allons commencer par le client.

Tout d'abord, nous installons le plugin cordova-plugin-facebook4. Pour cela il nous faut notre ID et nom d'application Facebook que nous renseignerons lors de l'installation du plugin dans les variables APP_ID et APP_NAME.

$ cordova plugin add cordova-plugin-facebook4 --save --variable APP_ID="" --variable APP_NAME=""

Provider

Ensuite, il est nécessaire de créer un provider qui pourra être appelé à chaque requête. Dans src/app/providers, créons le script identification.ts.

import { Injectable } from '@angular/core';
import { Facebook } from 'ionic-native';
import { Headers } from '@angular/http';

@Injectable()
export class Identification {
  /** L'adresse de note api. */
  public apiUrl:string = ENV.API_URL;
  /** Objet contenant les informations de connexion facebook */
  private payload: any;

  constructor() { }

  /**
   * Fonction retournant le payload.
   * @param {function} fnct La fonction qui retourne l'information.
   */
  getStatus = (fnct) => { 
    Facebook.getLoginStatus()
      .then(payload => {
        this.payload = payload;
        if(payload.status != "connected") this.login(fnct);
        else                              fnct(payload);
      })
      .catch(err => alert(`Error: ${err}`));
  }

  /**
   * Identifie l'utilisateur.
   * @param {function} fnct La fonction qui retourne l'information.
   */
  login = (fnct) => {
    Facebook.login(['email'])
      .then(payload => {
        fnct(payload);
      })
      .catch(err => alert(`Error: ${err}`));
  }

  logout = () => {
    Facebook.logout()
      .then(payload => {
        this.payload = payload;
      })
      .catch(err => alert(`Error: ${err}`));
  }

  /**
   * Enrichi le Header du token pour l'identification côté serveur. 
   * @return {Headers} Les informations d’autorisation. 
   */
  getHeaders() {
    let headers = new Headers();
    if(this.payload.status != "connected") return headers;
    headers.append('Content-Type', 'application/json');
    headers.append('Authorization', 'Token ' + this.payload.authResponse.accessToken);
    return headers;
  }

}

Nous pouvons maintenant obtenir notre header avec le token du client via getHeaders().

Demander le token

Pour l'utilisation, il suffit maintenant d'appeler ce service dans les providers.

import { Injectable } from '@angular/core';
import { Http, RequestOptions, URLSearchParams } from '@angular/http';
import { Identification } from './identification';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';

@Injectable()
export class People {
  constructor(private http: Http, private identification: Identification) { }

  get(object) {
    let url = this.identification.apiUrl+"/tastings";
    let options: RequestOptions = new RequestOptions({headers: this.identification.getHeaders()});
    let params: URLSearchParams = new URLSearchParams();
    params.set("name", object.name);
    options.search = params;
    return this.http.get(url, options).map(res => res.json());
  }
}

Ce provider serra lui appelé par le script d'une page tel que src/pages/people/people.ts.

import { Component } from '@angular/core';
import { Identification } from '../../providers/identification';
import { People } from '../../providers/people';

@Component({
  selector: 'page-people',
  templateUrl: 'people.html',
  providers: [Identification, People]
})
export class PeoplePage {
  public peopleList;
  public searchObject = {"name": ""};

  constructor(
    private identification: Identification,
    private people: People) 
  {
  }

  /**
   * Récupère la liste des gens.
   */
  getPeople(fnct){
    var self = this;
    this.identification.getStatus(function(payload){
      self.people.getAll(self.searchObject).subscribe(
        data => {
          self.peopleList = data;
        },
        err => { 
          console.log('get_people failed') 
        },
        () => { 
          console.log('get_people completed') 
        }
      );
    });
  }

}

Et dans le HTML src/pages/people/people.ts

<ion-item *ngFor="let user of peopleList" (click)="showDetail($event, user)">
  <ion-avatar item-left>
    <img src="">
  </ion-avatar>
</ion-item>

Création de l'API

Prérequis

Installer ruby 2.2.2, puis gem install rails --version=5.0.0.

Rails 5

Rails permet dans sa version 5 de générer une api simplement via le CLI rails new api_server --api --skip-bundle.

Rspec

Nous ajoutons les gem suivantes afin de mettre en place rspec.

# Gemfile
group :development, :test do

  # Use RSpec for specs
  gem 'rspec-rails', '>= 3.5.0'

  # Use Factory Girl for generating random test data
  gem 'factory_girl_rails'
end

Nous ne configurons pas tout de suite Rspec car nous allons ajouter d'autres Gems et que faire un bundle toutes les deux minutes me fatigue.

Sérialisation

Vue que nous créons une API, nos retours sont toujours sont forme de JSON. Ajoutons la sérialisation afin de définir les informations que chaque modèle doit rendre.

# Gemfile
gem 'active_model_serializers'

CORS

Nous devons autoriser les accès depuis des cross-domain donc, c'est parti.

# Gemfile
gem 'rack-cors'

La configuration de base peut être ainsi faite.

# config/application.rb
module ApiServer
  class Application < Rails::Application

    # ...

    config.middleware.insert_before 0, "Rack::Cors" do
      allow do
        origins '*'
        resource '*', :headers => :any, :methods => [:get, :post, :options]
      end
    end

  end
end

Éviter les attaques ddos

Afin d'éviter de se faire mettre au sol notre fantastique serveur, configurons le.

# Gemfile
gem 'rack-attack'

On demande au middleware d'utiliser la gem.

# config/application.rb
module ApiServer
  class Application < Rails::Application

    # ...

    config.middleware.use Rack::Attack

  end
end

Et on le configure.

# config/initializers/rack_attack.rb
class Rack::Attack

  # `Rack::Attack` is configured to use the `Rails.cache` value by default,
  # but you can override that by setting the `Rack::Attack.cache.store` value
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

  # Allow all local traffic
  safelist('allow-localhost') do |req|
    '127.0.0.1' == req.ip || '::1' == req.ip
  end

  # Allow an IP address to make 5 requests every 5 seconds
  throttle('req/ip', limit: 5, period: 5) do |req|
    req.ip
  end

  # Send the following response to throttled clients
  self.throttled_response = ->(env) {
    retry_after = (env['rack.attack.match_data'] || {})[:period]
    [
      429,
      {'Content-Type' => 'application/json', 'Retry-After' => retry_after.to_s},
      [{error: "Throttle limit reached. Retry later."}.to_json]
    ]
  }
end

Préparation des tests de connexion

Reste à ajouter la configuration de variables d'environnement.

# Gemfile
..
# Environement
gem 'figaro'
# Facebook graph api
gem 'koala'

Pour nos tests, nous aurons besoin de variables qui sont gérées par Figaro et notre contrôle de Token Facebook serra tenu par Koala.

Bundle

Maintenant que tout est en place, nous pouvons charger les gem avec bundle.

Initialisation de rspec

La commande rails g rspec:install nous installe rspec, nous pouvons supprimer le répertoire test.

Création du modèle utilisateur

Nous allons maintenant passer à l'identification. Pour cela il nous faut des utilisateurs et un système garantissant que chacun est bien qui il prêtant être.

Initialisation de Figaro

Après la commande bundle exec figaro install, ajoutons dans le fichier environnement les variables suivantes.

FACEBOOK_APP: ""
FACEBOOK_SECRET: ""

Les valeurs sont à prendre dans la partie settings de votre application facebook.

Le scaffold

Commençons par la génération de l'utilisateur.

rails generate scaffold user \
  user_name:string \
  email:string \
  token:string

Cette commande nous a généré toutes les specs et serialisé notre User dans app/serializers/user_serializer.rb.

Modification du modèle

# app/models/user.rb
class User < ApplicationRecord

  # Vérifie que l'utilisateur est identifié côté facebook et retourne ses informations.
  # @return {User} 
  def self.find_by_token(token)
    User.new if token.blank?
    graph = Koala::Facebook::API.new(token)
    user_attrs = graph.get_object("me", {:fields => 'picture, email, first_name, last_name, birthday'}, :api_version => "v2.0")
    user_attrs["email"] = user_attrs["first_name"]+"@"+user_attrs["last_name"]+".test" if user_attrs["email"].nil?
    user = User.find_by(:email => user_attrs["email"])
    user_attrs["birthday"] = Date.today.strftime('%m/%d/%Y') if user_attrs["birthday"].nil?
    attrs = { :email => user_attrs["email"], 
      :first_name => user_attrs["first_name"], 
      :last_name => user_attrs["last_name"], 
      :image => user_attrs["picture"]["data"]["url"], 
      :token => token,
      :birthday => Date.strptime(user_attrs["birthday"], '%m/%d/%Y')
    }

    if user.nil?
      user = User.create!(attrs)
    else
      user if user.update!(attrs)
    end
  end
end

Ici nous créons la fonction User.find_by_token qui va être utilisée par ApplicationController à chaque requête afin qu'il mette à jour le token de l'utilisateur ou le crée s'il n'est pas encore en base.

Maintenant, il nous reste à modifier le contrôleur de l'application afin qu'il demande à chaque fois une validation de l'identification.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  # Add a before_action to authenticate all requests.
  before_action :authenticate

  protected
    # Authenticate the user with token based authentication
    def authenticate
      authenticate_token || render_unauthorized
    end

    def authenticate_token
      authenticate_with_http_token do |token, options|
        @current_user = User.find_by_token(token)
      end
    end

    def render_unauthorized(realm = "Application")
      self.headers["WWW-Authenticate"] = %(Token realm="#{realm.gsub(/"/, "")}")
      render json: 'bad-credentials', status: :unauthorized
    end
end

Versionner l'API

Nous souhaitons versionner notre API, pour cela nous allons créer deux répertoires et déplacer notre contrôleur. Le générateur a créé le fichier app/controllers/users_controller.rb et nous allons le déplacer et créer un fichier api_controller.rb.

L’arborescence doit être comme ceci.

app
  controllers
    api
      v1
        api_controller.rb
        users_controller.rb

Le contrôleur api va nous permettre de définir des règles spécifiques et communes à la version 1 de notre api.

# app/controllers/api/v1/api_controller.rb
module Api
  module V1
    class ApiController < ApplicationController
      # Rien pour le moment.
    end
  end
end

Nous modifions donc notre users_controller.rb pour être issu de ce dernier tout en spécifiant les modules dont il fait parti maintenant.

Nous souhaitons que le contrôleur utilisateur ne permette d'accéder qu'a la vue d'une utilisateur (ses données) ou être modifié par lui-même.

# app/controllers/api/v1/users_controller.rb
module Api
  module V1
    class UsersController < ApiController
      before_action :set_user, only: [:show, :update]

      # GET /users/1
      def show
        # Nos modèles sont serializés
        render json: @user.as_json
      end

      # PATCH/PUT /users/1
      def update
        render json: "unauthorized" , status: :unauthorized if @current_user != @user
        if @user.update(user_params)
          render json: @user.as_json
        else
          render json: @user.errors, status: :unprocessable_entity
        end
      end

      private
        # Use callbacks to share common setup or constraints between actions.
        def set_user
          @user = User.find(user_params)
        end

        # Only allow a trusted parameter "white list" through.
        def user_params
          params.require(:user).permit(:user_name, :email, :token)
        end

    end
  end
end

Il ne nous reste plus qu'à modifier les routes.

#config/routes.rb
Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html  
  scope module: :api do
    namespace :v1 do
      resources :users, only: [:show, :update]
    end
  end
end

Nous utilisons scope module: :api car nous ne souhaitons pas que l'url contienne /api.

Tester notre api

Tel que, si vous lancez bundle exec rspec vous allez avoir un nombre important d'erreurs. Tout d'abord car nous avons versionné notre api et que les scripts ne le savent pas, puis parce que nous demandons une validation de jeton à chaque requête.

Helper

Tout d'abord, nous allons créer un helper. Notre serveur est une API, il ne retourne que des jsons. Donc nous allons passer notre temps à découper les réponses faites lors des tests en json comme le code ci-dessous.

it "returns a single cat" do  
  get '/cats/1'
  json = JSON.parse(last_response.body)
  expect(json["data"]["id"]).to eq(1) 
end 

Afin donc d'éviter cela, nous allons créer un fichier api_helper contenant notre méthode que nous nommons json.

# spec/api_helper.rb
module ApiHelper  

  # Retourne le corps de la réponse sous forme de tableau.
  # @exemple json['id']
  def json
    JSON.parse(response.body)
  end
end 

Puis nous l'ajoutons dans notre rails_helper.

# spec/rails_helper.rb
RSpec.configure do |config|
  ..
  # Ajout du helper pour api.
  config.include ApiHelper
end

Spec du modèle

Modifions le test du modèle comme suit.

# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do

  before(:each) do
    test_users = Koala::Facebook::TestUsers.new(app_id: ENV["FACEBOOK_APP"], secret: ENV["FACEBOOK_SECRET"])
    @user = test_users.create(true)
  end

  context "When user does not exists" do
    it "find should create the user and return it" do
      expect { 
        User.find_by_token(@user["access_token"])
      }.to change(User, :count).from(0).to(1)
    end
  end

  context "When user exists" do
    it "find should update the user api_key and return it" do
      expect { 
        user = User.find_by_token(@user["access_token"])
      }.to_not change(User, :count).from(1).to(2)
    end
  end
end

Ici nous testons que le modèle fonctionne comme nous le voulons. Si l'utilisateur n'est pas en base et que le jeton est validé par Facebook, on le crée ; si l'utilisateur existe, on met à jour.

Spec des requêtes

# spec/requests/user_spec.rb
require 'rails_helper'

RSpec.describe "Users", type: :request do

  before(:each) do
    test_users = Koala::Facebook::TestUsers.new(app_id: ENV["FACEBOOK_APP"], secret: ENV["FACEBOOK_SECRET"])
    user = test_users.create(true)
    @headers = {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Token.encode_credentials(user[:access_token])}
  end

  context "When token is passed" do
    it "should get user informations" do
      get "/v1/users/1", :headers => @headers
      expect(response).to have_http_status(200)
      expect(json["id"]).to eq(1)
    end
  end

  context "When token is not passed" do
    it "should fail to get user informations" do
      get "/v1/users/1"
      expect(response).to have_http_status(401)
      expect(response.body).to eq("bad-credentials")
    end
  end

end

Ici nous validons le fait qu'une requête contenant un token (via :headers) retourne bien l'utilisateur que nous voulons ; et que sans token, un message d'erreur soit retourné.

Spec des routes

Nous voulons que seul les routes :show et :update soient accessibles pour ce modèle, donc nous devons nous en assurer.

Notez bien qu'ici nous avons changer l'appel au contrôleur : Api::V1::UsersController au lieu de UsersController ainsi que les urls qui se sont enrichies d'un v1/.

# spec/routing/users_routing_spec.rb
require "rails_helper"

RSpec.describe Api::V1::UsersController, type: :routing do
  describe "routing" do

    it "routes to #index" do
      expect(:get => "v1/users").not_to route_to("api/v1/users#index")
    end

    it "routes to #create" do
      expect(:post => "v1/users").not_to route_to("api/v1/users#create")
    end

    it "routes to #show" do
      expect(:get => "v1/users/1").to route_to("api/v1/users#show", :id => "1")
    end

    it "routes to #update via PUT" do
      expect(:put => "v1/users/1").to route_to("api/v1/users#update", :id => "1")
    end

    it "routes to #update via PATCH" do
      expect(:patch => "v1/users/1").to route_to("api/v1/users#update", :id => "1")
    end

    it "routes to #destroy" do
      expect(:delete => "v1/users/1").not_to route_to("api/v1/users#destroy", :id => "1")
    end

  end
end

Références

  • http://sourcey.com/building-the-prefect-rails-5-api-only-app/
  • http://www.thegreatcodeadventure.com/better-rails-5-api-controller-tests-with-rspec-shared-examples/
  • http://ionicframework.com/docs/v2/native/facebook/
  • https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow#checktoken
  • http://stackoverflow.com/questions/38301878/ionic-2-angular-2-http-headers-are-not-being-sent-along-with-the-request
  • http://www.gajotres.net/ionic-2-making-rest-http-requests-like-a-pro/

Publié dans les catégories suivantes

javascriptruby
comments powered by Disqus

Téléphone

+33 637 700 504

Adresse

Bordeaux, 33300
France