Привет! В данной статье я хочу рассказать о том, как реализовать авторизацию с помощью социальных сетей в одностраничном приложении на примере Backbonejs + Express.
Если у вас не установлен 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:
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 выглядит так:
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.
Теперь нужно авторизироваться на стороне 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:
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', 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.
Автор: evgeniy2194