Статьи

CanCanCan: танец авторизации на Rails

Набор векторных силуэтов танцовщицы кабаре

Недавно я написал обзор некоторых популярных решений для аутентификации в Rails . Однако во многих случаях самой аутентификации недостаточно — вероятно, вам нужен механизм авторизации для определения правил доступа для различных пользователей. Существует ли существующее решение, предпочтительно не очень сложное, но все же гибкое?

Встречайте CanCanCan , гибкое решение для авторизации на Rails. Этот проект начался как CanCan, автором которого является Райан Бейтс, создатель RailsCasts Однако пару лет назад этот проект стал неактивным, поэтому члены сообщества решили создать CanCanCan, продолжение первоначального решения.

В этой статье я интегрирую CanCanCan в простой демонстрационный проект, определяя правила доступа, рассматривая возможные варианты и обсуждая, как CanCanCan может уменьшить дублирование кода. Прочитав этот пост, вы будете хорошо понимать основные функции CanCanCan и будете готовы использовать его в реальных проектах.

Исходный код можно найти на GitHub .

Рабочая демоверсия доступна по адресу sitepoint-cancan.herokuapp.com .

Подготовка детской площадки

Планирование и закладка фундамента

Чтобы начать взламывать CanCanCan, мы должны сначала подготовить площадку для наших экспериментов. Я собираюсь позвонить
мое приложение iCan, потому что я могу (хи!):

$ rails new iCan -T

Я собираюсь придерживаться Rails 4.1, но CanCanCan также совместим с Rails 3.

Демонстрационное приложение представит пользователям список проектов, как текущих, так и завершенных. Пользователи с разными ролями будут иметь разный уровень доступа:

  • Гости не будут иметь никакого доступа к проектам. Они будут видеть только главную страницу сайта.
  • Пользователи смогут видеть только текущие проекты. Они не смогут ничего изменить или удалить.
  • Модераторы будут иметь доступ ко всем проектам с возможностью редактировать текущие.
  • Админы будут иметь полный доступ.

Наша задача — представить эти роли и определить для них надлежащие правила доступа.

Я предпочитаю начать с Bootstrap, чтобы стилизовать приложение:

Gemfile

 [...]
gem 'bootstrap-sass'
[...]

Бегать

 $ bundle install

Настройте корневой маршрут:

конфиг / routes.rb

 [...]
root to: 'pages#index'
[...]

Создать контроллер:

pages_controller.rb

 class PagesController < ApplicationController
  def index
  end
end

и вид

просмотров / страниц / index.html.erb

 <div class="page-header"><h1>Welcome!</h1></div>

Измените макет, чтобы использовать преимущества стилей Bootstrap:

просмотров / макеты / application.html.erb

 [...]
<nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'iCan', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <ul class="nav navbar-nav">
      </ul>
    </div>
  </div>
</nav>

<div class="container">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>">
      <%= value %>
    </div>
  <% end %>
  <%= yield %>
</div>
[...]

Поддельная аутентификация

Итак, мы уже кратко обсудили роли, которые нужно добавить, и их уровни доступа, но сначала нам нужно ввести некоторую аутентификацию. CanCanCan на самом деле не волнует, какую систему аутентификации вы используете. Требуется только наличие метода current_usernil

Недавно я написал серию статей об аутентификации в Rails , поэтому не стесняйтесь выбирать одно из описанных там решений. Однако для этой демонстрации я не буду использовать настоящую аутентификацию, чтобы упростить вещи и сосредоточиться только на авторизации. Вместо этого я введу базовый класс User

модели / user.rb

 class User
  ROLES = {0 => :guest, 1 => :user, 2 => :moderator, 3 => :admin}

  attr_reader :role

  def initialize(role_id = 0)
    @role = ROLES.has_key?(role_id.to_i) ? ROLES[role_id.to_i] : ROLES[0]
  end

  def role?(role_name)
    role == role_name
  end
end

В основном, есть словарь со всеми доступными ролями. После инициализации назначьте пользователю одну из ролей
на основании предоставленного идентификатора. role? это просто обычный метод, который мы будем использовать позже.

Теперь давайте определим действие контроллера для установки роли:

