Статьи

Простая авторизация Rails с Pundit

LTKkxELyc

Это вторая статья из серии «Авторизация с Rails». В предыдущей статье мы обсуждали CanCanCan , широко известное решение, созданное Райаном Бейтсом и поддерживаемое группой энтузиастов. Сегодня я собираюсь представить вам немного менее популярную, но все еще жизнеспособную библиотеку под названием Pundit, созданную людьми из ELabs .

«Пандит» означает «ученый», «умный парень» (иногда используется как негативное описание), но вам не нужно быть гением, чтобы использовать его в своих проектах. Пандит действительно легко понять, и, поверьте мне, он вам понравится. Я влюбился в него, когда просматривал документацию.

Идея, лежащая в основе Pundit, заключается в использовании простых старых классов и методов Ruby без привлечения каких-либо специальных DSL. Этот драгоценный камень добавляет только пару полезных помощников, так что в целом вы можете создать свою систему так, как считаете нужным. Это решение немного более низкого уровня, чем CanCanCan, и действительно интересно сравнить их.

В этой статье мы обсудим все основные функции Pundit: работа с правилами доступа, использование вспомогательных методов,
и определение и определение разрешенных атрибутов.

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

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

Препараты

Наши приготовления будут очень быстрыми. Идите вперед и создайте новое приложение Rails. Pundit работает с Rails 3 и 4, но для этой демонстрационной версии будет использоваться версия 4.1.

Вставьте следующие драгоценные камни в свой Gemfile :

Gemfile

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

Клиренс будет использоваться для очень быстрой настройки аутентификации. Эта жемчужина была рассмотрена в моей статье «Простая аутентификация Rails с очисткой» , так что вы можете обратиться к ней для получения дополнительной информации.

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

Не забудь бежать

 $ bundle install

Теперь запустите генератор Clearance, который собирается создать модель User

 $ rails generate clearance:install

Измените макет, включив в него флеш-сообщения, так как Clearance и Pundit используют их для отображения информации пользователю:

макеты / application.html.erb

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

Создайте новый эшафот для Post

 $ rails g scaffold Post title:string body:text

Мы также хотим, чтобы наши пользователи вошли в систему перед началом работы с приложением:

application_controller.rb

 [...]
before_action :require_login
[...]

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

routes.rb

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

Администратор

Чтобы завершить настройку нашей лабораторной среды, нам нужно добавить поле adminusers

xxx_create_users.rb

 [...]
t.boolean :admin, default: false, null: false
[...]

Запустите миграции:

 $ rake db:migrate

Наконец, давайте добавим небольшую кнопку, чтобы легко переключаться между состояниями администратора:

макеты / application.html.erb

 [...]
<% if current_user %>
  <div class="well well-sm">
    Admin: <strong><%= current_user.admin? %></strong><br>
    <%= link_to 'Toggle admin rights', user_path(current_user), method: :patch, class: 'btn btn-info' %>
  </div>
<% end %>
[...]

Мы должны проверить, присутствует ли current_user

Добавить маршрут:

routes.rb

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

и контроллер:

users_controller.rb

 class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    @user.toggle!(:admin)
    flash[:success] = 'OK!'
    redirect_to root_path
  end
end

Это оно! Лабораторная среда готова, так что теперь мы можем начать играть с Pundit.

Интегрирование Pundit

Для начала добавьте следующую строку в ApplicationController

application_controller.rb

 [...]
include Pundit
[...]

Затем запустите генератор Pundit:

 $ rails g pundit:install

Это создаст базовый класс с политиками внутри папки app / icies. Классы политики — это ядро ​​Pundit, и мы будем активно с ними работать. Базовый класс политики выглядит следующим образом:

приложение / политика / application_policy.rb

 class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user = user
    @record = record
  end

  def index?
    false
  end

  def show?
    scope.where(:id => record.id).exists?
  end

  def create?
    false
  end

  def new?
    create?
  end

  # [...]
  # some stuff omitted

  class Scope
    # [...]
  end
end

Каждая политика является базовым классом Ruby, но вы должны иметь в виду несколько вещей:

  • Политики должны быть названы в честь модели, к которой они принадлежат, но с префиксом « Policy Например, используйте PostPolicyPost Если у вас нет связанной модели, вы все равно можете использовать Pundit — читайте больше здесь .
  • Первым аргументом метода initialize Pundit использует метод current_userpundit_user Узнайте больше здесь .
  • Второй аргумент — это модельный объект. Это не обязательно должен быть объект ActiveModel
  • Класс политики должен реализовывать методы запросов, такие как create? или new? проверить права доступа.

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

Предоставление правил доступа

Теперь давайте напишем наше первое правило доступа. Например, мы хотим, чтобы только администраторы могли уничтожать посты. Это легко сделать! Создайте новый файл post_policy.rb внутри папки политики и вставьте следующий код:

политика / post_policy.rb

 class PostPolicy < ApplicationPolicy
  def destroy?
    user.admin?
  end
