Контент


Вход через соцсети: модуль SSO

Еще со времен работы над kohana-world.com оставались наработки в области аутентификации с помощью сторонних сервисов (соцсети и тд). Не так давно решил причесать их, довести до ума (читай до версии 3.3) и оформить в виде отдельного модуля.

Задача

Предлагаем пользователю входить на сайт без регистрационных форм, что стало чем-то вроде правила хорошего тона. 99% пользователей имеют учетные записи в каком-либо из популярных ресурсов: Twitter, Google, Facebook, ВКонтакте и тд. Клиент выбирает сервис для аутентификации и спустя пару мгновений (после редиректа на сайт сервиса, подтверждения входа и возврата обратно) превращается в зарегистрированного пользователя.

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

Инструментарий

Тут нам на помощь придут два давно известных стандарта — OAuth и OpenID. Они совсем-совсем разные, но оба способны решить поставленную задачу. Используем оба, чтобы разнообразить список доступных сервисов (например, LiveJournal предлагает только OpenID). Имя поддержку обоих стандартов (точнее, трех стандартов, так как OAuth v1 и OAuth v2 — две большие разницы), нам в дальнейшем не составит большого труда увеличивать количество провайдеров, то есть тех самых сервисов.

OAuth

Как известно, в Kohana существует модуль OAuth. Точнее говоря, существовал вплоть до версии 3.1. Далее почему-то разработчики решили его не сопровождать. Ну, дело-то хозяйское, никто нам не мешает форкнуть модуль в свой репозиторий и дальше его допиливать. Особых революций в нем я делать не собираюсь, просто привел к требованиям v3.3 и добавил пару новых провайдеров. Сейчас мой OAuth поддерживает Google, Twitter, Facebook, ВКонтакте, Github и другие крупные сервисы.

OpenID

Стандартного модуля нет, но совсем несложно было создать свой модуль, а по сути — обертку вокруг LightOpenID. Модуль содержит несколько драйверов — это заготовки для известных OpenID-провайдеров, таких как LiveJournal, Google и WordPress. Данные драйверы содержат информацию, необходимую для соединения с сервисом, запроса аутентификации и получения профиля.

Общая схема

1. Пользователь выбирает провайдера (щелкает по ссылке).
2. Драйвер провайдера содержит информацию, куда перенаправить пользователя, и какие данные отдать провайдеру.
3. Провайдер уточняет у пользователя, стоит ли доверять сайту, с которого он перешел.
4. В случае положительного ответа провайдер возвращает пользователя обратно, добавляя к адресу страницы коды доступа.
5. Используя полученный код доступа, извлекаем профиль с личными данными.
6. На основании этих данных создаем учетную запись и используем ее для аутентификации на сайте.

Собственно говоря, пункты 1-4 доступны нам безо всяких там модулей, для этого достаточно модуля OpenID или OAuth. А вот дальше возникает вопрос — как унифицировать работу со множеством сервисов, предоставляющих нам доступ к своим данным. Решать данную проблему будет модуль SSO, ради которого я и пишу этот пост.

Подготовка к работе

1. Модуль использует базу данных для хранения полученных профилей. Основная таблица auth_data, в нее все и складывается. Таблица user_tokens предназначена для хранения токенов автологина (такие же используются в модуле Auth для «запоминания» пользователей).
2. Настройте OAuth, если планируете использовать его драйверы для аутентификации. OAuth отправляет провайдеру информацию о сайте, используя заранее полученные идентификатор приложения и секретный ключ (информации об этом полным-полно в интернете, поэтому я не буду рассказывать об этом нудном процессе). Их необходимо прописать в конфигурационном файле oauth.php согласно шаблона. Обратите внимание, что имена идентификатора преложения для OAuth и OAuth2 различаются, в первом случае используйте key, во втором — id.

Полезные методы

