Привет Хабр! В данной статье я хочу рассказать о том, как реализовать авторизацию с помощью социальных сетей в одностраничном приложении на примере Backbonejs + Express.

Если у вас не установлен Node.js, вы можете скачать его с офф.сайта. Для установки Express воспользуемся генератором приложений Express.
Мы создали новое приложение Express с именем habr. Удалим каталог views, так как он нам не понадобится, переименуем images в img, javascripts в js, stylesheets в style и добавим папку public/tpl в которой будут лежать шаблоны. Теперь структура нашего проекта выглядит так:
Для загрузки компонентов будем использовать RequireJS и RequireJS/textjs для загрузки шаблонов. Инициализация приложения будет выполняться в файле init.js.
Добавим конфигурацию RequireJs.
public/js/init.js:
Я сразу добавил библиотеки для работы с Vk и Facebook API.
Backbonejs не имеет функционала для вызова Middleware перед роутом, поэтому, воспользовавшись примером, я добавил 2 метода: before и after, которые будут вызываться перед и после каждого роута. Это нужно нам для проверки авторизации перед вызовом роутов к которым неавторизированый пользователь не должен получить доступ.
public/js/baseRouter.js:
Теперь определим наши маршруты:
public/js/router.js:
Создадим файл public/tpl/index.html, подключим bootstrap.css что бы он имел приемлемый вид:
Исправим файл app.js. Я удалил не нужный для моего примера код что бы не нагромождать файл лишним функционалом. Теперь app.js выглядит так:
И добавим загрузку приложения в init.js:
Запускаем наше приложение, и смотрим что получилось. Создадим view для нашей страницы логина.
public/js/login_view.js
Добавим шаблон для страницы логина:

Для авторизации через Facebook api нам нужно создать приложение. Я его уже создал, а вы можете сделать это по ссылке следуя не сложной инструкции.
Инициализируем подключение к API.
public/js/login_view.js:
Обновляем страницу в браузере и видим в консоли ошибку:
Это происходит потому что мы не добавили наш домен в настройки приложения. Давайте добавим localhost:3000/ в список действительных URL адресов. Для этого переходим в настройки нашего приложения, далее «Вход через фейсбук», и добавляем localhost:3000/ в поле «Действительные URL-адреса для перенаправления OAuth» и нажимаем сохранить.
Теперь нужно авторизироваться на стороне Facebook API. Для этого вызовем метод login, который принимает calback функцию первым аргументов и объект прав. Запроси�� основную информацию + email пользователя.
public/js/login_view.js:
Теперь обновив страницу и нажав «Войти с помощью Facebook» у нас появится окно в котором Facebook попросит подтвердить вход в наше приложение. После подтверждения можно увидеть в консоли браузера ответ от API. Нас интересует параметр status и authResponse.accessToken.
Status — статус текущего пользователя. Возможные значения:
accessToken — токен доступа, который мы будем в дальнейшем использовать.
Давайте добавим обработчик статусов и получим нужную нам информацию о текущем пользователе:
Теперь авторизировавшись в консоли мы увидим объект данных которые мы запросили. Подробнее о информацие которую можно получить читайте тут.
Отлично. Мы получили информацию о пользователе от facebook, но на клиентской стороне она не особо полезна. Хотелось бы авторизировать пользователя на стороне сервера и записать данные о нем в БД.
Для отправки запроса с сервера нам понадобится access_token, который мы получили немного раньше. Давайте отправим его на сервер:
А на сервере запросим информацию у Facebook:
app.js:
Я сохранил в куках логин и хеш для дальнейшей демонстрации авторизации. При отправке запроса обязательно нужно указать json:true, для того что бы получить javascript-объект, а не json-строку. Перезапустим приложение, логинимся, и видим ответ в консоли браузера. Отлично. Все работает как надо.

Авторизация через Вконтакте не сильно отличается от Facebook, поэтому я буду описывать менее подробно. Создаем приложение для авторизации тут. Инициируем подключение к VK API:
Логинимся. (Вторым параметром в метод login, передаем число, обозначающее права, которые мы хотим получить).
Смотрим в консоль и видим ответ. У нас тут так же содержится параметр status и sig(access_token) + объект user, содержащий некоторую информацию о пользователе.
Далее все идет не так гладко как с Facebook.

