Давно не писал в блог, а надо. Попробую писать почаще.
Этот пост из серии Considered harmful, в которой я буду рассказывать о том, как не надо делать. То есть, о вещах, которые нам предлагают делать, которые, на первый взгляд, приносят какие-то плюшки, но которые в итоге приходится убирать или с которыми приходится мучиться.
Сегодня мы поговорим о реализации аутентификационных запретов посредством ограничений в маршрутном файле. Уф. В общем, о authentication routing constraints.
Каждая библиотечка аутентификации в мире Рельсов, работающая на уровне контроллеров, считает своим долгом предложить своему пользователю routing constraint. Примеры:
Для аутентификации не нужно использовать routing constraints. Если же всё же хочется отделить проверку полномочий от конкретных контроллеров и вытащить её в отдельный файл, всё встаёт на свои месте, если пользоваться библиотекой для авторизации. Что неудивительно, поскольку это – авторизация.
Этот пост из серии 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 – сами по себе хорошие. У них просят, они и предлагают. Вы не пользуйтесь. Они же не только это предлагают.