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.
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."
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
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.
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 :
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 :
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 ensuitevalidate_grant
=> on ne veut pas utiliser la méthode accessible?
qui contrôle si l’autorisation est expirée ou révoquéevalidate_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’origineVoici 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)
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
.
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.