Node.js authentication with in-memory session
I’ll continue the code where I left off in the last post. It might be a good idea to skim that post before you continue with this one.
1. Authentication
It’s great that we have encrypted and saved the password to the database but subsequent request should also be aware of this. We should somehow let the next requests (usually from the browser, here from curl) know that the user has entered the correct username and password and they can now access private contents.
This can happen through a session or a token. Let’s start with sessions first.
2. In-memory session
When the authentication is successful, the server can open a session and can store a session id in the cookie and save the session data on the server. This way, when the next request comes to the server, it can check if the session is valid (not expired), and if so, the request can have an OK response. If the session is not valid (or there’s no session), the request to the private content will be denied.
2.1. express-session
The express-session
library can manage all of these for us. It works as middleware with some pre-defined and custom options.
First, let’s install and require
it in server.js
:
npm i express-session
and
// other packages required here...
const session = require('express-session')
// initiate app for express
const sessionOptions = {
secret: 'my-super-secret-imported',
saveUninitialized: false,
cookie: {
maxAge: 3600000,
},
}
if (app.get('env') === 'production') {
sessionOptions.cookie.secure = true
}
app.use(session(sessionOptions))
session
comes with some options. It should have a secret
, which is usually a long secret string or UUID stored in a secure place or as an environment variable.
saveUnitialized
forces new, unmodified sessions to be saved. It’s a good idea to set it to false
, because it helps prevent race conditions when the client makes multiple parallel requests without a session.
maxAge
refers to the time duration the session is valid (it’s one hour here). By default, no expiration is set.
The secure
property in the cookie
restricts the connections to HTTPS. If it’s set to true
and request doesn’t go through HTTPS, the cookie won’t be set. In this example, we don’t bother setting up a secure connection when developing the application, so we check if the environment is production
. If it’s not, we don’t need the secure connection.
2.2. authorize middleware
Now that we have the session middleware, we can create another middleware called authorize
, which checks for the existence of the session.
It can look like this:
const authorize = (req, res, next) => {
if (!req.session.user) {
return next(new Error('Please log in'))
}
next()
}
Here we check the user
property of the session
object. If it doesn’t exist, we’ll throw an error, and send it to the error handling middleware. If it exists, we’ll simply continue with the next step by calling next
.
The user
property will be written to session
when the authentication is successful and the session is created.
2.3. Add code to /register and /login
When the user registers, we want to redirect them to the /login
page, so that they can use their newly created credentials:
app.post('/register', async (req, res, next) => {
// code here...
console.log('User data have been saved')
res.redirect('/login')
})
Express has a method called redirect
, which does the job of redirecting the client to the login page.
We can make modifications in the handler of the /login
endpoint as follows:
app.post('/login', async (req, res, next) => {
// code here...
console.log('Authentication successful')
req.session.user = {
username: user,
}
res.redirect('/private')
})
This is the point where the user
property in the session
object referred to above in the authorize
middleware gets created. The handler ends with a redirection to the /private
endpoint.
2.4. The /private and /logout endpoints
The /private
endpoint doesn’t exist, so let’s create it quickly:
app.get('/private', authorize, (req, res, next) => {
console.log(req.session.id)
res.send('Private endpoint hit')
})
We can log the session id which is stored in the session
object and it should be there after a successful authentication.
When the user decides to log out, we destroy the session and redirect the user to the public endpoint (/
):
app.get('/logout', authorize, (req, res, next) => {
req.session.destroy()
console.log('Logging out')
res.redirect('/')
})
The code so far looks like this:
const express = require('express')
const { MongoClient } = require('mongodb')
const bodyParser = require('body-parser')
const session = require('express-session')
const bcrypt = require('bcryptjs')
const app = express()
const PORT = 3000
const MONGO_URL = 'mongodb://localhost:27017'
app.use(bodyParser.json())
const sessionOptions = {
secret: 'my-super-secret',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 3600000,
},
}
if (app.get('env') === 'production') {
sessionOptions.cookie.secure = true
}
app.use(session(sessionOptions))
const authorize = (req, res, next) => {
if (!req.session.user) {
return next(new Error('Please log in'))
}
next()
}
async function main() {
let client
try {
client = await MongoClient.connect(MONGO_URL)
console.log('Connected to database')
} catch (e) {
throw new Error('Error while connecting to database')
}
const db = client.db('authentication-with-node')
// register endpoint
app.post('/register', async (req, res, next) => {
const username = req.body.username
const password = req.body.password
if (!username || !password) {
return next(new Error('No username or password'))
}
if (password.length < 10) {
return next(new Error('Password must consist of at least 10 characters'))
}
const isExistingUser = await db.collection('users').find({ username })
if (isExistingUser) {
return next(new Error('User already registered'))
}
try {
const hashedPassword = await bcrypt.hash(password, 10)
await db.collection('users').insertOne({
username,
password: hashedPassword,
})
console.log('User data have been saved')
res.redirect('/login')
} catch (e) {
return next(new Error('Unknown error'))
}
})
// public endpoint
app.get('/', (req, res) => {
res.send('Public endpoint')
})
// login endpoint
app.post('/login', async (req, res, next) => {
const { username, password } = req.body
if (!(username && password)) {
return next(new Error('Missing username or password'))
}
const { username: user, password: hashedPassword } = await db.collection('users').findOne({ username })
if (username !== user) {
return next(new Error('Incorrect username/password'))
}
const isMatchingPassword = await bcrypt.compare(password, hashedPassword)
if (!isMatchingPassword) {
return next(new Error("Incorrect username/password"))
}
console.log('Authentication successful')
req.session.user = {
username: user,
}
res.redirect('/private')
})
// private endpoint
app.get('/private', authorize, (req, res, next) => {
console.log(req.session.id)
res.send('Private endpoint hit')
})
app.get('/logout', authorize, (req, res, next) => {
req.session.destroy()
console.log('Logging out')
res.redirect('/')
})
// error handling
app.use((err, req, res, next) => {
res.status(err.httpStatusCode || 400).json({ message: err.message })
})
app.listen(PORT, () => {
console.log(`Server is listening on ${ PORT }`)
})
}
main().catch((e) => {
console.log(e)
})
2.5. Try it out
There’s usually a client side application which makes these requests to the server.
I’ll use curl to hit the endpoints.
In the last post we successfully registered James Bond, so we can try to log him in now.
Before that, make sure that the database is up by running the following command from the project root:
docker-compose up -d
Also, we’ll need to start the server:
node server.js
We can now try to log in with Bond’s credentials in a third terminal window:
curl -X POST -H 'Content-Type: application/json' --cookie-jar cookie.txt --cookie cookie.txt \\
-d '{"username": "james.bond", "password": "mysupersecretpassword"}' localhost:3000/login
Here we save the cookie in a file called cookie.txt
, and we use it for the session.
The content of cookie.txt
looks like this:
# Netscape HTTP Cookie File
# https://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1572648432 connect.sid s%3ARfGVoHdK7d1dITdAcWLywzw6LU9Xm5Xu.miFO%2BXRFgm9Zo%2FNP58QZ9pI42teYWfoUgjg2hV5VR0o
The connect.sid
refers to the session id, which, as it was stated above, is stored in the cookie.
The above command should return the Authentication successful
and Redirecting to /private
messages (in two different terminal windows).
The redirection indicates that we should now be able to access the private content:
curl --cookie-jar cookie.txt --cookie cookie.txt localhost:3000/private
The response should be Private endpoint hit
. If we try to access the /private
endpoint without the cookie, we’ll get an error (Please log in
). The same happens with the /logout
endpoint. This is because the authorize
middleware is applied to both handlers.
Let’s log out:
curl --cookie-jar cookie.txt --cookie cookie.txt localhost:3000/logout
Because the session is destroyed upon logout, James won’t be able to access the private endpoint any more:
curl localhost:3000/private
This will return the following message:
{ "message": "Please log in" }
This is the authorize
middleware working and checking if the user
property exists in the session
. It doesn’t, because there’s no session (it has already been destroyed).
2.6. Considerations
This method, although it works, is not recommended because it uses in-memory store.
Storing session data in memory can lead to memory leaks, which is not desirable. Also, when the server is restarted, the session will be gone.
This means that it’s better to persist session data, and this will be the topic of the next post.
3. Summary
Session-based authentication is one way of making sure that the user has successfully identified themselves and they can now access protected content.
express-session
is a popular library that stores session data in the memory. It works as middleware and by creating a custom authorizer we can
check if the client session is valid.
Thanks for reading and see you next time.