Autoryzacja z CanCan

[Ryan Bates]

Ryan Bates

CanCan is a simple authorization plugin that offers a lot of flexibility. Zamierzamy to sprawdzić, dodając autoryzację do Fortunki z autentykacją.

Zaczynamy od sklonowania Fortunki z Autentykacja z Authlogic:

git clone git://sigma.ug.edu.pl/~wbzyl/authlogic

Teraz zmieniamy katalog, czytamy plik README:

cd authlogic
less README.md

i tak jak to opisano w README tworzymy gałąź cancan:

git checkout --track origin/cancan

Pora na migrację i umieszczenie przykładowych danych w bazie:

rake db:migrate
rake db:seed

Teraz jesteśmy w miejscu w którym zakończyliśmy zabawę z Authlogic. Ale tym razem jesteśmy na gałęzi cancan, a nie na master.

Wykorzystamy gem CanCan do dodania trzech ról do Fortunki:

Przygotowania

Dopuszczamy sytuację, że użytkownik może mieć kilka ról.

Wszystkie role użytkownika będziemy kodować w dodatkowym atrybucie modelu User o nazwie roles_mask.

Zaczynamy od dodania kolumny roles_mask:integer do tabeli users:

script/generate migration AddRoleRolesMaskToUser roles_mask:integer
rake db:migrate

Następnie dopisujemy do modelu User:

class User < ActiveRecord::Base
  attr_accessible :login, :email, :password, :password_confirmation
  attr_accessible :roles

  has_many :fortunes

  named_scope :with_role, lambda {
    |role| {:conditions => "roles_mask & #{2**ROLES.index(role.to_s)} > 0"}
  }

  ROLES = %w[admin moderator author]

  def roles=(roles)
    self.roles_mask = (roles & ROLES).map { |r| 2**ROLES.index(r) }.sum
  end
  def roles
    ROLES.reject { |r| ((roles_mask || 0) & 2**ROLES.index(r)).zero? }
  end
  def role?(role)
    roles.include? role.to_s
  end

Do widoku częściowego users/_form.html.erb dopisujemy:

<p>
  <%= f.label :roles %><br />
  <% for role in User::ROLES %>
    <%= check_box_tag "user[roles][]", role, @user.roles.include?(role) %>
    <%=h role.humanize %><br />
  <% end %>
  <%= hidden_field_tag "user[roles][]", "" %>
</p>

I jeszcze komentarz do kodu: „use of hidden_field_tag to workaround a limitation in HTML form checkbox”.

Jak to działa? Korzystamy z wirtualnego atrybutu roles.

Powiązania między modelami

Każada fortunka została wpisana przez jakiegoś użytkownika, a użytkownik mógł wpisać wiele fortunek:

belongs_to :user    // model Fortune
has_many :fortunes  // model User

Migracja:

script/generate migration AddUserIdToFortune user_id:integer
rake db:migrate

Dopisujemy identyfikator użytkownika w modelu Fortune do attr_accessible, ponieważ będziemy chcieli aby admini i moderatorzy mogli go zmieniać:

attr_accessible :quotation, :user_id

Umieszczamy przykładowe dane w bazie

Przykładowe dane wpiszemy w pliku db/seeds.rb. W Railsach jest specjalne zadanie rake, które dane z tego pliku „przepisze” do bazy:

rake db:seed  # load the seed data from db/seeds.rb

Zaczynamy od umieszczenia w bazie trzech użytkowników:

User.create :login => 'wlodek', :email => 'matwb@ug.edu.pl',
  :password=> '1234', :password_confirmation => '1234',
  :roles => ["admin", "", ""]
User.create :login => 'renia', :email => 'renia@example.pl',
  :password=> '1234', :password_confirmation => '1234',
  :roles => ["", "moderator", ""]
User.create :login => 'bazylek', :email => 'bazylek@cats.com',
  :password=> '1234', :password_confirmation => '1234',
  :roles => ["", "", "author"]

W tej wersji Fortunki skorzystamy z cytatów ze strony The Quotations Page. Każdemu użytkownikowi przypiszemy po dwa cytaty:

