Blog tech

Connexion par accès pré-autorisé avec Doorkeeper

Rédigé par Numa Claudel | 13 juillet 2016

Pour faire suite à mon précédent article sur l’administration des accès Doorkeeper, dans lequel je vous avais proposé une adaptation pour permettre leur gestion par un administrateur, je voudrais maintenant aborder la partie connexion avec ces accès.

Il va falloir mettre en place un processus d’autorisation spécifique à cette gestion d’accès customisée, ainsi qu’une stratégie d’authentification en accord avec celui-ci dans les applications clientes.

Processus d’autorisation personnalisée

Dans Doorkeeper, c’est à l’origine lors de la première phase de permission, qu’est créé l’autorisation d’accès. Si l’application, à partir de laquelle l’utilisateur essaye de se connecter, est connue de Doorkeeper, alors cette page est proposée :

En cliquant sur “Autoriser”, l’utilisateur dit à Doorkeeper de permettre à cette application d’accéder à ses informations. Un AccessGrant va ainsi être créé.

Le processus mis en place la dernière fois, pré-autorise ces accès en créant préalablement les AccessGrant. Il va donc falloir retirer cette phase de permission proposée aux utilisateurs.

La demande d’autorisation se fait sur l’URL /oauth/authorize qui pointe vers l’action new du contrôleur Authorizations. La voici telle qu’elle est définie dans Doorkeeper :

# app/controllers/oauth/authorizations_controller.rb

def new
  if pre_auth.authorizable?
    if skip_authorization? || matching_token?
      auth = authorization.authorize
      redirect_to auth.redirect_uri
    else
      render :new
    end
  else
    render :error
  end
end

Ce que l’on veut : ne plus afficher cette page et donc retirer le render :new.

Puisque cette vue servait à créer une autorisation, nous avons besoin de compléter la condition pour savoir si un accès existe et de rendre un message d’erreur dans le cas contraire. Je vous propose alors de créer un module Grantable pour l’évaluation de la présence d’un accès :

# app/controllers/concerns/grantable.rb

module Grantable
  extend ActiveSupport::Concern

  private

  def granted?
    !!matching_grant_for(pre_auth.client,
                         current_resource_owner.id,
                         pre_auth.scopes)
  end

  def matching_grant_for(application, resource_owner_id, scopes)
    access_grant = authorized_grant_for(application.id, resource_owner_id)
    access_grant if access_grant &&
      scopes_match?(access_grant.scopes, scopes, application.try(:scopes))
  end

  def authorized_grant_for(application_id, resource_owner_id)
    Doorkeeper::AccessGrant.find_by(
      application_id: application_id,
      resource_owner_id: resource_owner_id
    )
  end

  def scopes_match?(token_scopes, param_scopes, app_scopes)
    (!token_scopes.present? && !param_scopes.present?) ||
      Doorkeeper::OAuth::Helpers::ScopeChecker.match?(
        token_scopes.to_s,
        param_scopes,
        app_scopes
      )
  end

end

La logique, pour rechercher si un AccessGrant existe, est assez similaire à celle de recherche d’un AccessToken présente dans AccessTokenMixin de Doorkeeper (la méthode scopes_match est d’ailleurs exactement la même). La réponse de la méthode granted? est vraie, si un AccessGrant existe entre cet utilisateur et l’application depuis laquelle il essaie de se connecter.

Il nous reste à inclure ce module à notre contrôleur Authorizations, ainsi qu’ajouter la méthode new modifiée :

# app/controllers/oauth/authorizations_controller.rb

class AuthorizationsController < Doorkeeper::AuthorizationsController
  include Grantable

  def new
    if pre_auth.authorizable?
      if skip_authorization? || granted? || matching_token?
        auth = authorization.authorize
        redirect_to auth.redirect_uri
      else
        render :unauthorized
      end
    else
      render :error
    end
  end
.
.

Et donc une vue unauthorized avec le message d’erreur à donner à l’utilisateur dans ce cas. Par exemple :

/ app/views/oauth/authorizations/unauthorized.html.slim

.page-header
  h1= t("doorkeeper.authorizations.error.title")

main role="main"
  pre= t("doorkeeper.errors.messages.unauthorized")
# config/locales/app_fr.yml

