понедельник, 27 апреля 2015 г.

Rails authentication routing constraints considered harmful

Давно не писал в блог, а надо. Попробую писать почаще.

Этот пост из серии Considered harmful, в которой я буду рассказывать о том, как не надо делать. То есть, о вещах, которые нам предлагают делать, которые, на первый взгляд, приносят какие-то плюшки, но которые в итоге приходится убирать или с которыми приходится мучиться.

Сегодня мы поговорим о реализации аутентификационных запретов посредством ограничений в маршрутном файле. Уф. В общем, о authentication routing constraints.

Проблема

Реализация аутентификации в приложении требует способ задания запретов. Способ сказать в приложении "а вот сюда можно только залогиненным пользователям".

Решение


Каждая библиотечка аутентификации в мире Рельсов, работающая на уровне контроллеров, считает своим долгом предложить своему пользователю routing constraint. Примеры:


  • Devise
    authenticated :user do
      resources :posts
    end
  • Clearance
    constraints Clearance::Constraints::SignedIn.new do
      resources :posts
    end
  • Monban
    constraints Monban::Constraints::SignedIn.new do
      resources :posts
    end
Ну, вы поняли. 

Работает это следующим образом – роутинговая система Рельсов позволяет на маршрут задавать условие, при невыполнении которого маршрут просто не сработает. Пример из Рельсов: 

class BlacklistConstraint
  def initialize
    @ips = Blacklist.retrieve_ips
  end

  def matches?(request)
    @ips.include?(request.remote_ip)
  end
end

Rails.application.routes.draw do
  get '*path', to: 'blacklist#index',
    constraints: BlacklistConstraint.new
end

Если ограничение отвергает запрос, роутинговая система не учитывает этот маршрут и переходит к следующему, а по достижении конца файла – бросает RoutingError, который приводит к ответу 404: страница не найдена.

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

Почему плохо


Этот подход поначалу кажется прекрасной альтернативой написанию в контроллере чего-то вроде before_action :authenticate_user!. Вот всё то же самое, но не нужно разбивать это по отдельным файлам контроллеров или выбирать подходящий базовый контроллер. Всё красиво записано именно там, где это нужно – в файле маршрутов. И вы именно так к этому и относитесь: как к замене authenticate_user!.

Однако, это не так. Дело в том, что authenticate_user! и ему подобные в случае, если пользователь незалогинен, не отдают пользователю 404. Они перенаправляют пользователя на страницу ввода логина-пароля с сообщением "вы не вошли в систему", а после успешного входа ещё чаще всего возвращают назад.

А вот routing constraints именно что возвращают 404. И это очень неудобно для пользователя. Потёр он сессию в браузере, зашёл на привычную страницу и увидел "Страница не найдена". Вдобавок, "Страница не найдена" в Рельсах по умолчанию не содержит навигации, поэтому, нужно опять что-то набирать в адресной строке и пользователь совершенно не понимает, что происходит и куда он попал.

Заказчик просит это исправить, вы разбираетесь в проблеме, убираете routing constraints и раскидываете по контроллерам authenticate_user!.

Что делать?


На это есть два ответа: простой и правильный. Прежде всего, не используйте routing constraints и пишите authenticate_user!. Это как минимум не идёт в разрез со стандартным способом делать вещи, и не доставляет проблем пользователю. Но есть и более правильный путь.

Правильный ответ


Дело в том, что проверка полномочий "гость или залогиненный пользователь" – всего лишь частный случай проверки полномочий в приложении вообще, то есть, авторизации. 

Используйте библиотеку авторизации, такую, как CanCan или Pundit. Везде, всегда не глядя делайте authorize! из этой библиотеки. Большинство из них позволяют потребовать, чтобы authorize выполнялся и бросить исключение, если этого не произошло.

При этом, нужно будет ещё отличить ситуацию "человеку сюда нельзя, потому что у него нет определённой роли" от ситуации "человеку сюда нельзя, потому что он не залогинился".

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

Пример для CanCan:

  • В контроллере (любом)
    class ArticlesController < ApplicationController
      load_and_authorize_resource
    
      def show
      end
    end
  • Вы можете указать ресурс и не обязаны его загружать:
    class ArticlesController < ApplicationController
      authorize_resource :articles
    
      def show
      end
    end
  • Собственно, в том самом одном файле прав доступа:
    class Ability
      include CanCan::Ability
    
      def initialize(user)
        if user # права залогиненного пользователя
          can :manage, :articles
          # ...
        else # права незалогиненного пользователя (гостя)
          can :read, :home
        end
      end
    end

    Понятно, что вы можете пользоваться CanCan'ом и по назначению, проверяя у уже залогиненного пользователя наличие определённых ролей.
  • Соль: чтобы при несоответствии полномочий незалогиненных пользователей перенаправлять на страницу логина, а если полномочия не соответствуют уже для залогиненного пользователя, показывать "доступ запрещён" (пример для Devise):
    class ApplicationController < ActionController::Base
      rescue_from CanCan::AccessDenied do |exception|
        if user_signed_in?
          redirect_to root_url, :alert => exception.message # доступ запрещён
        else
          authenticate_user! # перенаправление на страницу логина
        end
      end
    end
  • Чтобы обязать себя всегда вызывать authorize:
    class ApplicationController < ActionController::Base
      check_authorization
    end

Вывод


Для аутентификации не нужно использовать routing constraints. Если же всё же хочется отделить проверку полномочий от конкретных контроллеров и вытащить её в отдельный файл, всё встаёт на свои месте, если пользоваться библиотекой для авторизации. Что неудивительно, поскольку это – авторизация.

Дополнительно

  • Routing constraints – штука сама по себе хорошая. Пользуйтесь. Но не для аутентификации.
  • Библиотеки аутентификации, которые предлагают routing constraints – сами по себе хорошие. У них просят, они и предлагают. Вы не пользуйтесь. Они же не только это предлагают.

1 комментарий:

  1. Nice article ^_^
    https://anadea.info/blog/rails-authentication-routing-constraints-considered-harmful
    "The correct answer" section
    "The point is that the check of access rights "guest or logged in user" – is just a particular case of access rights check in an app in general, i.e., AUTHENTICATION."
    but should be AUTHORIZATION

    ОтветитьУдалить