Полученный токен(sig) привязывается к ip-адресу, и при попытке использовать его на сервере вам выдаст ошибку: «User authorization failed: access_token was given to another ip addres». и при получении токена на клиентской стороне мы не сможем его использовать на сервере.
Самое интересное в сложившейся ситуации то, что это не так просто обнаружить, если разрабатывать и тестировать на одном ip. Проблема может всплыть только на боевом сервере.
В интернете существует миф о том что в scope нужно указать разрешение «offline», тогда токен будет «вечным» и не привязывается к IP. Но данный метод не убирает привязку к ip-адресу.
При таком способе авторизации нет возможности получить email пользователя, даже если вы запросите нужные права и пользователь даст согласие — вы не получите email в ответе.
При серверной авторизации, описанной в документации vk.com/dev/authcode_flow_user, если в scope указать email то он будет возвращен вместе с токеном. При использовании open api, email-адрес не приходит с токеном. Обратившись в техническую поддержку я получил ответ:
Токен, полученный на клиентом, мы не можем использовать на сервере, и соответственно не можем запросить информацию о пользователе со стороны сервера, но мы можем проверить токен на валидность и узнать ид пользователя которому принадлежит данный токен.
С документации мы можем узнать что Параметр sig равен md5 от конкатенации следующих строк:
Давайте получим информацию о пользователе через open api, передадим её на сервер, проверим токен, и если все ок запишем в базу:
Для создания md5-хеша используем crypto:
app.js:
Теперь наше приложение проверяет токен и id пользователя который пришел и мы можем авторизировать пользователя на сервере на основании этих данных.
Давайте создадим модель, которая будет содержать информацию о пользователе:
public/js/models/user.js:
Теперь давайте загрузим модель пользователя до того как запустим наше приложение:
public/js/init.js:
И добавим проверку в router.js:
Добавим роут получения информации о пользователе на сервере:
и роут logout:
Последним штрихом добавим user_view, в который будем выводить информацию о пользователе в шапке:
public/js/views/user_view.js:
Шаблон для user_view:
public/tpl/user.html:
И изменим index.html:
Запускаем наше приложение и радуемся.
» Исходники на Github.

