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

Backbone.js


Если у вас не установлен 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
¦   L-- www
+-- package.json
+-- public
¦   +-- img
¦   +-- js
¦   +-- tpl
¦   L-- style
¦       L-- style.css
+-- routes
¦   +-- index.js
¦   L-- 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.
Поделиться с друзьями
-->

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


  1. illi
    21.09.2016 12:28
    +1

    По поводу серверной части буду краток:
    passportjs


    1. evgeniy2194
      21.09.2016 12:31
      -4

      passportjs не подходит для реализации single page


      1. baka_cirno
        21.09.2016 19:56

        Чудесно подходит, по своему опыту говорю.


        1. evgeniy2194
          21.09.2016 21:38

          И как бы вы реализовали авторизацию с помощью passportjs на стороне клиента?


          1. mamiamradio
            22.09.2016 11:14

            А в чем конкретно вы видите проблему?


            1. evgeniy2194
              22.09.2016 11:28

              Проблем я не вижу.

              Меня смущает нарушение концепции SPA. Вместо того чтобы показать пользователю всплывающее окно, динамически авторизировать его и перерисовал лишь нужные елементы, приходится перенаправлять пользователя на сайт соц сети, потом редиректить на redirect url и опять загружать приложение.

              Представим на момент что у вас есть клиент, сервер со статикой и REST API. Не будет ли использование в данном случае passportjs костылем?


              1. mamiamradio
                22.09.2016 12:22

                https://github.com/jaredhanson/passport-facebook/blob/master/lib/strategy.js#L90
                https://github.com/stevebest/passport-vkontakte/blob/master/lib/strategy.js#L103

                Popup можно открыть с помощью window.open (в крайнем случае откроется новая вкладка).
                Отследить закрытие можно при помощи события onunload как вариант.


                1. evgeniy2194
                  22.09.2016 12:52

                  Ну как вариант.


  1. x512
    21.09.2016 12:45

    У вас, как я понимаю, Facebook авторизация сделана по старинке через всплывающее окно? Почему не через редирект?


    1. evgeniy2194
      21.09.2016 12:55
      -2

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


  1. oxidmod
    21.09.2016 12:51

    небольшое замечание по public/js/login_view.js
    что будет, если в метод render вы добавите
    console.log('1');
    ?


    1. oxidmod
      22.09.2016 13:06

      ну так что, автор не скажет, сколько раз выведется в консоль данное сообщение?


      1. evgeniy2194
        22.09.2016 13:16

        Один раз выводит 1 при переходе по ссылке /#login


      1. evgeniy2194
        22.09.2016 13:22

        Вы наверное имели ввиду файл public/js/views/user_view.js, если там добавить console.log('1'), то выведет 2 раза. Я это обнаружил уже после публикации статьи.

        Происходит это потому что 1 раз срабатывает событие change модели после того как она загрузится с сервера, а второй раз после того как изменится значение auth


      1. evgeniy2194
        22.09.2016 13:27
        +1

        Исправил. Спасибо за замечание.


  1. inoyakaigor
    21.09.2016 14:56

    Я думаю здесь самое место для моего вопроса:
    Делаю SPA на Реакте с авторизацией через OpenAPI VK. При попытке залогинится через ВК в хроме возвращает ответ, что я не авторизован, хотя я залогинен в ВК и авторизация нормально проходит на той же машине через Firefox. ЧСХ, на другом компьютере в Хроме авторизация проходит успешно


    1. bromzh
      21.09.2016 21:54

      Я думаю здесь самое место для моего вопроса:

      Нет


      1. inoyakaigor
        22.09.2016 12:56
        +3

        Я уверен, что комментарии на Хабре читают куда больше людей, чем просматривают новые вопросы на Тостере, а посему вполне логично спросить там, где больше вероятность получить ответ.
        Но таки да, намёк в виде минусов понял, учту.


  1. Mak_Di
    21.09.2016 21:39

    В user.html имеется опечатка:

    <p class="navbar-text">Вы вошли как гость <%= user.auth ? user.login.toUpperCase() : 'Гость' %>
    

    Если не авторизован, то будет сообщение вида: «Вы вошли как гость Гость»


    1. evgeniy2194
      21.09.2016 21:40

      Спасибо, исправил.


  1. Aries_ua
    22.09.2016 10:50
    +1

    Спасибо за статью. Как раз предстоит делать подобное в своем проекте. Теперь будет проще это сделать.