Updated As has been pointed out, sending the username and password hash over to the client is not the most secure thing you can do. I wholeheartedly agree. Please do not do this on anything for real-ish. Treat this tutorial as a simple demo of how to isolate a connection until you give it some sort of approval. A much better approach would be to use a one-time throw away key or create a salted hash using the incoming client’s IP address. Or probably a million other things. The concept of isolating a connection to socket.io within a closure still applies.
Now back to your regularly scheduled programming:
One of the problems I have had with nodechat.js is that using sessions to handle the transition of authenticated users between express and socket.io has always been somewhat finicky.
Nodechat.js and the previous nodechat-tutorials have used sessions to manage this transition by storing a username and password in the session after initial login and making it available to the socket.io listener on new connections and during each message. While adequate most of the time, this method never worked correctly with all the various socket.io transports and it seemed like clients would frequently get stuck when reconnecting, requiring them to reload the page to get back into nodechat.js.
I now have a pretty decent way to address this issue and I am going to share it with you. A little technique I came up with that I call Purgatory…
The goal of this tutorial is to set up nodechat to authenticate clients initially through connect/express and then to pass that authentication along to a subsequent socket.io connection without relying on the session.
Before we get started, a few notes:
- The code can be found on the v0.3 tag on github if you want to follow along.
- This tutorial was written with npm 1.0RC, so I have been able to include all dependencies in the source code.
- I am going to assume you are comfortable with node/socket or have at least worked through the previous two nodechat tutorials.
In standard fzysqr tutorial style, we will just march through the important bits of code, explaining as we go.
Views
We start off by making a few views. First up, our login page:
login.jade
Crate views/login.jade as follows:
!!! 5
html(lang="en")
head
title nodechat login
body
#heading
h1 nodechat login
#content
p
| please login
form(method="post", action="/")
div(style='width:200px; text-align:right')
label username:
input(type='text', name='username')
br
label password:
input(type='password', name='password')
br
input(type='submit', value='login')
p
| or create an account
form(method="post", action="/")
div(style='width:200px; text-align:right')
label username:
input(type='text', name='username')
br
label password:
input(type='text', name='password')
br
label email:
input(type='text', name='email')
br
input(type='submit', value='signup & login')
We have a single login/signup page that uses two different forms to handle their respective duties. The password is not hidden when signing up for a new account. Both forms post to the same URI, and we use the existence of the email field to tell which one was used.
index.jade
Next make views/index.jade as follows:
!!! 5
html(lang="en")
head
title nodechat
script(type="text/javascript", src="/lib/jquery-1.5.1.js")
script(type="text/javascript", src="/lib/underscore.js")
script(type="text/javascript", src="/lib/backbone.js")
script(type="text/javascript", src="/socket.io/socket.io.js")
script(type="text/javascript", src="/models/models.js")
script(type="text/javascript", src="/controllers/controllers.js")
script(type="text/javascript", src="/views/views.js")
script
//Fake out FF and IE8
function log() {
if (typeof console == 'undefined') {
return;
}
console.log.apply(console, arguments);
}
$(document).ready(function () {
window.app = NodeChatController.init({hashpassword: !{locals.hashpassword}, userName: '!{locals.name}'});
});
body
#heading
h1 nodechat
#content
p
| connected clients
span#client_count 0
p
a(href='/logout') logout
p
label You are logged in as:
= locals.name
p
| Fun Chat Messages
ul#chat_list
form(method="post", action="#", onsubmit="return false")#messageForm
p
label Message:
input(type='text', name='message')
input(type='submit', value='send')
This page serves up the actual chat UI. It includes the necessary dependencies, sets up the console logging and initializes the controller. The username and hashpassword are stored in the locals collection and replaced by express when the template is rendered. In turn, the parameters are passed to the controller for use later.
views.js and models.js
The backbone views are defined in the views/views.js folder. I won’t go over each view, as they are largely unchanged from the previous tutorials. Just pull the file from github and place in views.
We have the same story with models.js. It is basically the same as previous tutorials. Just pull the file from github and place in models.
clientauthrequest
We have seen how the username/hashpassword are passed into the controller during instantiation. Now let’s look at the controller:
var NodeChatController = {
init: function(options) {
var mySocket, user, view, hashpassword;
this.socket = new io.Socket(null, {port: 8001});
mySocket = this.socket;
user = this.userName = options.userName;
hashpassword = this.hashpassword = options.hashpassword
this.model = new models.NodeChatModel();
this.view = new NodeChatView({model: this.model, socket: this.socket, el: $('#content')});
view = this.view;
this.socket.on('connect', function () {
mySocket.send({
event: 'clientauthrequest'
, user: user
, hashpassword: hashpassword
});
log('Connected! Oh hai!');
});
this.socket.on('message', function(msg) {view.msgReceived(msg)});
this.socket.connect();
this.view.render();
return this;
}
};
The controller stores the username and hashpassword and sets up an on connect event handler to send them back to the server as a “clientauthrequest” message.
Which brings us to Purgatory.
Purgatory
Previously, our session based authentication went something like:
- [server] User submits username/password to login form.
- [server] Check username/password by calculating the hash and comparing the information to what has been stored.
- [server] If it matches, store a user object in the session.
- [server] Redirect to ‘/’ and load the client-side code.
- [client] The client fires up and connects back to the server.
- [server] On incoming connection, the server checks the session for a valid user object and allows the connection.
- [server] On incoming messages, the server checks the session for a valid user object and allows the message.
The process relies on the socket transport having access to the session cookie and that is what makes it unreliable.
The new, session-less process is:
- [server] User submits username/password to login form.
- [server] Check username/password by calculating the hash and comparing the information to what has been stored.
- [server] If it matches, send the rendered index.jade template to the client with the username/hashpassword as part of the rendered html.
- [client] The client fires up and connects back to the server.
- [server] The server places client connection in purgatory.
- [client] The client sends a “clientauthrequest” message back to the server.
- [server] On incoming message, the server checks to see if the client is in purgatory. It is, so the credentials are verified.
- [server] The authentication passes, so the client connection is removed from purgatory.
- [server] On future incoming messages, the messages are known to be from an authenticated client and are allowed to pass.
Core.js
Let’s look at the pieces that make this happen in core.js:
routes
app.get('/', function (req, res) {
res.render('login');
});
app.post('/', function (req, res) {
signInAccount(req, res)
});
We only need to handle two routes: GET ‘/’ and POST ‘/’. For get, render login.jade. For POST, call signInAccount().
signInAccount
function signInAccount(req, res) {
if (req.body.email) {
auth.createNewUserAccount(req.body.username, req.body.password, req.body.email, function (err, user) {
if ((err) || (!user)) {
es.redirect('back');
}
else if (user) {
res.render('index', {
locals: { name: user.name, hashpassword: JSON.stringify(user.hashpassword) }
});
}
});
}
else {
auth.authenticateUser(req.body.username, req.body.password, function (err, user) {
if (err) {
winston.error('[signInAccount][authenticateUser][fn] Error: ' + err);
}
if (user) {
res.render('index', {
locals: { name: user.name, hashpassword: JSON.stringify(user.hashPass) }
});
}
else {
res.redirect('back');
}
});
}
}
signInAccount() determines whether we are logging in, or creating a new account by checking the request object for an email form field. Upon successful authentication or account creation, the username and hashed password are passed to express in the local variables collection and rendered into the index template.
socket event handler
socket.on('connection', function (client) {
var clientPurgatory = purgatory();
client.on('message', function(message) {
if (clientPurgatory.stillInPurgatory()) {
if(message.event === 'clientauthrequest') {
//If we can get out of purgatory, set up the client for pubsub
clientPurgatory.tryToGetOut(message, client, function () {
activeClients += 1;
winston.info('clients: ' + activeClients);
socket.broadcast({
event: 'update'
, data: activeClients
});
client.on('disconnect', function () {
clientDisconnect(client);
});
});
}
}
else {
//If this is called, we are not in purgatory, so handle it normally
chatMessage(client, socket, message);
}
});
});
The connection event handler creates a closure with a new instance of the purgatory() function. It then sets up an anonymous function to handle client message events. Each time a message is received from this client, stillInPurgatory() is called. If it returns true, check to see if a clientauthrequest message was received and call tryToGetOut(). We pass tryToGetOut() a callback to call if the client successfully gets out of purgatory.
If stillInPurgatory() returns false, then handle the message as a regular chat message.
purgatory
Note: if you don’t understand Javascript closures, read up a bit on them.
function purgatory() {
var inPurgatory = true;
return {
tryToGetOut: function (message, client, cb) {
if (!message || !message.user || !message.hashpassword) {
winston.info('[purgatory][tryToGetOut] Client with no user/hash attempting message. Client still in purgatory');
return;
}
auth.authenticateUserByHash(message.user, message.hashpassword, function(err, data) {
if (err) {
winston.info('[purgatory] Bad auth. Client still in purgatory');
inPurgatory = true;
}
else {
winston.info('[purgatory] out of purgatory');
inPurgatory = false;
//Once we are sure the client is who s/he claims to be, attach name and hash for future use.
client.user = message.user;
client.hashpassword = message.hashpassword;
cb && cb();
}
});
}
, stillInPurgatory: function() {
winston.info('[purgatory] status ' + inPurgatory);
return inPurgatory;
}
}
}
The purgatory function returns a function that generates a closure for each client connection in order to keep track of whether the client has escaped purgatory or not. Subsequent calls to tryToGetOut() and stillInPurgatory() can reference the inPurgatory variable generated by the closure.
tryToGetOut() looks for a username and hashed password in the message. If it is found, it attempts to authenticate them. If authentication passes, the inPurgatory variable is set to false and the passed in callback is fired.
stillInPurgatory() returns the inPurgatory variable.
Wrapping it up
New clients are sent to purgatory. They stay in purgatory until they earn their release by successfully authenticating. Using the purgatory strategy gains us better compatibility with socket.io’s various transports and allows us to quit worrying about cookies and session expiration. Production nodechat has been running this code for the last week and I have already seen a big decrease in connection/reconnection issues.
You can get the code for this tutorial at the v0.3 tag at github. If you want to see how this is used in a more fully featured application, check out the production nodechat.js code-base on github.
If you have questions, comments, or suggestions, you can get a hold of me on twitter @jdslatts, at comments@fzysqr.com, or by stopping in at nodechat.no.de.
-jslatts