Nous avons vu, dans un précédent article, le sujet de l'identification des utilisateurs. Pour ceux qui l'ont raté, il sera disponible prochainement sur Unix Garden, l'autre jardin du bien. Pour ces mêmes personnes, le code source d'exemple est toujours disponible sur mon compte GitHub [2].
Nous verrons deux méthodes relativement simples dans cet article :
- faire notre propre système d'autorisation (simpliste) ;
- utiliser un plugin : CanCan [3].
Comme pour l'article précédent, le code utilisé dans celui-ci est disponible sur mon compte GitHub, la base est d'ailleurs le code de l'article précédent. J'ai cependant créé un dépôt spécifique [4] sur GitHub pour le bien de cet article. Contrairement à la fois précédente, j'ai utilisé un tag pour marquer le point initial (« base »). Je pense que c'est plus approprié.
Cet article est destiné à des personnes qui ont d'ores et déjà une connaissance de base, voire moyenne, de RubyOnRails. Pour ceux qui auraient du mal à suivre, je ne peux que recommander la lecture des guides disponibles sur les sites RubyOnRails et RailsFrance. Pour les personnes qui maîtrisent Rails, je les invite à m'envoyer commentaires, critiques et suggestions via mon compte GitHub.
1. Seul dans les bois
Pourquoi réinventer la roue ? Peut-être parce qu'on a pas les moyens ou pas l'utilité de la super roue WheelyWheel 2.3, nous avons juste besoin de dire qu'un utilisateur est le boss, et qu'un autre n'est qu'un larbin. Pour ce faire, pas besoin de toute la boutique de Bob, un attribut et quelques méthodes peuvent suffire.
La problématique est simple : nous avons notre application qui permet d'écrire des articles si nous sommes connectés. Au départ, seul l'auteur de chaque article a le droit d'éditer ou de supprimer celui-ci. Nous allons modifier le code pour que des modérateurs aient le droit d'éditer ou supprimer n'importe quel article.
1.1 Ajout de l'attribut
Nous allons ajouter un simple attribut role à la classe User, dans lequel on stockera, sous forme de chaîne, le rôle de l'utilisateur. Il nous faut donc commencer par ajouter l'attribut à la classe. Utilisons un générateur de base pour obtenir une migration :
#> script/generate migration AddRole
Editons la migration :
# db/migrate/[timestamp]_create_role.rb
class CreateRoles < ActiveRecord::Migration
def self.up
add_column :users, :role, :string
end
def self.down
remove_column :users, :role
end
end
Passons la migration.
#> rake db:migrate
Mais pour pouvoir utiliser cet attribut, il nous faut :
- pouvoir attribuer un rôle à un utilisateur ;
- pouvoir déterminer le rôle de l'utilisateur.
1.2 Attribution d'un rôle
Nous utiliserons deux rôles : modérateur et auteur. Le premier correspondra à un utilisateur ayant les droits de modérateur, le deuxième correspondra à un simple auteur.
Commençons par éditer le formulaire en ajoutant un select comportant les deux options possibles :
# app/views/users/_form.html.erb
<p>Role : <%= f.select(:role, [['Auteur', 'auteur'], ['Modérateur', 'moderateur']], {}) %></p>
Comme il s'agit d'un form lié à une instance de la classe User, Rails fait son boulot automatiquement et met à jour directement l'attribut role sans que nous ayons besoin de faire plus.
1.3 Test d'appartenance
Nous pouvons désormais attribuer un rôle à un utilisateur, mais nous ne pouvons pas encore déterminer simplement quel est le rôle de celui-ci. Nous allons donc ajouter deux méthodes : is_author? et is_moderator? au modèle User :
def is_author?
return true if role.to_sym == :auteur
return false
end
def is_moderator?
return true if role.to_sym == :moderateur
return false
end
Désormais, nous pouvons via un simple appel à ces méthodes déterminer le rôle de l'utilisateur. Pour ce qui est de savoir si un utilisateur a le droit de supprimer un post, il suffit donc de vérifier s'il est auteur de celui-ci, ou s'il est modérateur. Ce qui nous donne, pour la vue index du contrôleur posts :
# views/posts/index.html.erb
<table>
<tr>
<th>Titre</th>
<th>Auteur</th>
</tr>
<% for post in @posts %>
<tr>
<td><%= link_to post.title, post %></td>
<td><%= link_to post.user.login, post.user %></td>
<td><%= link_to "Editer", edit_post_path(post) if current_user && (current_user == post.user || current_user.is_moderator?) %></td>
<td><%= link_to "Supprimer", post, :confirm => 'Etes vous sûr ?', :method => :delete if current_user && (current_user == post.user || current_user.is_moderator?) %></td>
</tr>
<% end %>
</table>
Nous avons ajouté une condition supplémentaire : si l'utilisateur est auteur de l'article OU modérateur, alors il a le droit de supprimer ou d'éditer celui-ci. Pour vérifier, il nous faut deux utilisateurs : un auteur et un modérateur. Créez un post avec le premier et vérifiez que vous pouvez l'éditer ou le supprimer avec le second. Puis éditez les préférences du modérateur pour le transformer en auteur et retournez sur la vue index. En principe, vous ne voyez plus les liens.
Faites de même dans la vue show :
<p>
<%= link_to "Editer", edit_post_path(@post) if current_user && (current_user == post.user || current_user.is_moderator?) %> |
<%= link_to "Supprimer", @post, :confirm => 'Etes vous sûre ?', :method => :delete if current_user && (current_user == post.user || current_user.is_moderator?) %> |
<%= link_to "Voir tous", posts_path %>
</p>
Évidemment, nous devrions faire de même dans le contrôleur pour éviter tout désagrément, cela est relativement simple à faire, nous ne l'aborderons donc pas ici. Par ailleurs, nous allons voir une méthode plus pratique dans la partie de l'article traitant de CanCan.
Nous pouvons désormais attribuer et modifier le rôle d'un utilisateur, et vérifier si celui-ci est modérateur pour limiter son accès à certaines fonctions.
Une autre possibilité aurait été d'utiliser un modèle séparé et dédié pour gérer les rôles. Un modèle Role, par exemple, associé au modèle User par un belongs_to/has_many. C'est un peu plus complexe mais cela peut présenter des avantages et des possibilités supplémentaires.
Si ce système est simpliste et convient à nos besoins, il peut s'avérer limité et nous pourrions être tentés de le modifier (comme indiqué au paragraphe précédent). Il faut garder en mémoire qu'aussi tentant que cela puisse paraître, développer un tel système peut vite s'avérer un casse-tête, il faut donc peser le pour et le contre et toujours garder un œil sur ce qui existe à côté.
2. Un zeste de citron
Ryan Bates (RailsCasts) a créé un plugin relativement simple pour gérer la gestion des droits d'utilisateurs de façon simple mais efficace et il l'a appellé : CanCan.
Voici ce que l'on peut trouver dans le Readme :
This is a simple authorization solution for Ruby on Rails to restrict what a given user is allowed to access in the application. This is completely decoupled from any role based implementation allowing you to define user roles the way you want. All permissions are stored in a single location for convenience. (...) C'est une solution d'autorisation simple pour RubyOnRails afin de restreindre ce à quoi un utilisateur peut accéder au sein de l'application. Elle est totalement séparée d'une quelconque implémentation de gestion de rôles, ce qui vous permet donc de définir les rôles de la façon que vous désirez. Toutes les permissions sont gérées à un seul endroit par sens pratique. -- CanCan Readme.
Son utilisation est un délice : il suffit de créer une classe Ability (« Capacité » en français) et d'y coller les définitions de droits pour chaque rôle d'utilisateurs. Et comme vous venez de le lire, nous pouvons réutiliser le code exposé précédemment (YEAH). Le seul prérequis est qu'une méthode current_user doit exister et retourner une instance de la classe User.
2.1 Installation de CanCan
Pour les utilisateurs de Bundler, il faudra ajouter cancan comme dépendance dans le Gemfile. Pour ceux qui utilisent le bon vieux environment.rb, il faut ajouter la ligne suivante dedans, suivie de son installation :
# config/environment.rb
config.gem "cancan"
#> sudo rake gems:install
Pour ceux qui voudraient l'avoir en simple plugin :
#> script/plugin install git://github.com/ryanb/cancan.git
2.2 Abby -lity
Le seul fichier à gérer est donc ability.rb dans app/models/ (qu'il nous faut créer) :
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
end
end
Tout se passe dans la méthode initialize. Pour chaque rôle, il vous faudra ajouter un test suivi des droits correspondants. Commençons par le rôle modérateur :
def initialize(user)
if user.is_moderator?
can :manage, Post
else
can :read, Post
end
end
Nous devons maintenant modifier quelque peu nos vues et les tests précédemment ajoutés. Pour tester dans les vues, nous pouvons utiliser les méthodes can? et cannot?, dont la syntaxe est la suivante :
if can? :action, objet
# ou
if cannot? :action, objet
:action est à remplacer par l'action que l'on veut tester : update, edit, destroy, create, manage, ... et objet est l'objet sur lequel on veut vérifier les droits de l'utilisateur. Le premier cas permet de vérifier si l'utilisateur a le droit de faire l'action, le deuxième s'il n'a pas le droit de la faire. Cette dernière peut être pratique pour afficher un panneau, une insulte, ou toute autre chose si l'utilisateur n'a pas le droit d'éditer l'article ou de le supprimer, et ainsi lui indiquer (gentiment) qu'il devrait se connecter, payer, ...
# app/views/posts/index.html.erb (extrait)
<% for post in @posts %>
<tr>
<td><%= link_to post.title, post %></td>
<td><%= link_to post.user.login, post.user %></td>
<td><%= link_to "Editer", edit_post_path(post) if current_user && (can? :update, post) %></td>
<td><%= link_to "Supprimer", post, :confirm => 'Etes vous sûr ?', :method => :delete if current_user && (can? :destroy, post) %></td>
</tr>
<% end %>
#####
# app/views/posts/show.html.erb (extrait)
<p>
<%= link_to "Editer", edit_post_path(@post) if current_user && (can? :update, @post) %> |
<%= link_to "Supprimer", @post, :confirm => 'Etes vous sûre ?', :method => :delete if current_user && (can? :destroy, @post) %> |
<%= link_to "Voir tous", posts_path %>
</p>
Rechargeons la vue index en étant connecté avec un utilisateur modérateur et oh!, il peut bien voir les liens, vérifions ensuite que nous n'y avons plus accès si nous sommes simple auteur (et pas auteur de l'article)... (ici, ça marche).
Mais connectons-nous avec le compte auteur d'un article. HORREUR, il ne peut ni éditer, ni supprimer son article. Normal, me direz-vous, il n'a aucun droit de défini.
Il faut donc lui ajouter ces droits dans le fichier ability.rb :
def initialize(user)
if user.is_moderator?
can :manage, Post
elsif user.is_author?
can [:update, :destroy], Post do |post|
post && post.user == user
end
else
can :read, Post
end
end
Nous utilisons une syntaxe un peu particulière qui consiste à utiliser un bloc pour tester des attributs de l'objet passé en paramètre à la méthode can? ou cannot?. (Les anglophones peuvent se référer au Readme de CanCan pour plus de détails).
2.3 Prévention
Nous venons de voir comment mettre en place un contrôle efficace dans les vues, mais comment faire dans les contrôleurs ? Si nous essayons de supprimer un post directement via l'URL http://localhost:3000/posts/destroy/X (où X est un identifiant valide), rien ne nous empêchera de le faire.
Pour ce faire, il nous faut utiliser une démarche similaire à celle utilisée dans les vues en utilisant les méthodes can? et cannot? associés à des redirections ou à la méthode unauthorized!. Prenons la méthode new du contrôleur posts comme exemple :
# app/controllers/posts_controller.rb
def new
@post = Post.new
redirect_to posts_path if cannot?(:create, @post)
end
Maintenant, essayons d'accéder à l'adresse http://localhost:3000/posts/new sans être connectés. Nous devrions nous retrouver face à :
NoMethodError in PostsController#new
undefined method `is_moderator?' for nil:NilClass
Le problème vient du fait que nous ne sommes pas connectés, la méthode current_user renvoie donc nil. Nous pourrions ajouter une condition à notre if, mais nous allons utiliser une méthode plus habile. Dans de nombreux cas, nous ne voulons pas que des utilisateurs non connectés accèdent à certaines parties du site, et plus exactement à certaines fonctionnalités.
Rails nous offre la possibilité d'utiliser des filtres avant et après les actions, nous allons donc ajouter un filtre permettant de vérifier si un utilisateur est connecté, puis de rediriger celui-ci vers l'action ou l'adresse désirée en fonction du résultat. Pour ce faire, il nous faut utiliser le filtre before_filter en lui passant le nom de la méthode à appeler. Optionnellement, nous pouvons lui passer le nom des actions auxquelles il ne faudra pas appliquer ce filtre, ou celles qui seront concernées. Par défaut, toutes les actions du contrôleur se voient préfixées de ce filtre. Ici, nous allons utiliser la première forme suivante :
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_filter :require_login, :except => ["index", "show"]
# autre syntaxe
class PostsController < ApplicationController
before_filter :require_login, :only => ["update", "edit", "create", "new", "destroy"]
Il nous faut ensuite ajouter la méthode "require_login" à notre contrôleur "application" :
# app/controllers/application_controller.rb
helper_method :current_user_session, :current_user, :require_login
#
# ...
#
def require_login
return true if current_user
redirect_to root_path
end
Cette méthode retournera vrai si un utilisateur est connecté, ou redirigera le visiteur anonyme vers l'adresse pointée par root_path.
Nous pouvons retourner à nos moutons et ajouter les divers tests nécessaires pour vérifier si l'utilisateur a le droit d'accéder aux actions en utilisant la même syntaxe que dans la méthode new précédente :
# app/controllers/posts_controller.rb
def create
@post = Post.new(params[:post])
redirect_to :index if cannot?(:create, @post)
@post.user = current_user if current_user
if @post.save
flash[:notice] = "Post créé avec succès."
redirect_to @post
else
render :action => 'new'
end
end
def edit
@post = Post.find(params[:id])
redirect_to @post if cannot?(:edit, @post)
end
def update
@post = Post.find(params[:id])
unauthorized! if cannot? :update, @post
if @post.update_attributes(params[:post])
flash[:notice] = "Post mis à jour avec succès."
redirect_to @post
else
render :action => 'edit'
end
end
def destroy
@post = Post.find(params[:id])
unauthorized! if cannot? :destroy, @post
@post.destroy
flash[:notice] = "Post détruit avec succès."
redirect_to posts_url
end
Ici, nous utilisons donc soit redirect_to, soit unauthorized!. Si une fois les précédentes modifications faites, nous essayons de supprimer un post alors que nous ne sommes ni son auteur, ni un modérateur, nous allons nous retrouver devant une page d'erreur :
CanCan::AccessDenied in PostsController#new
You are not authorized to access this page.
Pour éviter cela, nous pouvons ajouter de quoi rattraper cette exception dans le contrôleur Application :
# app/controllers/application_controller.rb
rescue_from CanCan::AccessDenied do |exception|
flash[:error] = "Vous n'êtes pas autorisé à accéder à cette page."
redirect_to root_url
end
Désormais, nous serons directement ramenés à la racine du site lorsque nous essayerons d'accéder à une page à laquelle nous n'avons pas le droit d'accéder.
Conclusion
Voilà, c'est déjà fini, nous avons vu comment mettre en place un système simple de gestion de rôles pour les utilisateurs, comment installer CanCan et l'intégrer à l'application au niveau des vues et des contrôleurs.
Tout comme l'identification, l'autorisation est souvent au cœur de l'application et il faut parfois se pencher sur la question un certain temps pour arriver à réaliser exactement ce dont nous avons besoin. Mais dans certains cas, des plugins comme CanCan peuvent se révéler suffisants ou adaptés à nos besoins. Donc réinventer la roue ? Oui, mais pas tous les jours.
Il y aurait encore beaucoup à dire et à faire, mais je pense que vous avez désormais un aperçu de ce qui est faisable soit en solo, soit avec CanCan. Ryan Bates cite aussi deux autres solutions qui l'ont inspiré : Aegis [5] et Declarative Authorization [6], qui sont d'autres plugins pour Rails. Bref, regardez un peu tout ça et décidez en fonction.
Amusez-vous bien et surtout : restez ouverts.
Le précédent article (« Vos papiers -rouges- s'il vous plaît ») comporte une erreur dans le paragraphe 2.7. J'y explique qu'il faut ajouter deux helpers au fichier application_helper.rb, mais le code qui suit fait référence au fichier application_controller. En réalité, c'est bien du contrôleur qu'il s'agit.
Bonus
Pour ceux qui seraient désireux d'utiliser le login ou l'adresse e-mail (ou autre chose) pour identifier un utilisateur (en association avec un mot de passe), il y a une méthode assez simple pour faire cela. Par défaut, AuthLogic cherchera à appeler sa méthode find_by_login pour trouver l'utilisateur correspondant et l'identifier. Cependant, il est possible d'indiquer une autre méthode à utiliser pour faire cette même fonction. Cela peut être pratique dans plusieurs cas, notamment pour ajouter d'autres tests, mais ici, simplement pour pouvoir utiliser le login ou l'e-mail comme identifiant.
Pour faire cela, il nous suffit d'ajouter dans la classe UserSession (celle qui hérite de Authlogic::Session::Base) :
find_by_login_method :find_by_login_or_email
Ainsi, nous indiquons à AuthLogic d'appeler la méthode find_by_login_or_email en lieu et place de find_by_login. Il nous faut ensuite ajouter la méthode en question dans le modèle User :
def self.find_by_login_or_email(login)
find_by_login(login) || find_by_email(login)
end
Cette méthode ne sert donc qu'à trouver un objet User et le retourner. Ainsi, si l'utilisateur utilise son login, l'identification fonctionnera, et elle fonctionnera aussi s'il utilise son e-mail.
Liens
[1] Liste de plugins d'autorisation : http://steffenbartsch.com/blog/2008/08/rails-authorization-plugins/
[2] Code d'exemple du précédent article : http://github.com/mcansky/Article-Rails-Auth
[3] CanCan : http://github.com/ryanb/cancan
[4] Code d'exemple de l'article : http://github.com/mcansky/Article-Rails-Auth2
[5] Aegis : http://github.com/makandra/aegis
[6] Declarative Authorization : http://github.com/stffn/declarative_authorization/