doorkeeper:
  errors:
    messages:
      unauthorized: "Vous n'êtes pas autorisé à accéder à cette application."

Suppression complète des accès

Les utilisateurs ayant une autorisation d’accès, vont pouvoir se connecter au travers du serveur SSO. Lors de leur authentification, Doorkeeper va leur attribuer un jeton d’accès valable deux heures (c’est configurable). Ce jeton sera donc à renouveler.

Au fil du temps, un utilisateur ayant accès à une application aura donc, en base de données, une autorisation et plusieurs jetons d’accès révoqués. Les jetons d’accès sont représentés par le modèle AccessToken dans Doorkeeper.

Pour finaliser le processus de suppression d’accès par un administrateur, il reste donc (par rapport à ce que nous avons mis en place la dernière fois) à supprimer tous les jetons qui auront été obtenus pour ce même accès. Complétons alors l’action destroy du contrôleur Authorizations :

# app/controllers/oauth/authorizations_controller.rb

def destroy
  @access_grant = Doorkeeper::AccessGrant.find(params[:id])
  @access_grant.transaction do
    Doorkeeper::AccessToken.where(
      application_id: @access_grant.application_id,
      resource_owner_id: User.find(@access_grant.resource_owner_id)
    ).delete_all
    @access_grant.destroy
  end
  redirect_back(fallback_location: root_path)
end

Stratégie d’authentification personnalisée

Suite aux derniers changements, les tentatives de connexion ne fonctionneront pas. Et pour cause, la gestion des autorisations est totalement modifiée.

Normalement, c’est lorsque l’on clique sur “Autoriser” de la page new que l’autorisation (AccessGrant) est créée. À ce moment la, certaines informations comme la stratégie de connexion ou l’URL de retour de l’application appelante sont dans la requête. En créant des accès en amont de ce moment, il est impossible de connaître ces informations, car les requêtes contiennent des clés uniques servant à valider les échanges entre les serveurs, ou encore la stratégie de connexion cliente peut aussi changer de nom.

Il va donc falloir faire en sorte que Doorkeeper se contente de rechercher les accès correspondants aux demandes de connexion, mais aussi revoir certaines validations.

Adapter la procédure d’autorisation de Doorkeeper

Sans passer de paramètres spécifiques, la stratégie de connexion d’une application cliente est émise avec une requête de type “code”, puis, lors de la phase de délivrance d’un token, la requête est de type “authorization_code”.

Il nous faut donc adapter toutes les parties intervenantes lors de ces requêtes, dont voici l’inventaire :

  • lib/doorkeeper/request/code.rb
  • lib/doorkeeper/request/authorization_code.rb
  • lib/doorkeeper/oauth/code_request.rb
  • lib/doorkeeper/oauth/authorization_code_request.rb

Pour chaque objet request correspond un objet oauth qui va se charger des validations et du processus d’autorisation ou d’attribution (dans le cas de la demande de token). Il va aussi être nécessaire d’adapter deux méthodes de validation, pour accepter un nouveau mode d’autorisation et modifier la validation sur les URLs (je vous le rappelle, les informations en base des AccessGrant sont légèrement différentes de celle attendue habituellement par Doorkeeper). Les voici :

  • Config#calculate_authorization_response_types dans lib/doorkeeper/config.rb
  • Doorkeeper::OAuth::Helpers::URIChecker.matches? dans lib/doorkeeper/oauth/helpers/uri_checker.rb

Et enfin il va falloir compléter l’initialiseur doorkeeper.rb avec le nouveau mode d’autorisation.

Commençons par ajouter un sous-dossier doorkeeper dans le dossier lib, comprenant lui-même deux dossiers : un request et un o_auth pour contenir les objets des mêmes types.

Voyons déjà à quoi ressemble les requêtes de type code et authorization_code dans Doorkeeper :

require 'doorkeeper/request/strategy'

module Doorkeeper
  module Request
    class Code < Strategy
      delegate :current_resource_owner, to: :server

      def pre_auth
        server.context.send(:pre_auth)
      end

      def request
        @request ||= OAuth::CodeRequest.new(pre_auth, current_resource_owner)
      end
    end
  end
end
require 'doorkeeper/request/strategy'