end

Как вы видите, destroy? Метод будет возвращать truefalse

Внутри контроллера нам нужно проверить наше правило:

posts_controller.rb

 [...]
def destroy
  authorize @post
  @post.destroy

  redirect_to posts_url, notice: 'Post was successfully destroyed.'
end
[...]

authorize Это полезно, если имя вашего действия отличается, например:

 def publish
  authorize @post, :update?
end

Вы также можете передать имя класса вместо экземпляра, например, если у вас нет ресурса для работы:

 authorize Post

Если пользователю не разрешено выполнять действие, возникнет ошибка. Нам нужно спастись от него и вместо этого отобразить полезное сообщение. Самым простым решением будет просто отобразить некоторый основной текст:

application_controller.rb

 [...]
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

def user_not_authorized
  flash[:warning] = "You are not authorized to perform this action."
  redirect_to(request.referrer || root_path)
end
[...]

Однако вам может потребоваться настроить собственное сообщение для разных случаев или перевести его на другие языки. Это также возможно:

application_controller.rb

 [...]
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

def user_not_authorized(exception)
  policy_name = exception.policy.class.to_s.underscore
  flash[:warning] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
  redirect_to(request.referrer || root_path)
end
[...]

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

конфиг / локали / en.yml

 en:
 pundit:
   default: 'You cannot perform this action.'
   post_policy:
     destroy?: 'You cannot destroy this post!'

Если пользователь не может уничтожить сообщение, нет смысла рендерить кнопку «Уничтожить». К счастью, Pundit предоставляет специальный вспомогательный метод:

просмотров / сообщений / index.html.erb

 [...]
<% if policy(post).destroy? %>
  <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
<% end %>
[...]

Идите и проверьте это! Все должно работать хорошо.

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

политика / application_policy.rb

 [...]
def initialize(user, record)
  raise Pundit::NotAuthorizedError, "must be logged in" unless user
  @user = user
  @record = record
end
[...]

Только не забудьте спасти от ошибки Pundit::NotAuthorizedErrorApplicationController

Настройка отношений

Теперь давайте установим отношения «один ко многим» между сообщениями и пользователями. Создайте новую миграцию и примените ее:

 $ rails g migration add_user_id_to_posts user:references
$ rake db:migrate

Изменить файлы модели:

модели / post.rb

 [...]
belongs_to :user
[...]

модели / user.rb

 [...]
has_many :posts
[...]

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

 20.times do |i|
  Post.create({title: "Post #{i + 1}", body: 'test body', user_id: i > 10 ? 1 : 2})
end

Мы просто создаем 20 постов, принадлежащих разным пользователям. Не стесняйтесь изменять этот код по мере необходимости.

Бегать

 $ rake db:seed

заполнить базу данных.

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

политика / post_policy.rb

 [...]
def destroy?
  user.admin? || record.user == user
end
[...]

Как вы, наверное, помните, record Это хорошо, но мы можем сделать больше. Как насчет создания области для загрузки только сообщений, которыми владеет пользователь? Pundit это тоже поддерживает!

Работа с областями действия

Прежде всего, создайте новое, не RESTful действие:

posts_controller.rb

 def user_posts
end

routes.rb

 resources :posts do
  collection do
    get '/user_posts', to: 'posts#user_posts', as: :user
  end
end

Добавить верхнее меню:

макеты / application.html.erb

 [...]
<nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'Pundit', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <ul class="nav navbar-nav">
        <li><%= link_to 'All posts', posts_path %></li>
        <li><%= link_to 'Your posts', user_posts_path %></li>
      </ul>
    </div>
  </div>
</nav>
[...]

Нам нужно создать реальную сферу. Внутри файла application_policy.rb есть класс Scope

политика / application_policy.rb

 class Scope
  attr_reader :user, :scope

  def initialize(user, scope)
    @user = user
    @scope = scope
  end

  def resolve
    scope
  end
end