Fortune.create :quotation => "Men willingly believe what they wish.",
  :user_id => 1
Fortune.create :quotation => "I have often regretted my speech, never my silence.",
  :user_id => 1
Fortune.create :quotation => "All science is either physics or stamp collecting.",
  :user_id => 3
Fortune.create :quotation => "Nothing shocks me. I'm a scientist.",
  :user_id => 3
Fortune.create :quotation => "Science is organized knowledge.",
  :user_id => 3
Fortune.create :quotation => "Security is a kind of death.",
  :user_id => 3

Zmiany w layoucie

W główce każdej strony umieścimy login zalogowanego użytkownika. Informację o zalogowanym użytkowniku znajdziemy w sesji. Jeśli w widoku strony głównej dopiszemy:

<%= debug(UserSession.find) %>

albo, lepiej:

<%= debug(current_user) %>

to po zalogowaniu się do aplikacji i przejściu na stronę główną powinniśmy zobaczyć co Authlogic zapisał w sesji:

--- !ruby/object:User
attributes:
  ...
  last_request_at: !timestamp
    at: "2010-05-10 11:24:35.641914 Z"
    "@marshal_with_utc_coercion": true
  crypted_password: 50ae…9f
  perishable_token: EFHnHjn_-stHPpMPFoMb
  ...
  current_login_ip: 127.0.0.1
  id: "1"
  current_login_at: 2010-05-10 11:23:58
  password_salt: 3Jm5kBjYvk6qUlZX50YA
  roles_mask: "1"
  login_count: "1"
  persistence_token: e5cb…fe
  ...
  login: wlodek
  email: matwb@ug.edu.pl
  ...

Stąd widzimy, że

current_user.login

to login zalogowanego użytkownika.

Zatem dopisujemy w layout/application.html.erb poniżej linka "Logout":

Hello <em><%= current_user.login %></em>

Zmiany na stronie głównej

Widok z rusztowania to tabelka. Nie wygląda to zbyt ładnie. Zamiast tabelki uzyjemy kilku elementów div.

A w widoku show.html.erb przy każdym cytacie dopiszemy login użytkownika który dodał cytat.

Oto oryginalny widok fortunes/index.html.erb:

<table>
  <tr>
    <th>Quotation</th>
  </tr>
  <% for fortune in @fortunes %>
    <tr>
      <td><%=h fortune.quotation %></td>
      <td><%= link_to "Show", fortune %></td>
      <% if logged_in? %>
        <td><%= link_to "Edit", edit_fortune_path(fortune) %></td>
        <td><%= link_to "Destroy", fortune, :confirm => 'Are you sure?',
                  :method => :delete %></td>
      <% end %>
    </tr>
  <% end %>
</table>
<p><%= link_to "New Fortune", new_fortune_path %></p>

A to ten sam widok po „liftingu”:

<% for fortune in @fortunes %>
  <p><%=h fortune.quotation %>
  <p><%= link_to "Show", fortune %>
     <% if logged_in? %>
       | <%= link_to "Edit", edit_fortune_path(fortune) %>
       | <%= link_to "Destroy", fortune, :confirm => 'Are you sure?',
                  :method => :delete %>
     <% end %>
  </p>
<% end %>
<p><%= link_to "New Fortune", new_fortune_path %></p>

A to są poprawki do widoku fortunes/show.html.erb:

<p>
  <strong>Quotation </strong> added by <em><%= @fortune.user.login %></em>
</p>
<p>
  <%=h @fortune.quotation %>
</p>

Zmiany w edycji fortunek

Do widoku częściowego _form.html.erb dodajemy możliwość edycji użytkownika (użyjemy listy rozwijanej), który dodał cytat.

<% form_for @fortune do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :quotation %>:<br />
    <%= f.text_area :quotation, :cols => 71, :rows => 6 %>
  </p>
  <p>
    Zmień użytkownika: <%= f.collection_select :user_id, User.all, :id, :login %>
  </p>
  <p><%= f.submit "Submit" %></p>
<% end %>

