Tuesday, July 1, 2014

Facebook authentication using EmberJS and NodeJS, end 2 end

Вэб програмд client side authentication хэрхэн ямар зарчимаар ажилладаг талаар гайхаж байсан юм иймээс мэдэж байгаагаа хуваалцая гэж бодлоо.



Цэвэр клиэнт талын facebook authentication хийсэн тохиолдолд back end тал дээр яаж resource protection хийх вэ? гэдэг асуудал урган гарна.

Тэхээр ерөнхий workflow нь дараах байдалтай ажиллана :

  • Client ийн зүгээс facebook ч юм уу google мэтийн oauth provider-уудруу өөрийнхөө эрхээр нэвтрэлт хийх хүсэлт явуулна. 
  • Нэвтрэлт амжилттай болвол provider-ийн зүгээс нэвтэрсэн нь үнэн болно гэдгийг илтгэх access token бүхий өгөгдөл буцаан явуулдаг. Голдуу random string болон ямар нэгэн hash-лсан утга
  • Access token -оо client тал дээр олж авсан тохиолдолд back end рүүгээ жинхэнэ нэвтрэлт хийх(холбогдох) хүсэлтийг access token ий хамтаар явуулна.
  • Back end дээр access token ийг хүлээж аваад тухайн provider-лүү энэ access token нь хүчинтэй эсэхийг магадалсан хүсэлт явуулна.
  • Хүсэлтийн хариуд provider-дээрхи хэрэглэгчийг илтгэх ID болон бусад email, нэр мэтийн өгөгдлүүдийг буцаана
  • Энэ ID-аар backend-ийнхээ хэрэглэгчдийг хадгалдаг хүснэгтээс шүүгээд байхгүй бол шинээр хэрэглэгч хадгалж үүсгэнэ
  • Ингээд back end дээрхи хүснэгтээсээ хэрэглэгчээ олж авах бөгөөд энэ хэрэглэгчийн auth token талбарт нь өөрөө шинээр access token маягийн random string үүсгээд хадгалаад эдгээр мэдээллээ client рүү явуулна. Үүнийг back end дээр resource protection хийхэд ашиглана гэсэн үг.
  • Client тал дээр back end тэйгээ ярилцахад хэрэг болох access token буюу арай өөрөөр auth token гэж нэрлээд байгаа random string-тэй болж авна. Үүнийгээ back end-рүү хүсэлт  явуулах болгондоо header-т хадгалах хэрэгтэй. Authorization гэсэн header-ийн талбарт хадгална. 
  • Back end дээрхи ямар нэгэн protected нөөцрүү хандахад back end тал дээр эхлээд request-ийн header ийг шалгах хэрэгтэй. Header-ийн Authorization талбараас нь auth token-оо олж аваад хэрэглэгчдийг хадгалж байгаа хүснэгтийг энэ утгаар шүүгээд хэрэв олдвол энэ хэрэглэгч нь энэхүү нөөцөд хандах бүрэн эрхтэй юм байна гэж ойлгож болно. Тухайн хэрэглэгчийг илтгэж байх ID-г нь ч мөн дараа дараагийн боловсруулалтанд хэрэглэх зорилгоор header дээр нь хамт дайгаад ашиглаж болох юм.
  • Энэ мэтчилэн ажилласаар хэрэглэгч системээс гарах хэрэг гарвал provider-луу log out хийх хүсэлт явуулна. Хүсэлт амжилттай болвол өөрийн backend рүү log out хийх хүсэлтийг мөн явуулна. back end дээр ийм request орж ирвэл header дээрхи auth token-оор нь хэрэглэгчээ хайж олоод auth token талбарт нь хоосон утга онооно. Ингээд бүрэн систем сална. Гэхдээ клиэнт талын cache болон localStorage дээр хадгалсан хэрэглэгчийн бусад мэдээллүүдийг ч гэсэн арчих хэрэгтэй.


Emberjs-д client side auth хийхэд зориулагдсан Simple-Auth хэмээх сан бий үүнийг ашиглая. Үүний тулд FacebookAuthenticator объект үүсгэх хэрэгтэй. Энэ объект нь Facebook-тэй холбогдсон байвал нэвтрүүлээд холбогдоогүй байвал facebook login хүсэлт явуулан амжилттай болвол access token-ийг backend-рүү илгээн буцаж ирэх үр дүнг session объектэд хадгална. энэ session объект нь emberjs ийн хувьд хаанач дуудан ашиглаж болох singleton объект юм.

