From 45fd5e27f5db47f213579eee756528b43b17fe46 Mon Sep 17 00:00:00 2001 From: Herman Schistad Date: Sun, 16 Feb 2014 23:54:24 +0100 Subject: [PATCH] Adds cookie based login, also called "remember me"-functionality. This commit fixes issue #284 by adding cookies/authentication tokens at the client side and validating these in the DB. When a user either registers or logs in, without already having a cookie, a sha1-hash is generated using the username and a secret key. This hash is stored in a DB-table calles 'Session' and client side using the jquery-cookie plugin. When closing and opening the application again we check if the users have a 'auth_token' in their cookies, if this is the case we check its validity in the DB. If everything is OK, we 'jump through the hoops' and sets the user as logged in, restores his/hers connections and render the chat_application. If it is not valid we delete the cookie at the client and render the overview page. As I've never actually implemented a cookie-based login system using javascript before, I do not know if this solution is optimal and I'm more than happy to discuss alternative approaches or restructure the code. --- assets/js/client.js | 25 ++++++++ assets/js/libs/jquery.cookie.min.js | 2 + assets/js/views/overview.js | 10 +++- config.js | 8 ++- lib/models.js | 7 +++ lib/socket.js | 91 ++++++++++++++++++++++++++--- views/templates.jade | 2 + 7 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 assets/js/libs/jquery.cookie.min.js diff --git a/assets/js/client.js b/assets/js/client.js index b96c415..f114744 100644 --- a/assets/js/client.js +++ b/assets/js/client.js @@ -4,6 +4,7 @@ //= require 'libs/ICanHaz.min.js' //= require 'libs/bootstrap.min.js' //= require 'libs/ircparser.min.js' +//= require 'libs/jquery.cookie.min.js' //= require 'utils.js' //= require 'models.js' //= require 'collections.js' @@ -51,6 +52,14 @@ $(function() { } }); + irc.delete_session = function() { + // Deletes the session cookie at both server and client side + if ($.cookie('auth_token')) { + irc.socket.emit('session_delete', { auth_token: $.cookie('auth_token') }); + $.removeCookie('auth_token'); + } + } + // Registration (server joined) irc.socket.on('registered', function(data) { var window = irc.chatWindows.getByName('status'); @@ -69,6 +78,9 @@ $(function() { irc.socket.on('login_success', function(data) { window.irc.loggedIn = true; + + $.cookie('auth_token', data.auth_token, { expires: 7 }); + if(data.exists){ irc.socket.emit('connect', {}); } else { @@ -77,7 +89,9 @@ $(function() { }); irc.socket.on('disconnect', function() { + // The server probably went down. irc.connected = false; + irc.delete_session(); alert('You were disconnected from the server.'); $('.container-fluid').css('opacity', '0.5'); }); @@ -85,9 +99,18 @@ $(function() { irc.socket.on('register_success', function(data) { window.irc.loggedIn = true; + $.cookie('auth_token', data.auth_token, { expires: 7 }); irc.appView.overview.render({currentTarget: {id: "connection"}}); }); + irc.socket.on('session_not_found', function(data) { + // The client has a session cookie, but it is not found server-side. + // Delete it at the client, as something is not in sync, and render the overview page. + console.log("A session was found at client, but not in the server."); + irc.delete_session(); + irc.appView.overview.render(); + }); + irc.socket.on('restore_connection', function(data) { irc.me = new User({nick: data.nick, server: data.server}); irc.connected = true; @@ -283,6 +306,7 @@ $(function() { channel.stream.add(quitMessage); } } + irc.delete_session(); }); irc.socket.on('names', function(data) { @@ -347,6 +371,7 @@ $(function() { }); irc.socket.on('reset', function(data) { + irc.delete_session(); irc.chatWindows = new WindowList(); irc.connected = false; irc.loggedIn = false; diff --git a/assets/js/libs/jquery.cookie.min.js b/assets/js/libs/jquery.cookie.min.js new file mode 100644 index 0000000..9c99853 --- /dev/null +++ b/assets/js/libs/jquery.cookie.min.js @@ -0,0 +1,2 @@ +/*! jquery.cookie v1.4.0 | MIT */ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):a(jQuery)}(function(a){function b(a){return h.raw?a:encodeURIComponent(a)}function c(a){return h.raw?a:decodeURIComponent(a)}function d(a){return b(h.json?JSON.stringify(a):String(a))}function e(a){0===a.indexOf('"')&&(a=a.slice(1,-1).replace(/\\"/g,'"').replace(/\\\\/g,"\\"));try{a=decodeURIComponent(a.replace(g," "))}catch(b){return}try{return h.json?JSON.parse(a):a}catch(b){}}function f(b,c){var d=h.raw?b:e(b);return a.isFunction(c)?c(d):d}var g=/\+/g,h=a.cookie=function(e,g,i){if(void 0!==g&&!a.isFunction(g)){if(i=a.extend({},h.defaults,i),"number"==typeof i.expires){var j=i.expires,k=i.expires=new Date;k.setDate(k.getDate()+j)}return document.cookie=[b(e),"=",d(g),i.expires?"; expires="+i.expires.toUTCString():"",i.path?"; path="+i.path:"",i.domain?"; domain="+i.domain:"",i.secure?"; secure":""].join("")}for(var l=e?void 0:{},m=document.cookie?document.cookie.split("; "):[],n=0,o=m.length;o>n;n++){var p=m[n].split("="),q=c(p.shift()),r=p.join("=");if(e&&e===q){l=f(r,g);break}e||void 0===(r=f(r))||(l[q]=r)}return l};h.defaults={},a.removeCookie=function(b,c){return void 0!==a.cookie(b)?(a.cookie(b,"",a.extend({},c,{expires:-1})),!0):!1}}); \ No newline at end of file diff --git a/assets/js/views/overview.js b/assets/js/views/overview.js index e5cec74..410a58d 100644 --- a/assets/js/views/overview.js +++ b/assets/js/views/overview.js @@ -1,6 +1,14 @@ var OverviewView = Backbone.View.extend({ initialize: function() { - this.render(); + // Check if we are logged in with a cookie. + if ($.cookie('auth_token')){ + irc.socket.emit('session_check', { + auth_token: $.cookie('auth_token'), + }); + } else { + // If not, we render the overview page. + this.render(); + } }, events: { diff --git a/config.js b/config.js index 4935d25..86e21dd 100644 --- a/config.js +++ b/config.js @@ -33,5 +33,11 @@ module.exports = { use_polling: process.env.USE_POLLING || false, // Use polling if websockets aren't supported // limit each user's connection log to this amount of messages (***not implemented yet***) - max_log_size: 4096 + max_log_size: 4096, + + // How long you want to store a cookie, both server and client side, in hours. + cookie_time: 7 * 24, + + // Secret key used to generate a unique and secure session cookie hash. + secret_key: "MY-SUPER-SECRET-KEY" }; diff --git a/lib/models.js b/lib/models.js index 2c27163..d85b94d 100644 --- a/lib/models.js +++ b/lib/models.js @@ -52,6 +52,13 @@ module.exports = function (schema) { chanserv_password: { type: String } }); + schema.define('Session', { + auth_token: { type: String }, + username: { type: String }, + user_id: { type: String }, + expires: { type: Date } + }); + schema.autoupdate(); return schema; diff --git a/lib/socket.js b/lib/socket.js index cdcab1c..c0253c9 100644 --- a/lib/socket.js +++ b/lib/socket.js @@ -1,5 +1,6 @@ var bcrypt = require('bcrypt-nodejs'), - uuid = require('node-uuid'); + uuid = require('node-uuid'), + crypto = require('crypto'); function isChannel(name) { return ['#','&'].indexOf(name[0]) >= 0; @@ -23,6 +24,7 @@ module.exports = function(socket, app) { var Message = app.db.models.Message; var PM = app.db.models.PM; var Channel = app.db.models.Channel; + var Session = app.db.models.Session; // signal an IRC connection belonging to the user socket.signalIRC = function(connection, event, dict) { @@ -49,11 +51,13 @@ module.exports = function(socket, app) { , password: hash , joined: Date.now() }, function (err, user) { - socket.emit('register_success', {username: data.username}); - socket.userID = user.user_id; - // subscribe to all online IRC connections using socket.io room - socket.join(socket.userID); - socket.socketIORoom = socket.userID; + socket.session_create(data, user.user_id, function(data, auth_token) { + socket.emit('register_success', {username: data.username, auth_token: auth_token}); + socket.userID = user.user_id; + // subscribe to all online IRC connections using socket.io room + socket.join(socket.userID); + socket.socketIORoom = socket.userID; + }); }); }); }); @@ -87,7 +91,11 @@ module.exports = function(socket, app) { socket.connID = connections[0].id; socket.conn = connections[0]; } - socket.emit('login_success', {username: data.username, exists: exists}); + socket.session_create(data, user.user_id, function(data, auth_token){ + socket.emit('login_success', { username: data.username, + exists: exists, + auth_token: auth_token }); + }); }); } else { socket.emit('login_error', {message: 'Wrong password'}); @@ -98,6 +106,75 @@ module.exports = function(socket, app) { } }); }); + + socket.session_create = function(data, user_id, callback) { + /* + * Add the successful login to the session database. + */ + // Create a sha1-hash based on username and secret key + var hash = crypto.createHash('sha1'); + var auth_token = hash.update(data.username + app.config.secret_key).digest('hex'); + + // Delete any previous sessions for this user_id. + Session.all({ where: { user_id: user_id } }, function(err, sessions){ + sessions.forEach(function(session){ + session.destroy(); + }); + }) + + var now = new Date(); + Session.create({ + auth_token: auth_token, + username: data.username, + user_id: user_id, + expires: new Date().setHours(now.getHours() + app.config.cookie_time), + }); + callback(data, auth_token); + } + + socket.on('session_check', function(data){ + /* + * Check if the token provides is valid, that is: in the database and not + * expired. If found we restore the users connections, if any, and log in. + */ + Session.findOne({ where: { auth_token: data.auth_token}}, function(err, session){ + var now = new Date(); + if(session && session.expires > now) { + // TODO: Join the logic inside here, with the logic inside on('login') + socket.userID = session.user_id; + socket.join(socket.userID); + socket.socketIORoom = socket.userID; + + Connection.all({where: { user_id: session.user_id } }, function (err, connections) { + var exists = false; + if (connections.length > 0) { + exists = true; + // TEMPORARY - read note at top of this file on socket.connID + socket.connID = connections[0].id; + socket.conn = connections[0]; + } + socket.emit('login_success', { username: session.username, exists: exists }) + }); + } else { + if (session && session.expires < now) { + // Delete session if it is expired. + session.destroy(); + } + // In this situation, which should be rare, the client has a cookie + // which is not in the database. Thus we just want to delete the cookie + // at the client. + socket.emit('session_not_found'); + } + }) + }); + + socket.on('session_delete', function(data){ + Session.findOne({ where: {auth_token: data.auth_token}}, function(err, session){ + if (session) { + session.destroy(); + } + }); + }); // connection creation/restore socket.on('connect', function(data) { diff --git a/views/templates.jade b/views/templates.jade index 563a9ae..52bca57 100644 --- a/views/templates.jade +++ b/views/templates.jade @@ -22,12 +22,14 @@ script(id="overview_home", type="text/html") li.overview_button#settings img(src="/assets/images/settings.svg") span Settings + {{^loggedIn}} li.overview_button#login img(src="/assets/images/login.svg") span Login li.overview_button#register img(src="/assets/images/register.svg") span Register + {{/loggedIn}} script(id="overview", type="text/html") #overview