- PVSM.RU - https://www.pvsm.ru -
Аутентификация пользователей в экосистемах наподобие Google или Envato реализована в виде отдельных сервисов (accounts.google.com [1], account.envato.com [2]), предоставляющих необходимые данные и токены сайтам-клиентам. В ходе разработки некоторых проектов на Ruby on Rails мне и пришлось столкнуться с подобной задачей. По-научному — single sign-on [3] или технология единого входа [4].
Нужен был (1) общий сервис для всех сайтов экосистемы, с (2) преимущественно социальной авторизацией, в угоду входу по связке «логин+пароль».
Сервис, (3) аккумулирующий в себе данные из тех социальных сервисов, с помощью которых пользователь входит в систему, и (4) предоставляющий эти данные сайтам-клиентам.
Задача оказалась настолько же интересной, насколько и нестандартной. Началось все с полезной, но уже немного устаревшей статьи — автор предлагал использовать гем omniauth и кастомную стратегию на сайтах клиентах, а на сайте-провайдере — использовать тот же omniauth в связке с devise для аутентификации через соц. сервисы.
Devise в моем случае подходил мало (завязка на логине+пароле), поэтому предпочтение было полностью отдано omniauth. С этого и началось мое маленькое приключение, о ходе которого предлагаю вам ознакомиться в данной статье.
Рассмотрены будут три проекта: сайт-клиент [5], сайт-провайдер [6] и кастомная стратегия omniauth [7]. По ссылкам все они доступны на github и готовы к использованию. В статье будут подняты лишь ключевые моменты.
Запускать будем на localhost:4000.
Структура стандартна для любых сайтов, использующих omniauth:
gem 'omniauth'
gem 'omniauth-accounts'
Rails.application.config.middleware.use OmniAuth::Builder do
provider :accounts, ENV['ACCOUNTS_API_ID'], ENV['ACCOUNTS_API_SECRET'],
client_options: {
site: ENV['ACCOUNTS_API_SITE']
}
end
match '/auth/:provider/callback', :to => 'auth#callback'
rails g controller auth --skip-assets
# auth_controller.rb
class AuthController < ApplicationController
def callback
auth_hash = request.env['omniauth.auth']
render json: auth_hash
end
end
Вот и все, минимально.
Производная от стандартной oauth 2.0 стратегии, в Gemspec указана зависимость omniauth-oauth2. Кода совсем немного, к тому же — подстраивать его под себя не имеет смысла, все необходимые стратегии данные передаются в параметрах инициализации (в примере — в виде переменных окружения). Это:
Получив эти данные, стратегия всю дальнейшую работу берет на себя. Из-за этого, правда, в ходе разработки могут происходить неприятные случаи, когда в определенной ситуации стратегия «теряется» — не может продолжить свое выполнение дальше как планировалось. С такими проблемами пришлось столкнуться и мне, и решение каждой было найдено — будет рассмотрено далее в статье.
Запускать будем на localhost:3000.
Сочетает в себе две половины:
Аутентификация на сайте-провайдере происходит с помощью стандартных стратегий omniauth.
Аутентификация на сайте-клиенте — с помощью кастомной стратегии.
Общее звено — аккаунт (account):
Приятно бывает при регистрации на сайте-клиенте заполнить большую часть требуемых полей автоматически, из своего профиля в Facebook или Twitter. Наш сайт-провайдер будет играть роль агрегатора — пусть он агрегирует все данные из соц. сервисов в единой анкете, которую можно дополнять вручную, а сайты-клиенты будут брать информацию оттуда.
Данная тема ранее уже проскакивала на страницах хабра. К сожалению, никак не могу найти эту статью, но там, в частности, поднимался вопрос о типичных проблемах при социальной аутентификации на сайте:
Все это — типичные требования к системе подобного типа, так же как и валидация с отправкой письма на email — традиционное требование, сложившееся в аутентификации по логину+паролю. Кратко рассмотрим эти требования.
Зашли в систему через gmail-ящик — система создала один аккаунт, с данными из gmail'а. В следующий раз зашли через фейсбук, и система снова создала новый аккаунт. Смотрим и понимаем, что в прошлый раз уже аккаунт себе создавали через… вспоминаем… gmail! Кликаем по кнопке, заходим в этот раз через gmail и наши аккаунты сливаются в один — просто как две копейки!.. или нет — есть одна проблема. Слияние данных.
В gmail мы — Александр Половин, а в фейсбуке — Alex Polovin. И какие данные оставить в аккаунте?
Тут же при слиянии спросить у пользователя, что из этого оставить? Нет, это очень неудачная в плане юзабилити затея — пользователь ведь сливает аккаунты, чтобы поскорее снова зайти с помощью аккаунта на сайт, на который он заходил прежде, у него нет времени сейчас отвлекаться на диалоги вида «Заменить» и «Заменить все».
Моим решением стало добавление новых данных «про запас», как дополнительных значений полей аккаунта. По сути, все данные аккаунта хранятся в хеше, и этот хеш может принять следующий вид после слияния (добавим туда еще данные с условного твиттера — Половин Алекс):
{
name: ['Александр Половин', 'Alex Polovin', 'Половин Алекс'],
first_name: ['Александр', 'Alex', 'Алекс'],
sir_name: ['Половин', 'Polovin'],
...
}
Как видите, значения просто добавляются в массив для каждого поля. При этом они не дублируются — «Половин» из твиттера не сохранился дубликатом в «фамилии».
Сайты-клиенты же будут всегда получать самое первое значение из массивов, при желании пользователь может поставить на первое место любое из значений.
Среди всех данных, доступных omniauth из соц. сервисов, наиболее часто обновляется аватар пользователя. Чуть реже — ссылки на страницы (параметр urls), nickname и description в твиттере. В любом случае, возникает желание одним нажатием обновить их и в аккаунте… или оставить прежние — ситуации ведь бывают разные. Наш алгоритм для этого отлично подходит — записывает новые значения в конец массивов, не сохраняя дубликаты.
Аналог ключницы на хабре — в системе создается запись в таблице аутентификаций и привязывается к текущему аккаунту. Используется в дальнейшем как ключ и как источник данных.
Не все поля заполняются из соц. сервисов. Пользователь должен иметь возможность заполнить недостающие данные самостоятельно, на странице сайта-провайдера. А также — менять местами значения в массивах, что было упомянуто парой абзацев выше.
Чтобы Rails понимал info как хеш: в миграции указывается тип поля text, а в модель добавляется код:
serialize :info, Hash
Между моделями — связь один-ко-многим:
# /app/models/account.rb
has_many :authentications
# /app/models/authentication.rb
belongs_to :account
AuthenticationsController покрывает все нужды по аутентификации, включает следующие действия:
При выборе одного из сервисов аутентификации, выполняются стандартные для omniauth операции — венцом которых, в случае успешной аутентификации, является вызов callback-метода. В зависимости от ситуации он выполняет следующие действия:
Хеш данных при этом формируется в отдельном приватном методе get_data_hash() в зависимости от выбранного соц. сервиса.
Для добавления данных в конец массивов без дубликатов используется метод модели add_info (основан на операции объединения массивов):
def add_info(info)
self.info.merge!(info){|key, oldval, newval| [*oldval].to_a | [*newval].to_a}
end
А для привязки аутентификаций — add_authentications:
def add_authentications(authentications)
self.authentications << authentications
end
В результате, в сессии сохраняется id аккаунта, для которого был совершен вход — session[:account_id].
AccountsController на данном этапе содержит такие действия:
А также фильтр — обязательная проверка на наличие пользователя в сети (с редиректом на страницу входа login).
Очень хотелось добиться возможности действительно удобного и гибкого изменения данных. И такая задача до сих пор стоит и будет прорабатываться в будущем. Пока что — редактирование происходит двумя способами:
Стандартной практикой в этом случае является создание «приложения» на сайте-провайдере. Указываем имя и адрес сайта-клиента (вернее — адрес для callback-редиректа) — и получаем два ключа — id и secret. Их указываем в параметрах системы социальной аутентификации — будь то какой-либо плагин к cms, или гем для Rails. В нашем случае — ключи используются omniauth — ACCOUNTS_API_ID и ACCOUNTS_API_SECRET.
Внедрить поддержку приложений в сайт-провайдер несложно:
rails g scaffold Application name:string uid:string secret:string redirect_uri:string account_id:integer
rake db:migrate
# account.rb
has_many :applications
Модель при создании новой записи должна генерировать для нее ключи:
before_create :default_values
def default_values
self.uid = SecureRandom.hex(16)
self.secret = SecureRandom.hex(16)
end
И — во всех действиях на приложения должна стоять фильтрация по текущему пользователю. Например, вместо:
@applications = Application.all
используется:
@applications = Account.find(session[:account_id]).applications
Причем — обязательно добиваться того, чтобы пользователь был в сети — поставить фильтр:
before_filter :check_authentication
def check_authentication
if !session[:account_id]
redirect_to auth_path, notice: 'Вам необходимо войти в систему, используя один из вариантов.'
end
end
Аутентификация построена на oauth 2.0 — о принципах работы данного протокола можно узнать в этой статье на хабре, либо наглядно здесь.
Отправная точка — адрес client-site.com/auth/accounts. Его подхватывает omniauth и, используя стратегию omniauth-accounts, отправляет запрос на сервер сайта-провайдера.
При этом omniauth генерирует параметр state, который помогает провайдеру не перепутать запрос от одного сайта-клиента и пользователя с другими запросами.
Сайт-провайдер принимает запрос (по стандарту — по адресу provider-site.com/authorize), и выполняет определенные действия. Цель провайдера на данном этапе — авторизовать пользователя и выдать ему грант на аутентификацию на сайте клиенте.
Если цель достигается, с сайта-провайдера идет редирект в callback-метод сайта-клиента, в котором через request.env['omniauth.auth'] мы получаем хеш с токенами и данными от сайта-провайдера.
Метод authorize — самое темное место в схеме процесса — уж очень много нюансов нужно учесть, прежде чем выдать грант пользователю.
В идеале (при повторной авторизации) — соблюдаются следующие условия:
В этом случае пользователь авторизуется сразу же, и выполняется редирект в callback-метод сайта-клиента. Параметрами отсылаются код гранта и состояние state.
Если хотя бы одно из этих условий не выполнено — необходимо сперва уладить проблемы:
Эти действия подразумевают навигацию по сайту-провайдеру и даже по сайтам соц. сервисов (если пользователю нужно войти в систему). Последнее неспроста оказалось выделено — именно в этом месте omniauth показывает свои неприятные стороны.
Дело в том, что omniauth при переходе в authorize передает несколько параметров в url, а также прописывает несколько параметров в сессии сайта-провайдера. Это необходимо ему для корректного редиректа в callback-метод. Но если мы вдруг захотим воспользоваться omniauth на сайте-провайдере (например, при попытке войти в систему через соц. сервис) — omniauth сотрет свои данные из сессии. И редирект завершится ошибкой OmniAuth::Strategies::OAuth2::CallbackError — invalid_credentials.
Поэтому, во избежание подобных ситуаций, все параметры omniauth четко фиксируются в сессии и восстанавливаются уже перед самым редиректом.
Если все параметры переданы верно (то есть запрос пришел именно от omniauth) — создаем в текущей сессии запись — «заказ на грант» и сохраняем в ней все параметры:
session[:grants_orders] = Hash.new if !session[:grants_orders]
session[:grants_orders].merge!(
params[:client_id] => {
redirect_uri: params[:redirect_uri],
state: params[:state],
response_type: params[:response_type],
'omniauth.params' => session['omniauth.params'],
'omniauth.origin' => session['omniauth.origin'],
'omniauth.state' => session['omniauth.state']
}
)
Выполняем все проверки здесь. В сети ли пользователь, зарегистрировано ли на сайте приложение, с которого пришел запрос, есть ли старый грант, просрочен ли он.
Выполняется, если грант сразу был и подходил по требованиям, либо при нажатии на кнопку «Разрешить» на странице заказа гранта.
Отменяем заявку, просто удаляем ее из сессии.
По переданным параметрам находим приложение и грант. Если все в порядке — выдаем токены гранта в формате json.
Возвращаем хеш в формате json, как и было условлено — только первые значения параметров, если те представлены массивом.
data_hash = grant.account.info
hash = Hash.new
hash['id'] = grant.account.id
data_hash.each do |key, value|
if value.kind_of?(Array)
hash[key] = value[0]
else
hash[key] = value
end
end
render :json => hash.to_json
Решение получилось бесхитростным — и в нем, наверняка, многое можно улучшить и оптимизировать. На данный момент намечены следующие задачи:
Не каждый день приходится писать подобную систему — ведь, по сути, экосистем и в интернете то не так много. Google, Envato, Yandex, Yahoo — а кто еще? Быть может — ваш проект? Да и не единственный это способ внедрить аутентификацию в связанные проекты — есть технология CAS (пара полезных ссылок), есть OpenID (и как вариант — та же Логинза). На нашем же родном хабре и других проектах ТМ — вообще стоит отдельная система аутентификации на каждом сайте, плюс фирменная «Ключница».
Почему мой выбор пал именно на SSO? Пожалуй, ключевое «за» — это атмосфера. Это те ощущения, которые испытывает пользователь, когда совершает вход не на сайт, а в Систему — с большой буквы «С». В мощную, продвинутую, развитую Систему — это поистине потрясающее чувство, коллеги.
Автор: Gambala
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/tutorial/40528
Ссылки в тексте:
[1] accounts.google.com: http://accounts.google.com/
[2] account.envato.com: http://account.envato.com/
[3] single sign-on: https://en.wikipedia.org/wiki/Single_sign-on
[4] технология единого входа: http://ru.wikipedia.org/wiki/Технология_единого_входа
[5] сайт-клиент: https://github.com/gambala/demo-sso-client
[6] сайт-провайдер: https://github.com/gambala/demo-sso-provider
[7] кастомная стратегия omniauth: https://github.com/gambala/omniauth-accounts
[8] Источник: http://habrahabr.ru/post/189410/
Нажмите здесь для печати.