export default Ember.SimpleAuth.Authenticators.Base.extend({
  restore: function(properties) {
   return new Ember.RSVP.Promise(function(resolve, reject) {
    if (!Ember.isEmpty(properties.accessToken)) {
     resolve(properties);
    } else {
     reject();
    }
   });
  },
     authenticate: function() {
     return new Ember.RSVP.Promise(function(resolve, reject) {
      FB.getLoginStatus(function(fbResponse) {
       if (fbResponse.status === 'connected') {
        Ember.run(function() {
         Em.$.post('api/v1/auth/connect', {
          'access_token' : fbResponse.authResponse.accessToken
         }).done(function(response) {
          console.log(response);
          resolve({ 
           accessToken : fbResponse.authResponse.accessToken,
           userID      : fbResponse.authResponse.userID,
           authToken   : response.user.authToken,
           username    : response.user.username,
           email       : response.user.email
          });
         });
        });
       } else if (fbResponse.status === 'not_authorized') {
        reject();
       } else {
        FB.login(function(fbResponse) {
         if (fbResponse.authResponse) {
          Ember.run(function() {
           Em.$.post('api/v1/auth/connect', {
            'access_token' : fbResponse.authResponse.accessToken
           }).done(function(response) {
            console.log(response);
            resolve({ 
             accessToken : fbResponse.authResponse.accessToken,
             userID      : fbResponse.authResponse.userID,
             authToken   : response.user.authToken,
             username    : response.user.username,
             email       : response.user.email
            });
           });
          });
         } else {
          reject();
         }
        }, {
         perms: 'email'
        });
       }
      });
     });
    },
    invalidate: function() {
     return new Ember.RSVP.Promise(function(resolve, reject) {
      FB.logout(function(response) {
       Ember.run(resolve);
      });
     });
    }
});



Backend дээр холбогдох хүсэлт ирвэл facebook access token-ийг нь хүчинтэй эсэхийг нь магадлаад тийм бол хэрэглэгчээр хайж олон шинээр auth token-ийг back end дээ зориулан үүсгэнэ.

var crypto  = require('crypto');
var express = require('express');
var request = require('request');
var User    = require('../models/user');

module.exports = function(app) {
 var router = express.Router();
 router.post('/auth/disconnect', function(req, res) {
  var userID = req.headers['api-userid'];
  User.findOne({facebookUserId: userID}, function(err, user) {
   if (err) res.send(404, 'not found');
   user.authToken = '';
   user.save(function(err) {
    res.send('logged out');
   });
  });
 });
 router.post('/auth/connect', function(req, res) {
  var accessToken = req.body.access_token;
  var path = 'https://graph.facebook.com/me?access_token='+accessToken;
  request(path, function(error, response, body) {
   var data = JSON.parse(body);
   if (!error && response && response.statusCode && response.statusCode == 200) {
    var u = {
     facebookUserId : data.id,
     username       : data.name,
     email          : data.email
    };
    User.findOne({facebookUserId: u.facebookUserId}, function(err, user) {
     if (err) res.send(401, 'error');
     if (!user) {
      user = new User({
       facebookUserId : u.facebookUserId,
       username       : u.username,
       email          : u.email,
       authToken      : crypto.randomBytes(20).toString('hex')
      });
     } else {
      user.facebookUserId = u.facebookUserId;
      user.username       = u.username;
      user.email          = u.email;
      user.authToken      = crypto.randomBytes(20).toString('hex');
     }
     user.save(function(err) {
      if (err) res.send(401, 'error');
      res.json(200, {user: user});
     });
    });
   } else {
    res.send(401, 'Not authorized');
   }
  });
 });
 router.post('/auth/login', function(req, res) {
  var email = req.body.email;
  var password = req.body.password;
  User.findOne({email: email, password: password}, function(err, user) {
   if (err) res.send(401, 'error');
   if (!user) {
    res.send(404, 'No such user');
   } else {
    user.authToken = crypto.randomBytes(20).toString('hex');
    user.save(function(err) {
     if (err) res.send(401, 'error');
     res.json(200, {user: user});
    });
   }
  });
 });

 app.use('/api/v1', router);
};