В демо-проекте я использовал несколько методов, которые я считаю удобными. Они располагаются в одном из базовых контроллеров, и облегчают некоторые рутинные операции. Вместо них всегда можно прикрутить специальные модули, либо собственные наработки.

  • Работа с адресами.

    protected function _changed_uri($params)
    {
    	if (is_string($params))
    	{
    		// assume its an action name
    		$params = array('action' => $params);
    	}
     
    	$current_params = $this->request->param();
    	$current_params['controller'] = strtolower($this->request->controller());
    	$current_params['directory'] = strtolower($this->request->directory());
    	$current_params['action'] = strtolower($this->request->action());
    	$params = $params + $current_params;
    	return Route::url(Route::name(Request::current()->route()), $params, TRUE);
    }

    Этот метод используется для быстрого создания «похожего» URI. Самый частый случай — нам необходимо получить адрес для другого экшена текущего контроллера. Например $this->_changed_uri('complete') передаст в текущий роут текущие параметры, заменив имя экшена на ‘complete’. Можно передать массив параметров, если надо заменить что-то другое.

  • Запоминаем, куда направляться.

    protected function _go_back($event, $default_uri = '')
    {
    	$uri = $this->_session->get_once($event, $default_uri);
    	HTTP::redirect($uri);
    }
     
    protected function _save_referer($event, $referer = FALSE)
    {
    	if ($referer === TRUE)
    	{
    		$referer = $this->request->uri();
    	}
    	elseif ($referer === FALSE)
    	{
    		$referer = Request::initial()->referrer();
    	}
    	else
    	{
    		$referer = (string) $referer;
    	}
     
    	$hostname = parse_url($referer, PHP_URL_HOST);
    	$current_hostname = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : $_SERVER['SERVER_NAME'];
    	if ($hostname == $current_hostname)
    	{
    		$this->_session->set($event, $referer);
    		return TRUE;
    	}
     
    	return FALSE;
    }

    Это методы используются в паре. Сперва с помощью _save_referer() мы можем сохранить в сессии определенный URL, связав его с текстовым идентификатором (параметр $event). В дальнейшем вызываем _go_back($event) и отправляемся обратно по сохраненному адресу. Удобно при необходимости запоминать целые цепочки адресов (что и происходит в демо-проекте). Метод _save_referer() умеет запоминать не только переданный $referer, но и текущий адрес или его предшественника (если $referer является булевой переменной).

  • Методы _error() и _success() я полностью приводить не буду, они просто сохраняют в сессии текстовые сообщения для дальнейшего отображения пользователю.

Процесс аутентификации

По пути от ссылки «войти» до сообщения «Добро пожаловать, :username!» нам предстоит посетить несколько контроллеров, каждый из которых отвечает за свой участок. Конечно, можно было бы собрать весь код в одном большом раздутом классе, но я предпочитаю разделение по сферам деятельности.

Выбор провайдера

Итак, за собственно страницу аутентификации (то есть за отображение ссылок «войти») отвечает контроллер Auth, точнее метод action_login(). Шаблон accounts/login содержит список доступных ссылок для аутентификации через сервисы.

По уму, конечно, этот список должен храниться в БД или конфигурационном файле, но это ведь просто демка ;)

Подготовка к запросу

Controller_Account — самый-самый базовый контроллер, который объединяет нижеперечисленные. В нем приведены те немногие действия, которые являются общими для трех используемых протоколов (сохранить страницу для возврата, отредиректить и тд). Экшены action_login() и action_complete() расположены в нем, но вся важная начинка реализована в абстрактном методе _do_login(). Тем не менее, для ясности схемы имеет смысл рассмотреть метод action_login():

public function action_login()
{
	// where to go for checking account info
	$this->_save_referer('account/identify', $this->_changed_uri('complete'));
 
	$this->_do_login();
}

На метку «accont/identify» мы вешаем УРЛ, ведущий на метод action_complete() этого же контроллера. Таким образом, после получения токена доступа контроллер будет знать, в каком месте проекта этот токен ждут. Ну а дальше идет вызов метода _do_login(), для анализа работы которого мы перейдем к контроллерам различных протоколов аутентификации.

Вход через OpenID

Адреса для входа через OpenID имеют вид /openid/<provider>/login, например:

/openid/wordpress/login
/openid/google/login
/openid/openid/login — это вход без указания конкретного OpenID-провайдера

Можно догадаться, что эти адреса будут расшифрованы как метод action_login() контроллера Controller_Openid_Wordpress и тд. Впрочем, все эти контроллеры похожи друг на друга, вся суть содержится в базовом для них контроллере Controller_Openid.

$id = $this->_id_required ? Arr::get($_POST, 'openid_identifier') : NULL;
 
$this->_session->set($this->_id_key, $id);
$url = $this->_changed_uri('identify');
try {
	$this->_openid->returnUrl($url)->login($id);
}
catch (HTTP_Exception_302 $e) {
	// its normal behavior for OpenID authentication
	throw $e;
}
catch (Exception $e) {
	// @TODO log error info
	$this->_error('Login with ":identity" identity failed', array(':identity' => $id));
	$this->_go_back('account/login');
}