sessions_controller.rb

 class SessionsController < ApplicationController
  def update
    id = params[:id].to_i
    session[:id] = User::ROLES.has_key?(id) ? id : 0
    flash[:success] = "Your new role #{User::ROLES[id]} was set!"
    redirect_to root_path
  end
end

Настройте маршрут:

конфиг / routes.rb

 [...]
resources :sessions, only: [:update]
[...]

Добавьте ссылки, чтобы выбрать роль:

просмотров / макеты / application.html.erb

 <nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'iCan', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <ul class="nav navbar-nav">
      </ul>

      <ul class="nav navbar-nav pull-right">
        <li class="dropdown">
          <a class="dropdown-toggle" aria-expanded="false" role="button" data-toggle="dropdown" href="#">
            Role
            <span class="caret"></span>
          </a>
          <ul class="dropdown-menu" role="menu">
            <% User::ROLES.each do |k, v| %>
              <li>
                <%= link_to session_path(k), method: :patch do %>
                  <%= v %>
                  <% if v == current_user.role %>
                    <small class="text-muted">(current)</small>
                  <% end %>
                <% end %>
              </li>
            <% end %>
          </ul>
        </li>
      </ul>
    </div>
  </div>
</nav>

Я полагаюсь на выпадающий виджет Bootstrap здесь, поэтому включите его:

application.js

 [...]
//= require bootstrap/dropdown
[...]

Также, если вы используете Turbolinks, jquery-turbolinks

Gemfile

 [...]
gem 'jquery-turbolinks'
[...]

application.js

 [...]
//= require jquery.turbolinks
[...]

Наконец, представьте метод current_user

application_controller.rb

 [...]
private

def current_user
  User.new(session[:id])
end

helper_method :current_user
[...]

Большой! Загрузите сервер и проверьте правильность переключения ролей.

Добавление проектов

Последнее, что нужно сделать, это добавить модель Project Каждый проект будет иметь только заголовок:

 $ rails g model Project title:string
$ rake db:migrate

контроллер:

projects_controller.rb

 class ProjectsController < ApplicationController
  def index
    @projects = Project.all
  end
end

Маршруты:

конфиг / routes.rb

 [...]
resources :projects
[...]

И мнение:

просмотров / проекты / index.html.erb

 <div class="page-header"><h1>Projects</h1></div>

<% @projects.each do |project| %>
  <div class="well well-sm">
    <h2><%= project.title %></h2>
  </div>
<% end %>

Давайте также воспользуемся seed.rb, чтобы добавить несколько демонстрационных записей в базу данных:

дБ / seeds.rb

 20.times {|i| Project.create!({title: "Project #{i + 1}"}) }

Бегать

 $ rake db:seed

заполнить таблицу ваших projects

Теперь детская площадка готова, и мы можем включить музыку и танцевать CanCanCan.

Интеграция CanCanCan и определение способностей

Вставьте CanCanCan в свой Gemfile

Gemfile

 [...]
gem 'cancancan', '~> 1.10'
[...]

и беги

 $ bundle install

Теперь нам нужно сгенерировать файл able.rb, который будет содержать все наши правила доступа:

 $ rails g cancan:ability

Откройте этот файл:

модели / ability.rb

 class Ability
  include CanCan::Ability

  def initialize(user)
  end
end

Все ваши правила доступа (принадлежат нам …. Извините) должны быть помещены в метод initialize Есть несколько закомментированных примеров, которые помогут вам начать работу.

usercurrent_user Ability

 def current_ability
  @current_ability ||= Ability.new(current_user)
end

Если, например, вы не хотите называть этот метод current_userAbilitycurrent_abilityApplicationController

Другой вариант переименования current_user

 alias_method :current_user, :my_own_current_user

Таким образом, current_ability Узнайте больше здесь .

В нашем случае current_user В реальном сценарии аутентификации он, вероятно, вернет nil

модели / ability.rb

 [...]
def initialize(user)
  user ||= User.new
end
[...]

Теперь введем первое правило доступа, говорящее, что администратор имеет полный доступ везде:

модели / ability.rb

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

  if user.role?(:admin)
    can :manage, :all
  end
end
[...]

can :manage:all

Проверка способностей

Давайте отобразим ссылку на главной странице, ведущую к списку проектов, и проверим, имеет ли пользователь надлежащий доступ:

просмотров / страниц / index.html.erb

 <div class="page-header"><h1>Welcome!</h1></div>