Client-ийн session объект нэвтэрсэн төлөвт орвол header-тээ auth token-ийг оноон тохируулна.

export default Ember.SimpleAuth.Authorizers.Base.extend({
 authorize: function(jqXHR, requestOptions) {
  if (this.get('session.isAuthenticated') && !Ember.isEmpty(this.get('session.authToken'))) {
   jqXHR.setRequestHeader('Authorization', 'auth_token ' + this.get('session.authToken'));
   jqXHR.setRequestHeader('Api-UserID', this.get('session.userID'));
  }
 }
});


Эдгээр объектүүдийг Simple Auth ийн тохиролцоо ёсоор хамгийн эхлээд ажилладаг програм Initializer дээр тохируулах хэрэгтэй

import FacebookAuthenticator from 'frontend/objects/facebook-authenticator';
import CustomAuthorizer from 'frontend/objects/custom-authorizer';

export default {
    name       : 'authentication',
    initialize : function(container, application) {
  var self = this;
  FB.init({appId: ''});
  container.register('authorizer:custom', CustomAuthorizer);
  container.register('authenticator:facebook', FacebookAuthenticator);
  Ember.SimpleAuth.setup(container, application, {
   authorizerFactory : 'authorizer:custom'
  });
    }
};



Хэрэв хэрэглэгч системээс Log out хийн гарахыг хүсвэл Application controller дээрээ

export default Ember.ObjectController.extend({
 actions: {
  logout: function() {
   var self = this;
   Em.$.post('api/v1/auth/disconnect', {}).always(function(response) {
    localStorage.clear();
    self.get('session').invalidate();
    location.reload();
   });
  }
 }
});



Route-үүдийн хувьд Simple-Auth сангийн тохиролцоог дагах хэрэгтэй. application route ийн хувьд
export default Ember.Route.extend(Ember.SimpleAuth.ApplicationRouteMixin, {
});

login route ийн хувьд
export default Ember.Route.extend({
 activate: function() {
  if (this.get('session.isAuthenticated'))
   this.transitionTo('index');
 },
 actions: {
  authenticateWithFacebook: function() {
   this.get('session').authenticate('authenticator:facebook', {});
  }
 }
});

protected route ийн хувьд
export default Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, {
});


Back end тал дээр resource protection хийхийн тулд Passport плагинтай хамт хэрэглэгддэг EmberAuthStrategy хэрэлэн ашиглаж болно. Эхлэн тохируулахдаа

var express               = require('express');
var bodyParser            = require('body-parser');
var mongoose              = require('mongoose');
var port                  = process.env.PORT || 8080;
var request               = require('request');
var crypto                = require('crypto');
var passport              = require('passport');
var EmberAuthStrategy     = require('passport-ember-auth').Strategy;

var globSync          = require('glob').sync;
var routes            = globSync('./routes/*.js', { cwd: __dirname}).map(require);

var FACEBOOK_APP_ID     = '',
 FACEBOOK_APP_SECRET = '';

mongoose.connect('mongodb://127.0.0.1:27017/test');

var User = require('./models/user');
function findByToken(token, fn) {
 User.findOne({authToken: token}, function(err, user) {
  if (err) return fn(null, null);
  return fn(null, user);
 });
}

var app = express();
app.use(bodyParser());
app.disable('etag');
app.use(passport.initialize());
passport.use(new EmberAuthStrategy({}, function(token, done) {
 process.nextTick(function() {
  findByToken(token, function(err, user) {
   if (err) return done(err);
   if (!user) return done(null, false);
   return done(null, user);
  });
 });
}));

app.get('/', function(req, res, next) {
  res.send('INDEX');
});

routes.forEach(function(route) { route(app); });

app.listen(port);
console.log('Magic is happening on port '+port);



А харин protected resource-руу хандахыг хүсвэл хамгаалалтыг дараах байдлаар хийж болох юм.
var express  = require('express');
var passport = require('passport');
var User     = require('../models/user');

module.exports = function(app) {
 var router = express.Router();
 router.route('/protected')
  .post(passport.authenticate('EmberAuth', {session: false}),
    function(req, res) {
     console.log(req.headers['api-userid']);
     res.send('ITS OKEY');
    });
 app.use('/api/v1', router);
};


Бүрэн эх коод https://github.com/developerbro/ember-cli-nodejs-facebook