Это главная часть метода _do_login(). Ожидается, что пользователь ввел свой OpenID-идентификатор, его мы сохраняем в сессию. В дальнейшем он может понадобиться. Вызов $this->_openid->returnUrl($url) сохраняет в объекте OpenID адрес для возврата (он передается провайдеру), а метод login стартует процесс поиска OpenID-провайдера и передачи ему управления.

В модуле OpenID реализованы драйвера для некоторых провайдеров: Google, Livejournal, WordPress. Как видите, они содержат только необходимый минимум — формат идентификатора (чтобы не заставлять пользователя вводить его целиком, а только логин), адрес сервера, список дополнительных полей. Так что написать свой драйвер OpenID совсем несложно.

Что важно — редиректы в Kohana v3.3 происходят путем выбрасывания исключения HTTP_Exception_302, поэтому его мы в своем обработчике пробрасываем дальше. В случае остальных ошибок возвращаемся на исходную позицию.

Если же пользователь успешно представился провайдеру и разрешил нам использовать его данные, то мы возвращаемся на адрес вида /openid/<provider>/identify. В данном экшене происходит проверка успешности аутентификации, сохранение объекта OpenID в сессию и редирект на сохраненный ранее УРЛ (в нашем случае мы вернемся в адрес /openid/<driver>/complete/).

Вход через OAuth

Я не буду по отдельности рассматривать контроллеры Controller_Oauth и Controller_Oauth2, потому что общий механизм в принципе одинаков. Примеры буду брать из второй версии протокола, они слегка попроще. Метод _do_login() выглядит следующим образом:

	// clear old access tokens
	$this->_session->delete($this->_access_token_key);
 
	$callback = $this->_changed_uri('identify');
	$this->_consumer->callback($callback);
 
	HTTP::redirect($this->_provider->authorize_url($this->_consumer, $this->_request_params));

Сперва мы очищаем сессию, там могли остаться старые токены. Далее сохраняем адрес для обратного редиректа (он, как и в случае с OpenID, передается провайдеру). Ну и далее отправляемся на сгенерированный драйвером провайдера адрес авторизации (метод authorize_url объекта OAuth2_Provider). Подтверждаем, что мы — это мы, и что мы действительно хотим предоставить доступ к своим данным. После этого возвращаемся на адрес вида /auth/<provider>/identify. Метод action_identify() в OAuth несколько побогаче на события, чем в OpenID, поэтому его мы разберем:

public function action_identify()
{
	$code = $this->request->query('code');
	if ( ! $code)
	{
		$this->_error(__('Ooops, something was wrong. Cant complete authentication'));
		$this->_go_back('account/identify');
	}
 
	$this->_consumer->callback($this->_changed_uri(array()));
 
	$this->_token = $this->_provider->access_token($this->_consumer, $code);
 
	$this->_session->set($this->_access_token_key, $this->_token);
 
	$this->_go_back('account/identify');
}

Провайдер отправляет нас на этот адрес, добавляя в Query-строку параметр code (код доступа). На его основе мы имеем возможность запросить токен доступа (Access_Token) — нашу конечную цель в рамках аутентификации. За это отвечает метод access_token объекта OAuth2_Provider. В данном случае мы не направляемся обратно к провайдеру, а используем Curl-запрос через OAuth::remote(). Ответ оборачиваем в OAuth2_Access_Token — и все, все необходимое для дальнейшей работы у нас есть. Возвращаемся для обработки запрошенного токена.

Завершение аутентификации

Попав на адрес /<protocol>/<provider>/complete, происходит передача полученного идентифицирующего объекта (токен OAuth или объект OpenID) в модуль SSO для превращения его в учетную запись пользователя.

// это важно - для разных провайдеров разные наборы параметров для аутентификации
$params = $this->_login_params();
 
// вызываем метод login() объекта SSO
if (empty($params) OR call_user_func_array(array($this->_sso, 'login'), $params) === FALSE)
{
	// тут можно сообщить пользователю, что аутентификация по какой-то причине не удалась 
}
else
{
	// все ок, но лучше все равно отредиректиться в более спокойное место
}
 
// возвращаемся обратно на страницу, с которой пользователь инициировал аутентификацию
$this->_go_back('account/login');