module Doorkeeper
  module Request
    class AuthorizationCode < Strategy
      delegate :grant, :client, :parameters, to: :server

      def request
        @request ||= OAuth::AuthorizationCodeRequest.new(
          Doorkeeper.configuration,
          grant,
          client,
          parameters
        )
      end
    end
  end
end

Ce que l’on veut, c’est faire la même chose, mais en appelant nos objets OAuth (que nous allons ajouter). Nous allons simplement hériter des précédentes classes et redéfinir leur méthode request, car nous voulons des requêtes des mêmes “genres”.

Commençons par une classe que nous nommerons Custom, pour la phase de demande d’autorisation :

# lib/doorkeeper/request/custom.rb

module Doorkeeper
  module Request
    class Custom < Code

      def request
        @request ||= OAuth::CustomRequest.new(
          pre_auth,
          current_resource_owner
        )
      end

    end
  end
end

Et une autre que nous nommerons CustomAuthorization, pour la phase de demande de token :

# lib/doorkeeper/request/custom_authorization.rb

module Doorkeeper
  module Request
    class CustomAuthorization < AuthorizationCode

      def request
        @request ||= OAuth::CustomAuthorizationRequest.new(
          Doorkeeper.configuration,
          grant,
          client,
          parameters
        )
      end

    end
  end
end

Les méthodes request pointent simplement vers nos classes OAuth, dans lesquelles nous allons adapter la logique pour notre processus d’autorisation et d’attribution de token.

Regardons déjà à quoi ressemble la classe OAuth::CodeRequest :

module Doorkeeper
  module OAuth
    class CodeRequest
      attr_accessor :pre_auth, :resource_owner, :client

      def initialize(pre_auth, resource_owner)
        @pre_auth       = pre_auth
        @client         = pre_auth.client
        @resource_owner = resource_owner
      end

      def authorize
        if pre_auth.authorizable?
          auth = Authorization::Code.new(pre_auth, resource_owner)
          auth.issue_token
          @response = CodeResponse.new pre_auth, auth
        else
          @response = ErrorResponse.from_request pre_auth
        end
      end

      def deny
        pre_auth.error = :access_denied
        ErrorResponse.from_request pre_auth,
                                   redirect_uri: pre_auth.redirect_uri
      end
    end
  end
end

Ici le cœur de notre modification va résider dans la méthode authorize. Nous ne voulons pas délivrer une nouvelle autorisation, ce que fait la méthode issue_token, mais rechercher si une autorisation existe entre l’application et l’utilisateur.

Voici notre classe OAuth::CustomRequest :

# lib/doorkeeper/o_auth/custom_request.rb

module Doorkeeper
  module OAuth
    class CustomRequest < CodeRequest

      def authorize
        if pre_auth.authorizable?
          auth = Authorization::Code.new(pre_auth, resource_owner)
          auth.token = AccessGrant.find_by!(
            application_id: pre_auth.client.id,
            resource_owner_id: resource_owner.id
          )
          @response = CodeResponse.new(pre_auth, auth)
        else
          @response = ErrorResponse.from_request(pre_auth)
        end
      end

    end
  end
end

A la place de l’appel à la méthode issue_token, nous faisons une recherche d’AccessGrant et attribuons le résultat à l’attribut token de l’objet de type Authorization::Code (ce que fait la méthode issue_token après avoir créée une autorisation).

Procédons de la même manière pour la classe OAuth::AuthorizationCodeRequest, dont voici le code :

module Doorkeeper
  module OAuth
    class AuthorizationCodeRequest
      include Validations
      include OAuth::RequestConcern

      validate :attributes,   error: :invalid_request
      validate :client,       error: :invalid_client
      validate :grant,        error: :invalid_grant
      validate :redirect_uri, error: :invalid_grant

      attr_accessor :server, :grant, :client, :redirect_uri, :access_token

      def initialize(server, grant, client, parameters = {})
        @server = server
        @client = client
        @grant  = grant
        @redirect_uri = parameters[:redirect_uri]
      end

      private

      def before_successful_response
        grant.transaction do
          grant.lock!
          raise Errors::InvalidGrantReuse if grant.revoked?

          grant.revoke
          find_or_create_access_token(grant.application,
                                      grant.resource_owner_id,
                                      grant.scopes,
                                      server)
        end
      end

      def validate_attributes
        redirect_uri.present?
      end

      def validate_client
        !!client
      end

      def validate_grant
        return false unless grant && grant.application_id == client.id
        grant.accessible?
      end

      def validate_redirect_uri
        grant.redirect_uri == redirect_uri
      end
    end
  end