Как и в случае с политиками, необходимо учитывать несколько моментов:

  • Класс должен называться Scope
  • Первый аргумент, передаваемый методу initializeпользователь , как и с политиками.
  • Второй аргумент — это область действия (экземпляр ActiveRecordActiveRecord::Relation
  • Класс Scope

Просто наследуйте от базового класса и реализуйте свой собственный метод resolve

политика / post_policy.rb

 resolve

Эта область просто загружает все сообщения, которые принадлежат пользователю. Используйте эту новую область внутри вашего контроллера:

posts_controller.rb

 class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.where(user: user)
    end
  end
  [...]
end

Возможно, вы захотите извлечь некоторый код из представления индекса в партиалы (и немного подправить таблицу):

просмотров / сообщений / _post.html.erb

 [...]
def user_posts
  @posts = policy_scope(Post)
end
[...]

просмотров / сообщений / _list.html.erb

 <tr>
  <td><%= post.title %></td>
  <td><%= post.body %></td>
  <td><%= link_to 'Show', post %></td>
  <td><%= link_to 'Edit', edit_post_path(post) %></td>
  <% if policy(post).destroy? %>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  <% end %>
</tr>

просмотров / сообщений / index.html.erb

 <table class="table table-bordered table-striped table-condensed table-hover">
  <thead>
  <tr>
    <th>Title</th>
    <th>Body</th>
    <th colspan="3"></th>
  </tr>
  </thead>

  <tbody>
  <%= render @posts %>
  </tbody>
</table>

<br>

<%= link_to 'New Post', new_post_path %>

Теперь просто создайте новый вид:

просмотров / сообщений / user_posts.html.erb

 <h1>Listing Posts</h1>

<%= render 'list' %>

Загрузи сервер и наблюдай за результатом!

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

 <h1>Your Posts</h1>

<%= render 'list' %>

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

Если вы хотите проверить, что авторизация имела место в вашем контроллере, используйте <% policy_scope(@user.posts).each do |post| %>
<% end %>
Вы также можете выбрать, какие действия проверять:

posts_controller.rb

 verify_authorized

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

posts_controller.rb

 [...]
after_action :verify_authorized, only: [:destroy]
[...]

Однако в некоторых случаях проверка авторизации может быть необоснованной, поэтому вы можете пропустить ее. Например, если уничтожаемая запись не была найдена, мы просто возвращаемся без дальнейших действий. Для этого [...]
after_action :verify_policy_scoped, only: [:user_posts]
[...]
skip_authorization

 [...]
def destroy
  if @post.present?
    authorize @post
    @post.destroy
  else
    skip_authorization
  end

  redirect_to posts_url, notice: 'Post was successfully destroyed.'
end

private

def set_post
  @post = Post.find_by(id: params[:id])
end
[...]

Разрешенные параметры

Последняя функция, которую мы собираемся обсудить, — это возможность определения разрешенных параметров в политиках Pundit. Обратите внимание, что для правильной работы вы должны использовать Rails 4 или Rails 3 с гемом strong_params .

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

 $ rails g migration add_special_to_posts special:boolean

Немного измените миграцию:

xxx_add_special_to_posts.rb

 [...]
add_column :posts, :special, :boolean, default: false
[...]

и применить его:

 $ rake db:migrate

Теперь определите новый метод в ваших политиках:

политика / post_policy.rb

 [...]
def permitted_attributes
  if user.admin?
    [:title, :body, :special]
  else
    [:title, :body]
  end
end
[...]

Таким образом, администратор может установить все атрибуты, тогда как пользователи могут изменять только titlebody Наконец, обновите методы контроллера:

posts_controller.rb

 [...]
def create
  @post = Post.new
  @post.update_attributes(permitted_attributes(@post))

  if @post.save
    redirect_to @post, notice: 'Post was successfully created.'
  else
    render :new
  end
end

def update
  if @post.update(permitted_attributes(@post))
    redirect_to @post, notice: 'Post was successfully updated.'
  else
    render :edit
  end
end
[...]

permitted_attributes Внутри метода create@post Если вы делаете это:

 @post = Post.new(permitted_attributes(@post))

возникнет ошибка, потому что вы пытаетесь передать несуществующий объект в permitted_attributes

Вместо использования permitted_attributespost_params

posts_controller.rb

 [...]
def post_params
  params.require(:post).permit(policy(@post).permitted_attributes)
end
[...]

Наконец, измените представления:

просмотров / сообщений / _form.html.erb

 [...]
<div class="field">
  <%= f.label :special %><br>
  <%= f.check_box :special %>
</div>
[...]

просмотров / сообщений / _list.html.erb

 [...]
<thead>
<tr>
  <th>Title</th>
  <th>Body</th>
  <th>Special?</th>
  <th colspan="3"></th>
</tr>
</thead>
[...]

просмотров / сообщений / _post.html.erb

 <tr>
  <td><%= post.title %></td>
  <td><%= post.body %></td>
  <td><%= post.special? %></td>
  <td><%= link_to 'Show', post %></td>
  <td><%= link_to 'Edit', edit_post_path(post) %></td>
  <% if policy(post).destroy? %>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  <% end %>
</tr>

Теперь загрузите сервер и попытайтесь установить для «специального» параметра значение true, будучи обычным пользователем — вы не сможете этого сделать.

Вывод

В этой статье мы обсудили Pundit — отличное решение для авторизации, использующее базовые классы Ruby. Мы рассмотрели большинство его возможностей. Я рекомендую вам просмотреть его документацию, потому что вас может заинтересовать, как предоставить дополнительный контекст или указать класс политики вручную .

Вы когда-нибудь использовали Pundit раньше? Рассматриваете ли вы использовать его в будущем? Как вы думаете, это удобнее, чем CanCanCan, или это просто другое решение? Поделитесь своим мнением в комментариях!

Как всегда, я благодарю вас за то, что вы остались со мной. Если вы хотите, чтобы я осветил тему, не стесняйтесь спрашивать. Увидимся!