Обратите внимание, что в контроллерах OpenID, OAuth v1 и OAuth v2 есть метод _login_params(), который должен возвращать список аргументов в функцию SSO::login(). Первый аргумент всегда название драйвера, например ‘OAuth2.Google’, ‘OAuth.Twitter’ или ‘OpenID.Wordpress’. OAuth дополнительно передает токен доступа, а OpenID — объект OpenID (он содержит в себе ответ OpenID-сервера) и введенный пользователем OpenID-идентификатор. Дело в том, что некоторые провайдеры не отдают нам какого-либо «красивого» идентификатора пользователя (яркий пример тому — Google, который вместо [email protected] отдает совершенно дикое имя), вот и приходится использовать все, что есть.

Что потом?

SSO::login() в случае успеха работает аналогично коробочному Auth: в сессию добавляется модель Auth_Data с профилем пользователя, которую можно получить через SSO::instance()->get_user(), выход через SSO::instance()->logout() и тд.

На что следует обратить внимание — в рамках модуля не производится проверка на уникальность пользователя. То есть вы можете зайти сперва через Google, а затем через Twitter, для системы это будет два разных пользователя. Дело в том, что стоящая перед SSO задача — забрать максимум данных из профиля и обернуть это все в модель, предоставив универсальный API для разных сервисов. Что вы будет делать с учетными данными дальше — дело ваше. Позже я дополню демо-проект примером проверки учетных записей на уникальность, генерацией юзернеймов (ведь в соцсетях может быть сто Васей Пупкиных и триста Вов Пукиных.

Памятка

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

Как видно, имеется некоторая модульность — получение токена в принципе не завязано на остальные действия по аутентификации. К такой схеме я пришел после того, как обнаружилось, что в проекте токен может быть запрошен не только для аутентификации, а к примеру для использования API сервиса. А поскольку при запросе токена мы явно указываем свои намерения (это называется scopes, то есть описание частей сервиса, которые нам будут нужны), то токен аутентификации ничем не поможет при попытке забрать всех друзей пользователя.

Вот как бы и все. Статья получилась большая и наверняка сложная для восприятия. К сожалению, реализация подобных задач достаточно трудоемка и в любом случае потребует немалых усилий на практике. Надеюсь, что я хотя бы немного помогу в этом нелегком деле.

Google Bookmarks Digg Reddit del.icio.us Ma.gnolia Technorati Slashdot Yahoo My Web News2.ru БобрДобр.ru RUmarkz Ваау! Memori.ru rucity.com МоёМесто.ru Mister Wong

Опубликовано в Kohana3.

Теги: , , , , , .


Комментарии (18)

Будьте в курсе обсуждения, подпишитесь на RSS ленту комментариев к этой записи.

  1. slider23 пишет:

    Большое спасибо за модуль и за статью, очень полезно !

  2. Антон пишет:

    Как всегда — на высоте.
    Спасибо за модуль.

  3. Владимир пишет:

    Пригодится, у меня до такого руки не дошли :)

  4. yan_kos пишет:

    А почему б не соединить Auth и SSO?

  5. biakaveron пишет:

    Есть такие мысли. Времени пока нет

  6. Ivan пишет:

    Хочу разобраться с демкой, посмотреть что и как.
    Не могли бы вы выложить все файлы демо-проекта целиком? Я так и не смог его собрать, скачивал по кусочкам с гитхаба.

    В итоге вроде все собрал воедино, вылетает ошибка «ErrorException [ Warning ]: Missing argument 1 for LightOpenID::__construct(), called in W:\home\kohana.sso\www\modules\openid\classes\kohana\openid.php on line 39″

    это когда пытаюсь зайти например через /openid/livejournal/login

    P.S. С этим разобрался, обновилась версия lightopenid, теперь в конструкторе обязательный параметр $host.

    Появилась еще одна ошибка: View variable is not set: container

    В демке такой переменной не нашел, видимо используются модифицированные файлы Коханы?

  7. biakaveron пишет:

    На гитхабе были все необходимые файлы, кроме конфигов database и oauth (по понятным причинам). В случае с LightOpenID очевидно, что у Вас скачана более новая версия, так как в моей вендорской библиотеке конструктор без параметров. Предлагаю лучше разобраться, почему не собирается проект, чего не хватает?

  8. Ivan пишет:

    Я, видимо, совсем не умею пользоваться гитхабом — просто зашел в проект sso-demo и скачал zip-архив. Потом у вас же скачивал недостающие модули, точно так же, архивами.

    Видимо чего-то недокачал, подозреваю что собирать проект надо как-то по-другому )

  9. biakaveron пишет:

    Нужны только знания гита. git clone и git submodule update —init —recursive. Гитхаб не отдает в архиве подмодули, в этом обычно проблема

  10. Дмитрий пишет:

    Блин, что-то у меня не получается посмотреть sso-demo. Требует передачу обязательных параметров в конструктор (OAuth::__construct() expects at least 2 parameters, 0 given) при создании объекта ($this->_oauth = new OAuth2;)

    По ссылке http://examples.brotkin.ru/login проект тоже лежит. Забирал все через: git clone и git submodule update —init —recursive.

  11. biakaveron пишет:

    Да, хостер слегка поломал мой examples, починил. Вроде все работает. Есть подозрение, что Вы не создали конфиг application/oauth.php. Он должен содержать id+secret для провайдера.

  12. Дмитрий пишет:

    Да, спасибо, со скрипом, но авторизовался через Vk раза с 3-го. Выдало:
    Kohana_OAuth_Exception [ 0 ]: Error fetching remote https://oauth.vk.com/access_token [ status 0 ] Failed to connect to 2a00:bdc0:3:103:1:0:403:904: Network is unreachable
    Потом авторизация уже проходит нормально.
    Конфиг я создал, просто до него дело не доходит почему-то:
    ErrorException [ Warning ]: OAuth::__construct() expects at least 2 parameters, 0 given
    APPPATH/classes/Controller/Oauth2.php [ 63 ]

    58 public $type = 'OAuth2';
    59
    60 public function before()
    61 {
    62 parent::before();
    63 $this->_oauth = new OAuth2; // конструктор предка требует параметры
    64 $this->_consumer = OAuth2_Client::factory(Kohana::$config->load('oauth.' . $this->name));
    65 $this->_provider = $this->_oauth->provider($this->name);
    66 }
    67
    68 public function action_token()

  13. biakaveron пишет:

    Судя по всему, это PECL’овский модуль OAuth мешает. Попробуйте его отключить. В идеале, конечно, надо переименовывать библиотеку во что-то более конкретное…

  14. Дмитрий пишет:

    Судя по всему, это PECL’овский модуль OAuth мешает.

    А что за модуль такой? Не совсем мне понятно.

  15. biakaveron пишет:

    http://php.net/manual/ru/book.oauth.php

    Проверить можно через `phpinfo()` или `php -i` (в консоли)

  16. Дмитрий пишет:

    Да, вы правы на моем хостинге есть такой модуль.

    надо переименовывать библиотеку во что-то более конкретное…

    Есть ли мысли по этому поводу?

  17. biakaveron пишет:

    Конфликт в имени класса OAuth. Можно просто взять и переименовать файл и класс в что-то вроде KOAuth и использовать в свое удовольствие. Естественно, все упоминания класса в коде тоже придется поправить

  18. Дмитрий пишет:

    Я решил все переименовать KOAuth и все заработало как надо. Уже добавил авторизацию на свой сайт.

    Позже я дополню демо-проект примером проверки учетных записей на уникальность, генерацией юзернеймов

    А подскажите, пожалуйста, было ли это реализовано в демо проекте?

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

    Хотел, конечно, сначала воспользоваться уже готовыми решениями типа Logina или uLogin, но так как uLogin лагал на то время и на Хабре в комментариях упоминалось — эта проблема, решил идти другим путем и не от кого не зависеть, с Loginza уже и не стал смотреть и разбираться .

    Дополнительная возможность получения адреса электронной почты, номера телефона и другой информации о пользователе. - с сайта uLogin

    В частности email раньше не отдавал ВКонтакте. Сейчас если пользователь разрешил, его можно свободно брать. Одноклассники тоже отдают email, но нужно писать [email protected], чтобы вашему приложению дали право на его получение. При этом в scope нужно передать параметр GET_EMAIL, иначе при получении ответа от одноклассников вы его там не увидите. Кроме того, при регистрации на одноклассниках поле email не является обязательным, поэтому у пользователя его может и не быть. В общем в одноклассниках как-то все туманно там пока, хотя техподдержка в течении суток — двое отвечает на обращения.

    К сожалению, реализация подобных задач достаточно трудоемка и в любом случае потребует немалых усилий на практике.

    В общем задача оказалась интересной :) Спасибо за модуль и статью о нем!



Можно включить подсветку кода: <code><pre lang="">...</pre></code>
Разрешены некоторые HTML теги

или используйте trackback.