Na razie każdy zalogowany użytkownik może dodawany przez siebie cytat przypisać innemu użytkownikowi. Później ograniczymy możliwość edycji użytkownika do admina i moderatora.

Tyle przygotowań i poprawek w aplikacji. Teraz zabieramy się za implementację autoryzacji.

Implementujemy autoryzację

[Autoryzacja]

Zaczynamy instalacji gemu cancan w Fortunce:

Rails::Initializer.run do |config|
  config.gem "authlogic", "2.1.3"
  config.gem "cancan", "1.1.1"

Autoryzację będziemy programować tworząc nową klasę i domieszkujac do niej moduł CanCan::Ability:

class Ability
  include CanCan::Ability

  def initialize(user)
  end
end

gdzie do metody initialize przkazywany jest obiekt user. W metodzie initialize określamy to co może każdy użytkownik.

Autor gemu sugeruje, aby powyższy kod umieścić w pliku models/ability.rb.

Zaczynamy umożliwienia każdemu użytkownikowi czytanie z modelu Fortune:

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new  # guest user
    can :read, Fortune
  end
end

CanCan definiuje kilka użytecznych aliasów:

alias_action :index, :show, :to => :read
alias_action :new, :to => :create
alias_action :edit, :to => :update

i skrótów:

can :manage, Fortune  # has permissions to do anything to fortunes
can :manage, :all     # has permission to do anything to any model

Uaktywniamy autoryzację wykomentowując wiersz z before_filter i wpisując w kodzie kontrolera FortunesController:

class FortunesController < ApplicationController
  #before_filter :require_user, :only => [:new, :edit, :create, :update, :destroy]
  load_and_authorize_resource

Ponieważ, CanCan jest kontrolerem napisanym w stylu RESTful, więc możemy usunąć wszystkie linjki z kodem:

@fortune = ...

ponieważ „resource is already loaded and authorized”.

Jest jeszcze jedna rzecz, o której zapomnieliśmy:

def create
  @fortune.user = current_user # przypisz cytat do zalogowanego użytkownika

Teraz możemy już sprawdzić jak to działa. Logujemy się do Fortunki i próbujemy wyedytować jakiś cytat. Po kliknieciu w link Edit powinnismy zobaczyć coś takiego:

CanCan::AccessDenied in FortunesController#edit
  You are not authorized to access this page.

Oczywiście, taki wyjątek powinnismy jakoś obsłużyć. Zrobimy to tak. Do pliku application_controller.rb dopiszemy:

rescue_from CanCan::AccessDenied do |exception|
  flash[:error] = exception.message
  redirect_to root_url
end

Sprawdzamy jak to działa — jest lepiej!

Następną rzeczą, którą zrobimy będzie ukrycie linków Edit i Destroy. Przy okazji będziemy musieli zamienić/usunąć kod, który dodaliśmy przy okazji autentykacji.

Zaczynamy od zmian w widoku fortunes/index.html.erb:

<% for fortune in @fortunes %>
  <p><%=h fortune.quotation %>
  <p class="right"><%= link_to "Show", fortune %>
     <!-- zamiast logged_in? -->
     <% if can? :update, fortune %>
       | <%= link_to "Edit", edit_fortune_path(fortune) %>
     <% end %>
     <% if can? :destroy, fortune %>
       | <%= link_to "Destroy", fortune, :confirm => 'Are you sure?',
                  :method => :delete %>
     <% end %>
  </p>
<% end %>
<% if can? :create, Fortune %>
  <p><%= link_to "New Fortune", new_fortune_path %></p>
<% end %>

Następnie w pliku show.html.erb poprawiamy akapit:

<p>
  <% if can? :update, @fortune %>
    <%= link_to "Edit", edit_fortune_path(@fortune) %>
  <% end %>
  <% if can? :destroy, @fortune %>
    | <%= link_to "Destroy", @fortune, :confirm => 'Are you sure?',
               :method => :delete %>
  <% end %>
</p>

Doprecyzowujemy Abilities

Role dodaliśmy w trakcie wstępnych przygotowań. Teraz zabierzemy się za przypisanie abilities do ról.

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new    # guest user
    if user.role? :admin
      can :manage, :all
    else
      can :read, Fortune
    end
  end