Если у вас не установлен Node.js, вы можете скачать его с офф.сайта. Для установки Express воспользуемся генератором приложений Express.
npm install express-generator -g express habr cd habr && npm install
Мы создали новое приложение Express с именем habr. Удалим каталог views, так как он нам не понадобится, переименуем images в img, javascripts в js, stylesheets в style и добавим папку public/tpl в которой будут лежать шаблоны. Теперь структура нашего проекта выглядит так:
. ├── app.js ├── bin │ └── www ├── package.json ├── public │ ├── img │ ├── js │ ├── tpl │ └── style │ └── style.css ├── routes │ ├── index.js │ └── users.js
Для загрузки компонентов будем использовать RequireJS и RequireJS/textjs для загрузки шаблонов. Инициализация приложения будет выполняться в файле init.js.
Добавим конфигурацию RequireJs.
public/js/init.js:
requirejs.config({ baseUrl: "js/", paths: { jquery: 'lib/jquery.min', backbone: 'lib/backbone.min', underscore: 'lib/underscore.min', fb: 'https://connect.facebook.net/ru_RU/all', //Facebook api vk: 'https://vk.com/js/api/openapi', //Vk API text: 'lib/text', tpl: '../tpl' }, shim: { 'underscore': { exports: '_' }, 'vk': { exports: 'VK' }, 'fb': { exports: 'FB' }, 'backbone': { deps: ['underscore', 'jquery'], exports: 'Backbone' } } });
Я сразу добавил библиотеки для работы с Vk и Facebook API.
Backbonejs не имеет функционала для вызова Middleware перед роутом, поэтому, воспользовавшись примером, я добавил 2 метода: before и after, которые будут вызываться перед и после каждого роута. Это нужно нам для проверки авторизации перед вызовом роутов к которым неавторизированый пользователь не должен получить доступ.
public/js/baseRouter.js:
baseRouter.js
define([ 'underscore', 'backbone' ], function(_, Backbone){ var BaseRouter = Backbone.Router.extend({ before: function(){}, after: function(){}, route : function(route, name, callback){ if (!_.isRegExp(route)) route = this._routeToRegExp(route); if (_.isFunction(name)) { callback = name; name = ''; } if (!callback) callback = this[name]; var router = this; Backbone.history.route(route, function(fragment) { var args = router._extractParameters(route, fragment); var next = function(){ callback && callback.apply(router, args); router.trigger.apply(router, ['route:' + name].concat(args)); router.trigger('route', name, args); Backbone.history.trigger('route', router, name, args); router.after.apply(router, args); } router.before.apply(router, [args, next]); }); return this; } }); return BaseRouter; });
Теперь определим наши маршруты:
public/js/router.js:
define([ 'baseRouter', ], function(BaseRouter){ return BaseRouter.extend({ routes: { "secure": "secure", "login" : "login" }, //Маршруты к которым будет запрещен доступ неавторизированым пользователям secure_pages: [ '#secure' ], before : function(params, next){ next(); }, secure: function(){ console.log('This is secure page'); }, login: function(){ console.log('This is login page'); } }); });
Создадим файл public/tpl/index.html, подключим bootstrap.css что бы он имел приемлемый вид:
<!DOCTYPE html> <html> <head> <title></title> <script data-main="/js/init" src="js/lib/require.js"></script> <link rel="stylesheet" href="/style/bootstrap.min.css"/> </head> <body> <div class="container"> <nav class="navbar navbar-default"> <div class="container-fluid"> <ul class="nav navbar-nav"> <li><a href="#">Home</a></li> <li><a href="#secure">Secure</a></li> </ul> <ul class="nav navbar-nav navbar-right"> <li><p class="navbar-text">Вы вошли как Гость</p></li> <li><a href="#login">Login</a></li> </ul> </div> </nav> <div id="main"></div> </div> </body> </html>
Исправим файл app.js. Я удалил не нужный для моего примера код что бы не нагромождать файл лишним функционалом. Теперь app.js выглядит так:
app.js
var express = require('express'); var path = require('path'); var favicon = require('serve-favicon'); var logger = require('morgan'); var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); var app = express(); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); //Routes app.get('/', function(req, res, next) { res.sendFile(path.join(__dirname, 'public/tpl/index.html')); }); // catch 404 and forward to error handler app.use(function(req, res, next) { var err = new Error('Not Found'); err.status = 404; next(err); }); // development error handler if (app.get('env') === 'development') { app.use(function(err, req, res, next) { res.status(err.status || 500); res.json({ message: err.message, error: err }); }); } // production error handler app.use(function(err, req, res, next) { res.status(err.status || 500); res.json({ message: err.message, error: err }); }); module.exports = app;
И добавим загрузку приложения в init.js:
require([ 'backbone', 'router', ], function(Backbone, Route){ //Стартуем приложение после загрузки модели пользователя var appRoute = new Route(); Backbone.history.start(); });
Запускаем наше приложение, и смотрим что получилось. Создадим view для нашей страницы логина.
public/js/login_view.js
define([ 'backbone', 'text!tpl/login.html', //Шаблон формы авторизации 'vk', //Vk Api 'fb' //Fb Api ], function(Backbone, Tpl, VK, FB){ return Backbone.View.extend({ initialize: function () { this.render(); }, events: { 'click #fb_login' : 'fb_login', 'click #vk_login' : 'vk_login' }, fb_login: function(e){ e.preventDefault(); }, vk_login: function(e){ e.preventDefault(); }, render: function(){ this.$el.html(Tpl); } }); });
Добавим шаблон для страницы логина:
<h3>Login</h3> <a href="" id="fb_login">Войти с помощью Facebook</a> <br> <a href="" id="vk_login">Войти с помощью Vkontakte</a>
Авторизация через Facebook Api

Для авторизации через Facebook api нам нужно создать приложение. Я его уже создал, а вы можете сделать это по ссылке следуя не сложной инструкции.
Инициализируем подключение к API.
public/js/login_view.js:
initialize: function () { FB.init({ appId: ID приложения, cookie: true, oauth: true}, function(err){ console.log(err); }); this.render(); });
Обновляем страницу в браузере и видим в консоли ошибку:
URL заблокирован: Мы не можем перенаправить Вас, URI не в белом списке приложения клиентских настроек. Убедитесь, что клиент и Web OAuth Login включены и добавьте Ваши приложения как домены действительные OAuth перенаправлении URI.
Это происходит потому что мы не добавили наш домен в настройки приложения. Давайте добавим localhost:3000/ в список действительных URL адресов. Для этого переходим в настройки нашего приложения, далее «Вход через фейсбук», и добавляем localhost:3000/ в поле «Действительные URL-адреса для перенаправления OAuth» и нажимаем сохранить.
Теперь нужно авторизироваться на стороне Facebook API. Для этого вызовем метод login, который принимает calback функцию первым аргументов и объект прав. Запроси�� основную информацию + email пользователя.
public/js/login_view.js:
fb_login: function(e){ e.preventDefault(); FB.login(function(res) { console.log(res); }, { scope: 'public_profile,email'} ); },
Теперь обновив страницу и нажав «Войти с помощью Facebook» у нас появится окно в котором Facebook попросит подтвердить вход в наше приложение. После подтверждения можно увидеть в консоли браузера ответ от API. Нас интересует параметр status и authResponse.accessToken.
Status — статус текущего пользователя. Возможные значения:
- connected — пользователь авторизован в Facebook и разрешил доступ приложению;
- not_authorized — пользователь авторизован в Facebook, но не разрешил доступ приложению;
- unknown — пользователь не авторизован в Facebook.
accessToken — токен доступа, который мы будем в дальнейшем использовать.
Давайте добавим обработчик статусов и получим нужную нам информацию о текущем пользователе:
fb_login: function(e){ e.preventDefault(); FB.login(function(res) { if (res.status === 'connected') { var fields = ['id', 'first_name', 'last_name', 'link', 'gender', 'picture', 'email']; FB.api('/me?fields=' + fields.join(','), function(res) { console.log(res); }); } }, { scope: 'public_profile,email'} ); },
Теперь авторизировавшись в консоли мы увидим объект данных которые мы запросили. Подробнее о информацие которую можно получить читайте тут.
Отлично. Мы получили информацию о пользователе от facebook, но на клиентской стороне она не особо полезна. Хотелось бы авторизировать пользователя на стороне сервера и записать данные о нем в БД.
Для отправки запроса с сервера нам понадобится access_token, который мы получили немного раньше. Давайте отправим его на сервер:
fb_login: function(e){ e.preventDefault(); FB.login(function(res) { if (res.status === 'connected') { $.ajax({ url: '/auth/facebook', method: 'POST', data: { accessToken: res.authResponse.accessToken }, dataType: 'JSON', success: function(res){ console.log(res); } }); } }, { scope: 'public_profile,email'} ); },
А на сервере запросим информацию у Facebook:
app.js:
app.post('/auth/facebook', function(req, res, next){ var accessToken = req.body.accessToken; var profileFields = ['id', 'first_name', 'last_name', 'link', 'gender', 'picture', 'email']; var request = require('request'); request({ url: 'https://graph.facebook.com/me?access_token=' + accessToken + '&fields=' + profileFields.join(','), method: 'GET', json: true },function (error, response, body) { /** * Тут пишем данные в базу */ res.cookie.login = 'test'; res.cookie.hash = 'test'; res.json(body); }); });
Я сохранил в куках логин и хеш для дальнейшей демонстрации авторизации. При отправке запроса обязательно нужно указать json:true, для того что бы получить javascript-объект, а не json-строку. Перезапустим приложение, логинимся, и видим ответ в консоли браузера. Отлично. Все работает как надо.
Авторизация через Вконтакте Api.

Авторизация через Вконтакте не сильно отличается от Facebook, поэтому я буду описывать менее подробно. Создаем приложение для авторизации тут. Инициируем подключение к VK API:
VK.init( { apiId: ID приложения },function(res) { console.log('success'); }, function(res) { console.log('error'); }, '5.53');
Логинимся. (Вторым параметром в метод login, передаем число, обозначающее права, которые мы хотим получить).
vk_login: function(e){ e.preventDefault(); VK.Auth.login(function(res){ console.log(res); }, 4194304 ); },
Смотрим в консоль и видим ответ. У нас тут так же содержится параметр status и sig(access_token) + объект user, содержащий некоторую информацию о пользователе.
Далее все идет не так гладко как с Facebook.

Проблема 1
Полученный токен(sig) привязывается к ip-адресу, и при попытке использовать его на сервере вам выдаст ошибку: «User authorization failed: access_token was given to another ip addres». и при получении токена на клиентской стороне мы не сможем его использовать на сервере.
Самое интересное в сложившейся ситуации то, что это не так просто обнаружить, если разрабатывать и тестировать на одном ip. Проблема может всплыть только на боевом сервере.
В интернете существует миф о том что в scope нужно указать разрешение «offline», тогда токен будет «вечным» и не привязывается к IP. Но данный метод не убирает привязку к ip-адресу.
| offline (+65536) | Доступ к API в любое время (при использовании этой опции параметр expires_in, возвращаемый вместе с access_token, содержит 0 — токен бессрочный). |
Проблема 2
При таком способе авторизации нет возможности получить email пользователя, даже если вы запросите нужные права и пользователь даст согласие — вы не получите email в ответе.
При серверной авторизации, описанной в документации vk.com/dev/authcode_flow_user, если в scope указать email то он будет возвращен вместе с токеном. При использовании open api, email-адрес не приходит с токеном. Обратившись в техническую поддержку я получил ответ:
Агент поддержки #1605
В настоящий момент возможность получения e-mail предусмотрено только при использовании OAuth-авторизации, средствами Open API это сделать не получится.
Как быть?
Токен, полученный на клиентом, мы не можем использовать на сервере, и соответственно не можем запросить информацию о пользователе со стороны сервера, но мы можем проверить токен на валидность и узнать ид пользователя которому принадлежит данный токен.
С документации мы можем узнать что Параметр sig равен md5 от конкатенации следующих строк:
- данных сессии expire, mid, secret, sid в виде пар parameter_name=parameter_value, расположенных в порядке возрастания имени параметра (по алфавиту);
- защищенного ключа Вашего приложения.
Давайте получим информацию о пользователе через open api, передадим её на сервер, проверим токен, и если все ок запишем в базу:
vk_login: function(e){ e.preventDefault(); VK.Auth.login(function(res){ if (res.status === 'connected') { var data = {}; data = res.session; var user = {}; user = res.session.user; VK.Api.call('users.get', { fields: 'sex,photo_50' }, function(res) { if(res.response){ user.photo = res.response[0].photo_50; user.gender = res.response[0].sex; data.user = user; $.ajax({ url: '/auth/vk', method: 'POST', data: data, dataType: 'JSON', success: function(res){ console.log(res); } }); } }); } }, 4194304 ); },
Для создания md5-хеша используем crypto:
npm install crypto
app.js:
app.post('/auth/vk', function(req, res, next) { var secretKey = '( . )( . )'; //Защищенный ключ приложения var sig = req.body.sig, expire = req.body.expire, mid = req.body.mid, secret = req.body.secret, sid = req.body.sid, user = req.body.user; var str = "expire=" + expire + "mid=" + mid + "secret=" + secret + "sid=" + sid + secretKey; var hash = crypto.createHash('md5').update(str).digest('hex'); //Пользователь наш if(hash == sig){ /** * Тут пишем данные в базу, сохраняем сессии, куки и т.д */ res.cookie.login = 'test'; res.cookie.hash = 'test'; res.json({ success: true }); } else { res.json({ success: false }); } });
Теперь наше приложение проверяет токен и id пользователя который пришел и мы можем авторизировать пользователя на сервере на основании этих данных.
Проверка авторизации
Давайте создадим модель, которая будет содержать информацию о пользователе:
public/js/models/user.js:
define([ 'backbone' ], function(Backbone){ var User = Backbone.Model.extend({ url: '/auth/getUser', initialize: function(){ console.log('user model was loaded'); //Слушаем изменение модели. Если что-то меняется - обновляем auth this.on('change', function(){ if(this.has('login')){ this.set('auth', true); } }); }, defaults: { auth: false }, isAuth: function(){ return this.get('auth'); }, logout: function(){ //Удаляем данные модели this.clear(); //Разлогиниваемся на стороне сервера $.post( "/auth/logout" ); } }); return new User(); });
Теперь давайте загрузим модель пользователя до того как запустим наше приложение:
public/js/init.js:
require([ 'backbone', 'router', 'models/user' ], function(Backbone, Route, User){ //Стартуем приложение после загрузки модели пользователя User.fetch().done(function(){ var appRoute = new Route(); Backbone.history.start(); }); });
И добавим проверку в router.js:
router.js
define([ 'baseRouter', 'views/login_view', 'models/user' ], function(BaseRouter, LoginView, User){ return BaseRouter.extend({ initialize: function(){ //Модель пользователя this.model = User; //Слушаем изменение свойства auth, модели пользователя и релоадим роут this.listenTo(this.model, 'change:auth', function(){ Backbone.history.loadUrl(); }); }, routes: { "" : "index", "#" : "index", "secure": "secure", "login" : "login", "logout": "logoute" }, //Страницы к которым нужна авторизация secure_pages: [ '#secure' ], before : function(params, next){ //Текущий роут var path = Backbone.history.location.hash; //Нужна ли авторизация для доступа к данному роуту? var needAuth = _.contains(this.secure_pages, path); if(path == '#login' && User.isAuth()){ this.navigate("/", true); }else if(!User.isAuth() && needAuth){ this.navigate("login", true); } else { next(); } }, index: function(){ $('#main').html('Index page'); }, secure: function(){ $('#main').html('Secure page'); }, login: function(){ $('#main').html( new LoginView().el ); }, logoute: function(){ this.navigate("/", true); this.model.logout(); } }); });
Добавим роут получения информации о пользователе на сервере:
app.get('/auth/getUser', function(req, res, next){ /** * Достаем пользователя с базы */ if(res.cookie.login == 'test' && res.cookie.hash == 'test'){ res.json({ login: 'text', hash: 'text' }); } else { res.send({}); } });
и роут logout:
app.post('/auth/logout', function(req, res, next){ res.cookie.login = ''; res.cookie.hash = ''; });
Последним штрихом добавим user_view, в который будем выводить информацию о пользователе в шапке:
public/js/views/user_view.js:
define([ 'backbone', 'text!tpl/user.html' ], function(Backbone, Tpl){ return Backbone.View.extend({ tpl: _.template(Tpl), initialize: function(){ this.render(); //Слушаем изменение модели, если что-то изменилось - перерисовываем this.listenTo(this.model, 'change:auth', function(){ this.render(); }); }, events: { //Обработчик на кнопку разлогинивания 'click #logout':'logout' }, logout: function(e){ e.preventDefault(); //Разлогиниваем пользователя this.model.logout(); }, render: function(){ this.$el.html( this.tpl({ user:this.model.toJSON() })); } }); });
Шаблон для user_view:
public/tpl/user.html:
<ul class="nav navbar-nav navbar-right"> <li> <p class="navbar-text">Вы вошли как <%= user.auth ? user.login.toUpperCase() : 'Гость' %></p> </li> <li> <% if(user.auth){ %> <a href="" id="logout">Logout</a> <% } else {%> <a href="#login">Login</a> <% } %> </li> </ul>
И изменим index.html:
<!DOCTYPE html> <html> <head> <title></title> <script data-main="/js/init" src="js/lib/require.js"></script> <link rel="stylesheet" href="/style/bootstrap.min.css"/> </head> <body> <div class="container"> <nav class="navbar navbar-default"> <div class="container-fluid"> <ul class="nav navbar-nav"> <li><a href="#">Home</a></li> <li><a href="#secure">Secure</a></li> </ul> <div id="user-info"></div> </div> </nav> <div id="main"></div> </div> </body> </html>
Запускаем наше приложение и радуемся.
» Исходники на Github.