This is part 3 of a series of posts describing a proof-of-concept web app that implements cryptographic authentication using Node.js with a MongoDB back-end. Part 1 described the login process. Part 2 described the registration process. This Part 3 is concerned with login session maintenance in a broader scope than cryptographic authentication. Part 4, concerned with random bit generation, is now available. The proof-of-concept app, called app-mongodb.js, can be found in a zip file downloadable from the cryptographic authentication page.
Update. The name of the constant securityStrength has been changed to rbgSecurityStrength as noted in the last post of the series and reflected in one of the snippets below.
At first glance it may seem that there is no need for login session maintenance in a web app that implements cryptographic authentication with a key pair. Every HTTP request can be authenticated on its own without linking it to a session, by sending the public key to the back-end and proving possession of the private key, as in the login process described in Part 1. That login process relied on the user supplying the username in order to locate the user record, but this is not essential, since the user record could be located in the database by searching for the public key, which is unique with overwhelming probability.
But login sessions provide important login/logout functionality, allowing the user to choose whether to authenticate or not. A member of a site accessible to both members and non-members, for example, may choose to visit the site without authenticating in order to see what information is made available by the site to non-members. Also, the proof of possession of the private key has a latency cost for the user due to the need to retrieve the challenge from the server, and a computational cost for the server and the browser. These costs are insignificant if incurred once per session, but may not be insignificant if incurred for every HTTP request.
The app discussed in this series, app-mongodb.js, implements login sessions in the traditional way using session cookies. Having said that I could stop here. But the Express framework used in the app provides interesting ways of implementing traditional login sessions, which are worth discussing.
Login session maintenance involves session creation and subsequent authentication by linking a cookie to a session. Session creation in Node.js is relatively straightforward. Authentication with a session cookie using Express is where it gets interesting.
Login session creation
A login session is created in app-mongodb.js after a successful login or a successful registration. Session creation after login is shown in the third code snippet of Part 1, which is shown again below for ease of reference.
app.post('/verify-signature',function(req,res) { var username = req.body.username; var pubKeyHex = req.body.publicKey; var sigRHex = req.body.signatureR; var sigSHex = req.body.signatureS; var destination = req.body.destination; if ( username.search(/^[A-Za-z0-9]+$/) == -1 || pubKeyHex.search(/^[A-Fa-f0-9]+$/) == -1 || sigRHex.search(/^[A-Fa-f0-9]+$/) == -1 || sigSHex.search(/^[A-Fa-f0-9]+$/) == -1 || destination.search(/^[-A-Za-z0-9]+$/) == -1 ) { res.redirect(303, "/authentication-failure.html"); return; } User.findOne({username: username}).select('loginTimeStamp loginChallenge publicKey').exec(function(err, user) { if (err) throw new Error(err); if (!user) { res.redirect(303, "/authentication-failure.html"); return; } var now = (new Date()).getTime(); if (!user.loginTimeStamp || (now - user.loginTimeStamp > loginRedirTimeout)) { res.redirect(303, "/authentication-failure.html"); return; } var challengeHex = user.loginChallenge; if (!challengeHex) { res.redirect(303, "/authentication-failure.html"); return; } if (pubKeyHex != user.publicKey) { res.redirect(303, "/authentication-failure.html"); return; } var p = pjclHex2BigInt(pHex); var q = pjclHex2BigInt(qHex); var g = pjclHex2BigInt(gHex); var y = pjclHex2BigInt(pubKeyHex); var msg = pjclHex2BitArray(user.loginChallenge); var r = pjclHex2BigInt(sigRHex); var s = pjclHex2BigInt(sigSHex); if (!pjclDSAVerifyMsg(p,q,g,y,msg,r,s)) { res.redirect(303, "/invalid-credential.html"); return; } user.loginChallenge = ""; user.save(function (err) { if (err) throw new Error(err); var sessionId = pjclBitArray2Hex(pjclRBGGen(rbgStateObject,rbgSecurityStrength,rbgSecurityStrength)); var session = new Session({ sessionId: sessionId, username: username, timeStamp: new Date().getTime() }); session.save(function (err) { if (err) throw new Error(err); res.cookie('session', sessionId, {httpOnly: true, secure: true}); res.redirect(303, `/${destination}.html`); }); }); }); });
The snippet shows how the back-end handles the POST request to the /verify-signature endpoint, which conveys the signature on the login challenge that proves possession of the private key. You may recall that the line-1 callback validates the inputs, the line-17 callback verifies the signature, and the line-49 callback creates the login session and sets the session cookie. Part 1 postponed a detailed explanation of the line-49 callback. Here are the details.
Line 50 throws an exception if a database error occurs as the line-17 callback saves the user record, updated to remove the login challenge.
Line 51 generates a random session ID using a server-side random bit generator. Client and server side random bit generation will be discussed in the next and final post of the series.
Lines 52–56 create an instance of the Mongoose model “Session” with properties sessionId, username and loginTimeStamp. Saving this model instance to the database will create a session record (or session document in MongoDB terminology) where the properties of the model instance will become record fields.
Lines 57–61 save the model instance to the database and define a callback that is invoked when the save operation has been processed. If no database error occurs, the callback sets a cookie named “session” whose value is the session ID, then redirects to the destination page. (The destination page is the page where the user intended to go when he or she was asked to log in on the fly, or the welcome page /private-page-1.html if the user logged in using the page-top login form.)
Authentication with a session cookie
The back-end needs to check if the user is logged in when it receives an HTTP GET request that targets one of the three private pages, in order to decide whether to respond with the page, showing the name of the logged in user, or whether to redirect to the on-the-fly login page. It also needs to check if the user is logged in when it receives an HTTP GET request that targets one of the three public pages, in order to display the page-top login form if the user is not logged in, of the name of the user and a logout link if the user is logged in.
The procedure for checking if the user is logged in is the same for all six public and private pages: check if there is a session cookie; if so obtain the session ID from the cookie; query the database to find the second record containing the session ID; retrieve the username from the session record; query the database to find the user record containing the username; and retrieve the name of the user.
On a server such as Apache that forks a separate process for handling each request, the procedure for checking if a user is logged in would block for each of the two queries, and would eventually return the name of the user if logged in, or an indication that the user is not logged in otherwise. But on a server such as Node.js that can handle multiple requests in parallel within one thread, blocking is out of the question. Callback functions could be used to wait for the completion of the queries. But then the procedure could not return any results, since it would finish without waiting for the queries to be executed on the database; it would instead take a callback as an argument, and pass its results as arguments to the callback. The callback would then be in charge of responding to the browser with the requested page or a redirection. We have verified that this works well, but Express provides a better way, using Express middleware.
Using Express middleware for authentication with a session cookie
Here is a simplified explanation of the concept of middleware in Express.
Express creates an object app that has methods corresponding to HTTP verbs, such as app.get and app.post. These methods take as arguments a URL path specification and a callback. The path specification may match a single path, such as /verify-signature or /private-page-1.html, or multiple paths specified using a wild card or a regular expression. Invoking such a method creates an association between the path specification and the callback, which is called a route. The app object also has a method app.use that registers a callback. A callback registered by app.use is called “a middleware”.
When the application starts, invocations of app.get, app.post and app.use create an ordered list of routes and middleware registrations. Callback arguments of app methods take as inputs a request object, a response object and a function, conventionally referenced by callback parameters req, res and next, the latter parameter being usually omitted if not used. When an HTTP request with a verb such as GET or POST is received at an endpoint, Express goes down the list of routes and middleware registrations, until it finds a route for the verb that matches the endpoint or a middleware registration. It then calls the corresponding route or middleware callback. If the callback calls next(), Express continues down the list, otherwise it is done with the HTTP request.
The procedure for checking if a user is logged in can be implemented as a middleware checkIfLoggedIn, as shown in the following snippet.
function checkIfLoggedIn(req,res,next) { var sessionId = req.cookies.session; if ( !sessionId || sessionId.search(/^[A-Fa-f0-9]+$/) == -1 ) { res.locals.loggedIn = false; next(); return; } Session.findOne({sessionId: sessionId}).select('timeStamp username').exec(function(err, session) { if (err) throw new Error(err); if (!session) { res.locals.loggedIn = false; next(); return; } var now = (new Date()).getTime(); if (!session.timeStamp || (now - session.timeStamp > sessionTimeout)) { res.locals.loggedIn = false; next(); return; } User.findOne({username: session.username}).select('name').exec(function(err, user) { if (err) throw new Error(err); if (!user) { res.locals.loggedIn = false; next(); return; } res.locals.loggedIn = true; res.locals.fullName = `${user.name.firstname} ${user.name.lastname}`; next(); }); }); }; app.use(checkIfLoggedIn); var publicPageNames = [ "public-page-1", "public-page-2", "public-page-3" ]; publicPageNames.forEach(function(pageName) { app.get(`/${pageName}.html`,function(req,res) { res.render(`${pageName}.handlebars`); }); }); var privatePageNames = [ "private-page-1", "private-page-2", "private-page-3" ]; privatePageNames.forEach(function(pageName) { app.get(`/${pageName}.html`,function(req,res) { if (res.locals.loggedIn) { res.render(`${pageName}.handlebars`); } else { res.redirect(303,`/please-log-in.html?destination=${pageName}`); } }); }); app.use(function(req,res) { res.status(404).send('NOT FOUND'); }); app.use(function(err,req,res,next) { console.log("Error: " + err.stack); res.status(500).send('INTERNAL ERROR'); });
The function checkIfLoggedIn is defined at lines 1–36 of the snippet and registered as middleware by the invocation of app.use at line 38, before routes are created for the public pages by invocations of app.get in the loop at lines 45–49 and for the private pages by invocations of app.get in the loop at lines 56–65. Thus when a GET request for a page is received, the middleware checkIfLoggedIn will be called before before the route callback for the page.
When checkIfLoggedIn is called, it is passed as arguments the request and response objects and the next function. At line 2, it checks if the cookies property of the request object (created by cookie parser middleware, not shown in the snippet) has a session property indicating that a cookie named session has been received. If so, sessionId is assigned the value of the cookie. Otherwise sessionId has the value undefined, which type-converts to false in a conditional.
The condition at lines 4–5 is true if no session cookie has been received, or a session cookie has been received whose value has characters other than letters and digits. If the condition is true, line 7 adds a property loggedIn with value false to the object res.locals, then line 8 calls next(). As we shall see, the properties of res.locals are visible not only to subsequent route callbacks, but also to Handlebars views rendered by those subsequent callbacks, including so-called helpers and partials used in those views. (Lines 8–9 are frequently combined in middleware software into a single line “return next()”, but we avoid that idiom, which wrongly suggests that next() returns a value.)
If a session cookie with a well-formed value has been received, a database query is issued at line 11, looking for a session record containing the session ID found in the cookie and asking for the timeStamp and username fields of the record; and the callback defined at lines 11–35 is bound to the query.
An exception is thrown at line 12 if a database error occurs. As noted in part one, such an exception is caught by Express and the error object of the exception is passed to a special middleware that takes four arguments instead of three. The registration of that middleware is shown in the snippet at lines 71–74. When invoked, it sends an HTTP response with status 500 to the browser.
If there is no database error, the line-11 callback creates a loggedIn property of res.locals with value false, at line 14 if no session record has been found, or at line 20 if a session record has been found but is older than a session timeout. Otherwise it issues a second query at line 24, this time looking for a user record containing the username found in the session record, asking for the name field of the record (whose value is an object containing the first and last names), and binding the callback defined at lines 24–34 to the query.
The line-24 callback throws an exception if a database error is encountered. Otherwise, if no user record is found, a loggedIn property with value false is added to the res.locals object at line 27. Finally, if everything is OK, two properties are added to res.locals at lines 31–32: a property loggedIn with value true and a property fullname whose value consists of the first and last names found in the name field of the user record.
A Handlebars shortcut from middleware to views
Implementing checkIfLoggedIn as Express middleware facilitates authentication with a session cookie by conveying the results of the authentication procedure to the callbacks that handle page requests as properties of res.locals. The Handlebars templating engine, used in Node.js by means of the express-handlebars Node.js module, further facilitates authentication with a session cookie by allowing properties of res.locals to be used directly in views, partials and helpers without relying on res.render to convey their values to views.
To illustrate this, the following snippet shows the view public-page-1.handlebars that is used to render the first public page (on the server, before downloading the page to the browser).
{{#if loggedIn}} {{> logged-in-as}} {{else}} {{> pagetop-login-form}} {{/if}} {{> preamble}} <h1>Public Page 1</h1> <small> <p>Public Page 1 is the home page. The user can navigate to other pages, as exemplified by the following menu. </p> <p><strong>On-the-fly login.</strong> <i>If the user tries to go to a private page while not logged in, the user is asked to log in, then redirected to the desired private page if the login is successful.</i> </p> </small> <h3>Menu</h3> <p>You are in Public Page 1.</p> <p><a href="/public-page-2.html">Go to Public Page 2</a>.</p> <p><a href="/public-page-3.html">Go to Public Page 3</a>.</p> <p><a href="/private-page-1.html">Go to Private Page 1</a>.</p> <p><a href="/private-page-2.html">Go to Private Page 2</a>.</p> <p><a href="/private-page-3.html">Go to Private Page 3</a>.</p>
Public pages have a header showing the name of the user and a logout link if the user is logged in, or the page-top login form if the user is not logged in. The header is rendered by lines 1–5 of the snippet, where {{#if …}}, {{else}} and {{/if}} are called helpers, and {{> logged-in-as}} and {{> pagetop-login-form}} are references to partial views, or partials.
In line 1, the view variable loggedIn takes the value true or false assigned to res.locals.loggedIn by the checkIfLoggedIn middleware. If the user is logged in, the header is the result of rendering the partial logged-in-as.handlebars shown below by replacing {{fullName}} with the value of res.locals.fullName set by the checkIfLoggedIn middleware. If the user is not logged in, on the other hand, the header is the partial pagetop-login-form.handlebars where there is no view variable to be replaced.
You are logged in as {{fullName}} <br> <a href="/logout">Log out</a>