Pull to refresh

Single sign-on на omniauth и rails

Reading time 10 min
Views 19K

Аутентификация пользователей в экосистемах наподобие Google или Envato реализована в виде отдельных сервисов (accounts.google.com, account.envato.com), предоставляющих необходимые данные и токены сайтам-клиентам. В ходе разработки некоторых проектов на Ruby on Rails мне и пришлось столкнуться с подобной задачей. По-научному — single sign-on или технология единого входа.

Нужен был (1) общий сервис для всех сайтов экосистемы, с (2) преимущественно социальной авторизацией, в угоду входу по связке «логин+пароль».
Сервис, (3) аккумулирующий в себе данные из тех социальных сервисов, с помощью которых пользователь входит в систему, и (4) предоставляющий эти данные сайтам-клиентам.

Задача оказалась настолько же интересной, насколько и нестандартной. Началось все с полезной, но уже немного устаревшей статьи — автор предлагал использовать гем omniauth и кастомную стратегию на сайтах клиентах, а на сайте-провайдере — использовать тот же omniauth в связке с devise для аутентификации через соц. сервисы.

Devise в моем случае подходил мало (завязка на логине+пароле), поэтому предпочтение было полностью отдано omniauth. С этого и началось мое маленькое приключение, о ходе которого предлагаю вам ознакомиться в данной статье.

Общая схема


Рассмотрены будут три проекта: сайт-клиент, сайт-провайдер и кастомная стратегия omniauth. По ссылкам все они доступны на github и готовы к использованию. В статье будут подняты лишь ключевые моменты.

Сайт-клиент

Запускать будем на localhost:4000.
Структура стандартна для любых сайтов, использующих omniauth:
  • В Gemfile подключаем omniauth и нашу стратегию omniauth-accounts:

gem 'omniauth'
gem 'omniauth-accounts'

  • bundle install
  • В config/initializers/omniauth.rb вставляем код инициализации:

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

  • В router.rb добавляем маршрут для callback-метода:

match '/auth/:provider/callback', :to => 'auth#callback'

  • Создаем контроллер и callback-метод в нем, в котором получаем через request.env['omniauth.auth'] конечный хеш с данными и токенами

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. Кода совсем немного, к тому же — подстраивать его под себя не имеет смысла, все необходимые стратегии данные передаются в параметрах инициализации (в примере — в виде переменных окружения). Это:
  • Ключи credentials (ACCOUNTS_API_ID и ACCOUNTS_API_SECRET) для подключения к сайту-провайдеру сайта-клиента
  • Адрес сайта-провайдера ACCOUNTS_API_SITE
  • Адрес для аутентификации на сайте-провайдере (по-умолчанию: /authorize)..
  • … и для получения токена (/token)

Получив эти данные, стратегия всю дальнейшую работу берет на себя. Из-за этого, правда, в ходе разработки могут происходить неприятные случаи, когда в определенной ситуации стратегия «теряется» — не может продолжить свое выполнение дальше как планировалось. С такими проблемами пришлось столкнуться и мне, и решение каждой было найдено — будет рассмотрено далее в статье.

Сайт-провайдер

Запускать будем на 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 в твиттере. В любом случае, возникает желание одним нажатием обновить их и в аккаунте… или оставить прежние — ситуации ведь бывают разные. Наш алгоритм для этого отлично подходит — записывает новые значения в конец массивов, не сохраняя дубликаты.

Привязка разных сервисов к одному аккаунту

Аналог ключницы на хабре — в системе создается запись в таблице аутентификаций и привязывается к текущему аккаунту. Используется в дальнейшем как ключ и как источник данных.

Ручное редактирование полей аккаунта

Не все поля заполняются из соц. сервисов. Пользователь должен иметь возможность заполнить недостающие данные самостоятельно, на странице сайта-провайдера. А также — менять местами значения в массивах, что было упомянуто парой абзацев выше.

Реализация

Модели

  • Account — хранит в себе хеш данных (info)
  • Authentication — совершенная через omniauth аутентификация на фейсбуке, твиттере, либо другом сервисе; хранит в себе имя провайдера и uid пользователя;

Чтобы Rails понимал info как хеш: в миграции указывается тип поля text, а в модель добавляется код:
serialize :info, Hash

Между моделями — связь один-ко-многим:
# /app/models/account.rb
has_many :authentications

