Node.js authentication with in-memory session

Sessions provide one way of authenticating users. Session data is stored on the server while the session id is placed in a cookie. With this, each subsequent request has the identifier of the session, and the server will know that it can make private content available for the user.

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.