end

Sprawdzamy, jak to działa logując się jako admin (wlodek jest adminem) i jako zwykły użytkownik (bazylek).

Teraz zabieramy się za rolę author. Autor może dodawać, edytować i usuwać swoje cytaty:

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new

    if user.role? :admin
      can :manage, :all
    elsif user.role? :author
      can :read, Fortune
      can :create, Fortune
      can [:update, :destroy], Fortune, :user_id => user.id
    else
      can :read, Fortune
    end
  end
end

Na koniec zostawiliśmy rolę moderatora, który może tylko edytować i usuwać cytaty.

if user.role? :admin
  can :manage, :all
elsif user.role? :author
  can :read, Fortune
  can :create, Fortune
  can [:update, :destroy], Fortune, :user_id => user.id
elsif user.role? :moderator
  can :read, Fortune
  can [:update, :destroy], Fortune
else
  can :read, Fortune
end

Powinniśmy jeszcze zadbać o to, aby autor nie mógł przypisać nowej fortunki innemu użytkownikowi.

Niestety, nie wystarczy ukryć rozwijalnej listy „Zmień użytkownika” przed autorami. Użytkownik może przechwycić wysyłany formularz, na przykład za pomocą rozszerzenia Web Developer lub Tamper Data przeglądarki Firefox, i zmienić wysyłane dane.

Wydaje się, że najprostszym rozwiązaniem będzie zignorowanie przesyłanego id użytkownika w params[:fortune] w metodach updatecreate. Zamiast tego ustawimy dane użytkownika na current_user:

def update
  if current_user.role? :author
    # to samo co params[:fortune][:user_id] = current_user.id
    @fortune.user = current_user
  end
  if @fortune.update_attributes(params[:fortune])

def create
  @fortune = Fortune.new(params[:fortune])
  @fortune.user = current_user  # przebijamy ustawienia z przesłanego formularza
  ...

Zanim ukryjemy rozwijalną listę select przed autorami, jeszcze jedna poprawka w kodzie kontrolera:

def new
  @fortune.user = current_user  # ustaw użytkownika na liście select
end

Sprawdzamy, czy wszystko działa. Jesli tak, to ukrywamy listę rozwijaną przed autorami (ale nie adminami i moderatorami). W widoku fortunes/_form.html.erb podmieniamy akapit z „Zmień użytkownika…” na:

<% if !current_user.role?(:author) %>
<p>
  Zmień użytkownika: <%= f.collection_select :user_id, User.all, :id, :login %>
</p>
<% end %>

Kosmetyka widoków

Dwie rzeczy należałoby zmienić w widoku fortunes/index.html.erb:

  1. Autorzy powinni po zalogowaniu widzieć tylko swoje cytaty.
  2. Admini i moderatorzy powinni po zalogowaniu widzieć kto dodał cytat.

Dlaczego?

Na stronie Upgrading 1.1 – cancan w sekcji Fetching Records jest przykład pokazujący jak zaprogramować punkt 1. powyżej. W fortunes_controller.rb w metodzie indeks podmieniamy:

@fortunes = Fortunes.accessible_by(current_ability)

Ale powyższy kod zwraca to samo co Fortunes.all — czyli nie działa (bug?).

Zamiast tego metodę indeks zaprogramujemy tak:

def index
  @fortunes = current_user.fortunes
end

co po uwzględnieniu adminów, moderatorów oraz niezalogowanych użytkowników daje:

def index
  if current_user && current_user.role?(:author)
    @fortunes = current_user.fortunes
  else
    @fortunes = Fortune.all
  end
end

Czy ten kod jest równoważny z rozwiązaniem korzystającym z accessible_by ze strony gemu CanCan?

Pozostaje punkt 2. Można to zrobić tak:

<% for fortune in @fortunes %>
  <p>
    <%=h fortune.quotation %>
    <% if logged_in? && (current_user.role?(:admin) || current_user.role?(:moderator)) %>
      (added by <em><%= fortune.user.login %></em>)
    <% end %>
  </p>