<% if can? :index, Project %>
  <%= link_to 'Projects', projects_path, class: 'btn btn-lg btn-primary' %>
<% end %>

Так can? это метод проверки, имеет ли текущий пользователь разрешение на выполнение действия. :indexProject Вы также можете предоставить объект вместо класса (пример мы увидим позже).

Существует также cannot? метод, который, как вы, наверное, догадались, выполняет противоположную проверку can? , Узнайте больше здесь .

К сожалению, ничто не мешает пользователю получить прямой доступ к странице проектов (например, «http: // localhost: 3000 / projects»). Следовательно, мы также должны обеспечить проверку авторизации внутри контроллера. Это легко:

projects_controller.rb

 [...]
def index
  @projects = Project.all
  authorize! :index, @project
end
[...]

Пойдите и попробуйте получить доступ к этой странице непосредственно как не администратор. Приложение теперь выдаст ошибку, но это не очень удобно для пользователя. Нам нужно решить еще одну проблему: как спасти от ошибки «доступ запрещен»?

Спасение от ошибки «Отказано в доступе»

Rails предоставляет нам хороший метод rescue_fromApplicationController

application_controller.rb

 [...]
rescue_from CanCan::AccessDenied do |exception|
  flash[:warning] = exception.message
  redirect_to root_path
end
[...]

Таким образом, если CanCan::AccessDenied Помимо messageaction:indexsubjectProject

Вы можете вручную вызвать ошибку «Отказано в доступе» и указать свое сообщение, действие и тему:

 raise CanCan::AccessDenied.new("You are not authorized to perform this action!", :custom_action, Project)

Попробуйте! Узнайте больше об обработке исключений здесь .

Добавляем больше способностей

Давайте добавим пару других действий контроллера для создания нового проекта и определим, кто может это делать:

projects_controller.rb

 [...]
def new
  @project = Project.new
  authorize! :new, @project
end

def create
  @project = Project.new(project_params)
  if @project.save
    flash[:success] = 'Project was saved!'
    redirect_to root_path
  else
    render 'new'
  end
  authorize! :create, @project
end

private

def project_params
  params.require(:project).permit(:title)
end
[...]

Виды:

просмотров / проекты / new.html.erb

 <div class="page-header"><h1>New Project</h1></div>

<%= render 'form' %>

просмотров / проекты / _form.html.erb

 <%= form_for @project do |f| %>
  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control' %>
  </div>

  <%= f.submit 'Post', class: 'btn btn-primary' %>
<% end %>

Добавить новую ссылку в верхнее меню:

 [...]
<ul class="nav navbar-nav">
  <% if can? :create, Project %>
    <li><%= link_to 'Add Project', new_project_path %></li>
  <% end %>
</ul>
[...]

Как видите, я использую :create:new

Теперь добавьте еще пару способностей:

модели / ability.rb

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

  if user.role?(:admin)
    can :manage, :all
  elsif user.role?(:moderator)
    can :create, Project
    can :read, Project
  elsif user.role?(:user)
    can :read, Project
  end
end

Подождите, что это значит :read Как насчет :index Похоже, что CanCanCan вводит некоторые псевдонимы действий по умолчанию:

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

:read:index:show:create:new Это действительно удобно, и вы можете легко определить свои собственные псевдонимы, используя тот же принцип:

 alias_action :update, :destroy, :to => :modify

Узнайте больше здесь .

Наконец, давайте разберемся с действиями editupdatedestroy

projects_controller.rb

 [...]
def edit
  @project = Project.find(params[:id])
  authorize! :edit, @project
end

def update
  @project = Project.find(params[:id])
  if @project.update_attributes(project_params)
    flash[:success] = 'Project was updated!'
    redirect_to root_path
  else
    render 'edit'
  end
  authorize! :update, @project
end

def destroy
  @project = Project.find(params[:id])
  if @project.destroy
    flash[:success] = 'Project was destroyed!'
  else
    flash[:warning] = 'Cannot destroy this project...'
  end
  redirect_to root_path
  authorize! :destroy, @project
end
[...]

Вид:

edit.html.erb

 <div class="page-header"><h1>Edit Project</h1></div>

<%= render 'form' %>

Теперь добавьте две кнопки для редактирования и уничтожения проекта:

просмотров / проекты / index.html.erb

 [...]
<% @projects.each do |project| %>
  <div class="well well-sm">
    <h2><%= project.title %></h2>
    <% if can? :update, project %>
      <%= link_to 'Edit', edit_project_path(project), class: 'btn btn-info' %>
    <% end %>

    <% if can? :destroy, project %>
      <%= link_to 'Delete', project_path(project), class: 'btn btn-danger', method: :delete, data: {confirm: 'Are you sure?'} %>
    <% end %>
  </div>
<% end %>

Я передаю объект projectProject Например, я могу добавить правило о том, что пользователь может редактировать только проект, который был добавлен менее 2 часов назад.

По способностям:

модели / ability.rb

 if user.role?(:admin)
  can :manage, :all
elsif user.role?(:moderator)
  can [:create, :read, :update], Project
elsif user.role?(:user)
  can :read, Project
end

Обратите внимание, что метод can На самом деле, второй аргумент также может быть массивом:

 can [:create, :read, :update], [Project, Task]

Мы можем переписать эту строку

 can [:create, :read, :update], Project

по-другому, исключая некоторые разрешения:

 can :manage, Project
cannot :destroy, Project

Это означает, что пользователь может делать все с проектами, но не может их уничтожать. Обратите внимание, что здесь важен порядок строк. Если вы cannotлюбые действия над проектами. Вы можете прочитать больше о приоритете здесь .

Работа с дублированием кода

Тебе не кажется, что звонят, can в действиях каждого контроллера это довольно утомительно? Более того, что если вы забудете включить его в какой-либо метод. CanCanCan справляется и с этим! Использование authorize!

projects_controller.rb

 load_and_authorize_resource

На самом деле этот метод состоит из двух методов: class ProjectsController < ApplicationController
load_and_authorize_resource
[...]
end
load_resourceauthorize_resource
Вы можете позвонить им по отдельности, если вам нравится:

 load_resource
authorize_resource

load_resourceauthorize_resourceindex Но как ресурс загружается для разных действий?

  • Для Model.accessible_by(current_ability)accessible_by show
  • Для editupdatedestroyfindModel.find(params[:id])new
  • Для createnewfind
  • Для пользовательских (не CRUD) действий ресурс будет загружен с использованием команды class ProjectsController < ApplicationController
    load_and_authorize_resource
    [...]

    def update
    if @project.update_attributes(project_params)
    flash[:success] = 'Project was updated!'
    redirect_to root_path
    else
    render 'edit'
    end
    end

    def create
    if @project.save
    flash[:success] = 'Project was saved!'
    redirect_to root_path
    else
    render 'new'
    end
    end

    def destroy
    if @project.destroy
    flash[:success] = 'Project was destroyed!'
    else
    flash[:warning] = 'Cannot destroy this project...'
    end
    redirect_to root_path
    end
    end

Итак, наш контроллер может быть упрощен следующим образом:

projects_controller.rb

 authorize!

Что изменилось?

  • Я удалил authorize_resource вызовы метода, потому что index
  • Во-вторых, я полностью удалил действия neweditload_resource@project = Project.find(params[:id])
  • В-третьих, я удалил updatedestroy@project = Project.new(project_params)createload_resourcecreate_params

Да, я знаю, о чем ты думаешь. Как насчет сильных параметров? Как насчет сортировки и нумерации страниц? Что если я хочу пропустить загрузку и авторизацию ресурса для некоторых действий? Что делать, если мне нужен пользовательский искатель? Это замечательные вопросы, давайте обсудим их один за другим.

Сильные параметры . При инициализации ресурса CanCanCan проверяет, отвечает ли контроллер следующим методам:

  • update_paramscreate_params CanCanCan собирается использовать один из этих методов для очистки входных данных в зависимости от текущего действия. Это круто, потому что вы можете определить различные правила очистки для создания и обновления.
  • Если метод update_params_params project_params Метод — это то, что мы используем в нашей демонстрации ( resource_params
  • Наконец, CanCanCan будет искать метод со статическим именем load_and_authorize_resource param_method: :my_sanitizer
  • Вы также можете [...]
    before_action :load_projects, only: :index
    load_and_authorize_resource

    [...]

    private

    def load_projects
    @projects = Project.accessible_by(current_ability).order('created_at DESC')
    end

    [...]load_resource

  • Если CanCanCan не смог найти ни один из этих методов в контроллере, и пользовательское дезинфицирующее средство не установлено, он будет инициализировать ресурс как обычно.

Переопределить загрузку ресурса . Например, я хочу, чтобы проекты на странице индекса сортировались по дате создания по убыванию. Вы можете сделать это легко:

projects_controller.rb

 before_action

Идея состоит в том, что @projects Пока я добавил load_resourceindexbefore_actionload_and_authorize_resource Важно поместить load_projectsперед accessible_by

Обратите внимание, что внутри load_and_authorize_resource only: :index
# or
load_and_authorize_resource except: :index
load_resource

Пропуск загрузки и авторизация ресурса . Если по какой-то причине вы хотите пропустить эти действия, просто напишите:

 find

Пользовательские искатели . Как мы уже видели, find_byload_resource find_by: :title
authorize_resource
Это легко изменить, предоставив параметр ongoing

 $ rails g migration add_ongoing_to_projects ongoing:boolean

CanCanCan действительно гибкий, и вы можете легко изменить его поведение по умолчанию! Полную информацию можно найти здесь .

Добавление условий к способностям

Теперь предположим, что каждый проект имеет [...]
def change
add_column :projects, :ongoing, :boolean, default: true
add_index :projects, :ongoing
end
[...]
Пользователи должны иметь доступ только к текущим проектам, а модераторы могут просматривать все проекты, но обновлять только текущие.

Прежде всего, добавьте новую миграцию:

 $ rake db:migrate

Измените файл миграции:

xxx_add_ongoing_to_projects.rb

 <%= form_for @project do |f| %>
  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= f.label :ongoing %>
    <%= f.check_box :ongoing %>
  </div>

  <%= f.submit 'Post', class: 'btn btn-primary' %>
<% end %>

Применить миграцию:

 [...]
def project_params
  params.require(:project).permit(:title, :ongoing)
end
[...]

Не забудьте обновить форму:

просмотров / проекты / _form.html.erb

 [...]
if user.role?(:admin)
  can :manage, :all
elsif user.role?(:moderator)
  can :create, Project
  can :update, Project do |project|
    project.ongoing?
  end
  can :read, Project
elsif user.role?(:user)
  can :read, Project, ongoing: true
end
[...]

И измените список разрешенных атрибутов:

projects_controller.rb

 can

Теперь о способностях:

модели / ability.rb

 accessible_by

user Для хеша,
важно использовать только столбцы таблицы, потому что эти условия будут использоваться с методом SELECT "projects".* FROM "projects" WHERE "projects"."ongoing" = 't' ORDER BY created_at DESC Узнайте больше здесь .

Теперь переключитесь на роль accessible_by В консоли вы должны увидеть вывод, похожий на этот:

 check_authorization

Это означает, что ApplicationControllerфайлеility.rb . Очень круто.

Принудительная авторизация

Если вы хотите проверить, что авторизация происходит на каждом контроллере, вы можете добавить class ApplicationController < ActionController::Base
check_authorization
[...]
end
CanCan::AuthorizationNotPerformed

application_controller.rb

 class PagesController < ApplicationController
  skip_authorization_check
end

Если авторизация не выполняется в одном из действий, возникнет ошибка class SessionsController < ApplicationController
skip_authorization_check
end
Тем не менее, мы хотим пропустить эту проверку для некоторых контроллеров, так как любой пользователь должен иметь доступ к главной странице и переключаться между ролями. Это легко:

pages_controller.rb

 skip_authorization_check

sessions_controller.rb

 only

exceptcheck_authorizationif Кроме того, unless

 check_authorization if: :admin_subdomain?

private

def admin_subdomain?
  request.subdomain == "admin"
end

Узнайте больше здесь .

Вывод

В этой статье мы обсудили CanCanCan, отличное решение для авторизации в Rails. Я надеюсь, что теперь вы уверены в том, чтобы использовать его в своих проектах и ​​реализовывать более сложные сценарии. Я настоятельно рекомендую вам просмотреть вики CanCanCan , так как она содержит действительно полезные примеры.

Спасибо за чтение! Как всегда, любой читатель имеет право прислать свой отзыв об этой статье :). Увидимся!