end

Ce que nous avons besoin de modifier dans cette classe, c’est toutes les méthodes qui sont en lien avec les AccessGrant, ici l’attribut grant :

  • before_successful_response => on ne veut pas révoquer les autorisations préalablement accordées, ce qui implique de les ré-accorder ensuite
  • validate_grant => on ne veut pas utiliser la méthode accessible? qui contrôle si l’autorisation est expirée ou révoquée
  • validate_redirect_uri => il faut adapter la validation de l’URL de retour, car nous n’enregistrons pas la même valeur que dans le processus d’origine

Voici donc notre classe OAuth::AuthorizationCodeRequest :

# lib/doorkeeper/o_auth/custom_authorization_request.rb

module Doorkeeper
  module OAuth
    class CustomAuthorizationRequest < AuthorizationCodeRequest

      private

      def before_successful_response
        find_or_create_access_token(grant.application,
                                    grant.resource_owner_id,
                                    grant.scopes,
                                    server)
      end

      def validate_grant
        grant && grant.application_id == client.id
      end

      def validate_redirect_uri
        Helpers::URIChecker.
          valid_for_authorization?(redirect_uri, grant.redirect_uri)
      end
    end
  end
end

La méthode before_successful_response se charge uniquement de trouver ou créer un token et la méthode validate_grant valide qu’une autorisation existe et qu’elle correspond à l’application appelante. La méthode validate_redirect_uri utilise maintenant la classe Helpers::URIChecker pour valider l’URL de retour, dans laquelle nous allons modifier cette validation. La classe Helpers::URIChecker étant utilisée à plusieurs endroits dans Doorkeeper, cette modification sera donc valable globalement.

Il nous reste maintenant de permettre à Doorkeeper de connaître notre nouveau processus d’autorisation et de modifier cette validation d’URL. Pour ce faire, il va falloir modifier les méthodes Config#calculate_authorization_response_types et Helpers::URIChecker.matches? de Doorkeeper, ainsi qu’ajouter notre processus spécial d’autorisation.

Voici à quoi ressemblent ces méthodes dans Doorkeeper :

private

# Determines what values are acceptable for 'response_type' param in
# authorization request endpoint, and return them as an array of strings.
#
def calculate_authorization_response_types
  types = []
  types << 'code'  if grant_flows.include? 'authorization_code'
  types << 'token' if grant_flows.include? 'implicit'
  types
end
def self.matches?(url, client_url)
  url = as_uri(url)
  client_url = as_uri(client_url)
  url.query = nil
  url == client_url
end

Je vous propose d’ajouter un initializer doorkeeper_extensions que nous allons compléter comme suit :

# config/initializers/doorkeeper_extensions.rb

module Doorkeeper

  Config.class_eval do

    private

    # Add our custom authorization grant flow to the allowed responses types
    alias_method :old_calculate_authorization_response_types,
                 :calculate_authorization_response_types
    def calculate_authorization_response_types
      types = old_calculate_authorization_response_types
      types << "custom" if Doorkeeper.configuration.grant_flows.
                                      include?("custom_authorization")
      types
    end

  end

  OAuth::Helpers::URIChecker.class_eval do

    # Validate by matching the redirect_uri
    def self.matches?(url, client_url)
      !!/\A#{client_url}/.match(url)
    end

  end

end

Simplement permettre un nouveau type de réponse et valider les URL en vérifiant que la racine de l’URL appelante correspond à l’URL enregistrée en base.

Et enfin, dans l’initializer doorkeeper, ajoutons un nouveau paramètre custom_authorization à la liste existante grant_flows, pour permettre les réponses de type custom :

# config/initializers/doorkeeper.rb

.
.
grant_flows %w(authorization_code client_credentials custom_authorization)

Au niveau de l’application cliente

Pour se connecter avec ce processus de pré-autorisation, la stratégie d’authentification de l’application cliente devra inclure les paramètres custom et custom_authorization à ses requêtes. Par exemple, dans une stratégie OAuth2 pour OmniAuth :