# /app/models/authentication.rb
belongs_to :account


Контроллеры

AuthenticationsController покрывает все нужды по аутентификации, включает следующие действия:
  • auth (/auth) — страница выбора сервиса для входа, либо обновления данных анкеты
  • logout (/logout) — выход из системы
  • callback (/auth/:provider/callback) — в этом методе выполняется основная работа по входу, обновлению данных, привязке аутентификаций и прочее
  • failure (/auth/failure) — выполняется если при входе произошла ошибка на «том конце»
  • detach (/auth/detach) — отсоединяет аутентификацию от текущего аккаунта

При выборе одного из сервисов аутентификации, выполняются стандартные для 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 на данном этапе содержит такие действия:
  • index — вывод анкеты пользователя в виде формы
  • edit — редактирование данных анкеты аккаунта
  • update — обновление анкеты (POST-запрос из edit)

А также фильтр — обязательная проверка на наличие пользователя в сети (с редиректом на страницу входа login).

Очень хотелось добиться возможности действительно удобного и гибкого изменения данных. И такая задача до сих пор стоит и будет прорабатываться в будущем. Пока что — редактирование происходит двумя способами:
  • Если js отключен — есть текстовая зона с YAML-форматированным хешем
  • Если включен — подгружается визуальный редактор json-структур jsoneditor



Создание связи между сайтом-клиентом и сайтом-провайдером


Стандартной практикой в этом случае является создание «приложения» на сайте-провайдере. Указываем имя и адрес сайта-клиента (вернее — адрес для 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 четко фиксируются в сессии и восстанавливаются уже перед самым редиректом.



orders#register

Если все параметры переданы верно (то есть запрос пришел именно от 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']
	}
)


orders#show

Выполняем все проверки здесь. В сети ли пользователь, зарегистрировано ли на сайте приложение, с которого пришел запрос, есть ли старый грант, просрочен ли он.
  • Если все в порядке — сразу вызываем метод выдачи гранта
  • Если что-то не так — показываем страницу, типичную для таких аутентификаций («Приложение запрашивает доступ к аккаунту», «Разрешить», «Запретить»)



orders#accept

Выполняется, если грант сразу был и подходил по требованиям, либо при нажатии на кнопку «Разрешить» на странице заказа гранта.
  • Все сохраненные в сессии параметры omniauth восстанавливаем, чтобы они адекватно обработались omniauth'ом при редиректе в callback-метод
  • Создаем грант и выполняем редирект


orders#deny

Отменяем заявку, просто удаляем ее из сессии.

grants#token

По переданным параметрам находим приложение и грант. Если все в порядке — выдаем токены гранта в формате json.

accounts#get_info

Возвращаем хеш в формате 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


Заключение


Решение получилось бесхитростным — и в нем, наверняка, многое можно улучшить и оптимизировать. На данный момент намечены следующие задачи:
  • Дать возможность при создании приложения указывать, какие именно параметры оно будет требовать — обязательно и необязательно. И, если необходимых параметров нет в анкете пользователя — давать ему возможность ввести их прямо на странице получения гранта
  • Обеспечить вход по связке логин+пароль — с помощью стратегии omniauth-identity
  • Добавить в сайт-клиент действие logout, выходящее из системы не только на сайте-клиенте, но и на сайте-провайдере
  • Решить проблему с пропадающей сессией при json-запросах token и get_info (это, судя по всему, как-то связано с системой безопасности Rails, protect_from_forgery и verify_authenticity_token)

Не каждый день приходится писать подобную систему — ведь, по сути, экосистем и в интернете то не так много. Google, Envato, Yandex, Yahoo — а кто еще? Быть может — ваш проект? Да и не единственный это способ внедрить аутентификацию в связанные проекты — есть технология CAS (пара полезных ссылок), есть OpenID (и как вариант — та же Логинза). На нашем же родном хабре и других проектах ТМ — вообще стоит отдельная система аутентификации на каждом сайте, плюс фирменная «Ключница».

Почему мой выбор пал именно на SSO? Пожалуй, ключевое «за» — это атмосфера. Это те ощущения, которые испытывает пользователь, когда совершает вход не на сайт, а в Систему — с большой буквы «С». В мощную, продвинутую, развитую Систему — это поистине потрясающее чувство, коллеги.
Tags:
Hubs:
+20
Comments 2
Comments Comments 2

Articles