# lib/omniauth/strategies/our_sso.rb

option :authorize_params, {
  response_type: "custom"
}

option :token_params, {
  grant_type: "custom_authorization"
}

Maintenant, pour les cas où nous aurions besoin d’un client OAuth2, il va aussi falloir en définir un spécifique à notre processus. Nous allons procéder de la même manière que précédemment : il faut définir un client spécifique, ainsi qu’une stratégie qui enverra nos paramètres spéciaux.

Dans l’application cliente, ajoutons un sous-dossier o_auth2 dans le dossier lib, comprenant lui-même un dossier strategy, et plaçons notre client OAuth2 à la racine du dossier o_auth2 :

# lib/o_auth2/custom_client.rb

module OAuth2
  class CustomClient < Client

    def auth_custom
      @auth_custom ||= Strategy::AuthCustom.new(self)
    end

  end
end

On veut utiliser le client OAuth2, mais enrichit d’une méthode nous permettant d’appeler notre stratégie d’autorisation, que nous allons ajouter.

Voici la stratégie AuthCode sur laquelle nous allons nous baser :

module OAuth2
  module Strategy
    # The Authorization Code Strategy
    #
    # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1
    class AuthCode < Base
      # The required query parameters for the authorize URL
      #
      # @param [Hash] params additional query parameters
      def authorize_params(params = {})
        params.merge('response_type' => 'code', 'client_id' => @client.id)
      end

      # The authorization URL endpoint of the provider
      #
      # @param [Hash] params additional query parameters for the URL
      def authorize_url(params = {})
        @client.authorize_url(authorize_params.merge(params))
      end

      # Retrieve an access token given the specified validation code.
      #
      # @param [String] code The Authorization Code value
      # @param [Hash] params additional params
      # @param [Hash] opts options
      # @note that you must also provide a :redirect_uri with most OAuth 2.0 providers
      def get_token(code, params = {}, opts = {})
        params = {'grant_type' => 'authorization_code', 'code' => code}.merge(client_params).merge(params)
        @client.get_token(params, opts)
      end
    end
  end
end

Les deux méthodes que nous avons besoin de modifier, sont : authorize_params et get_code. Actuellement, elles ne vont pas utiliser nos paramètres spéciaux.

Ajoutons donc une classe AuthCustom dans le dossier strategy qui va prendre en charge nos paramètres :

# lib/o_auth2/strategy/auth_custom.rb

module OAuth2
  module Strategy
    class AuthCustom < AuthCode

      def authorize_params(params = {})
        params.merge(client_id: @client.id).
               merge(custom_params('authorize'))
      end

      def get_token(code, params = {}, opts = {})
        params = { code: code }.merge(custom_params('token')).
                                merge(client_params).
                                merge(params)
        @client.get_token(params, opts)
      end

      def custom_params(type)
        params = :"#{type}_params"
        OmniAuth::Strategies::Sso.default_options[params].to_h
      end

    end
  end
end

On hérite donc de la classe AuthCode, puis on ajoute une méthode custom_params, qui se charge de récupérer les paramètres spéciaux dans la stratégie d’authentification. Par exemple ici, avec une stratégie OAuth2 pour OmniAuth, on récupère simplement les options token_params ou authorize_params que nous avons définies.

Une page de wiki fournit par Doorkeeper, donne une procédure pour tester que le SSO répond correctement avec la gem OAuth2. En se basant sur celle-ci, il suffit donc d’utiliser notre OAuth2::CustomClient en lieu et place du OAuth2::Client et d’utiliser la méthode auth_custom à la place de auth_code.

Conclusion

Je mets un léger bémol à cette implémentation, car elle implique de “monkey patch” deux méthodes de Doorkeeper, même si l’impact est assez restreint.

Avec cette adaptation, l’administrateur du SSO n’a plus qu’à gérer les accès. Un utilisateur pour lequel l’administrateur a créé des accès, peut se connecter au travers du SSO sur les applications qui lui ont été autorisées. Et le client OAuth2 customisé permet de dialoguer avec le SSO pour d’autres besoins que la connexion.

Le but est donc atteint, les autorisations d’accès sont administrées et les ajustements au niveau des applications clientes permettent un dialogue habituel entre les deux serveurs.

L’équipe Synbioz.

Libres d’être ensemble.