Simple JWT User Authentication With Neo4j and React

Of Note: This article assumes basic understanding of using the neo4j python driver and some knowledge on Neo4J.

Authentication with JWTs is a pretty common practice; however, when using a Tornado API or a Neo4J backend there isn’t too much out there on how to go about it, or how to implement it. Here, we go through our strategy on how to do just that using our project on explainable AI as a template.

First, some useful links:
Github with the full codebase for the curious: https://github.com/coshx/antares
Neo4J Python driver: https://github.com/neo4j/neo4j-python-driver
Neo4J cypher: https://neo4j.com/docs/cypher-refcard/current/

Authentication is usually an out of the box solution since it’s been a problem so many have run into. Why not just build off of shoulders of giants and use and existing tool, like Rails’ Devise gem? I’ve used Devise in many Rails solutions in my time developing without much pain but also not much knowledge what is going on behind the curtains.

As part of Antares, one of our hackathon projects, we wanted to quickly add authentication to a Tornado web server that communicates with Neo4j. While Tornado can set cookies in the browser, we wanted to enable flexible usage of our API server with JSON web tokens.

First let’s describe our goal. We want to allow users to be able to log in and engage in a session with our system. Moreover, we want to make sure it is an authorized user engaging with us to control access to protected parts of our API. Sounds kinda tricky, doesn’t it?

Let’s break it up into steps: Managing new and existing users, creating a JSON Web Token to store on the user’s side, then requiring the JWT on every action and ensuring that said token is valid. This is the basic gameplan, and we can expand it to meet more complicated needs — such as mixing in cookie storage with JWT and expiring sessions to add an extra layer of security.

Creating User Endpoints: The POST endpoint

I decided to create two user endpoints, one for creating new users via a POST and the other for retrieving existing users via a GET request. I placed this in a Tornado handler called registration_handler.py.

These endpoints are pretty simple to start, if you hit the POST endpoint, the system checks the Neo4j database for a user node with the given email, if that email exists, throw an error stating a user with that email is already present, if not, create a new user node with that password. The only slightly tricky thing here is that we don’t want to store a plaintext password. I’ll get into that in a second, but first let’s share some code. Here is the POST endpoint.

     def post(self):
        """Checks if user exists if not, creates new user."""
        body = json.loads(self.request.body.decode('utf-8'))
        email = body['email']
        password = body['password']

        if not user_exists(email):
            utf8_pw = password.encode('utf-8')
            hashed_pw = hashpw(utf8_pw, gensalt()).decode('utf-8')
            with DRIVER.session() as session:
                if session.write_transaction(
                        create_user, email, hashed_pw):
                    #Handle JWT token here
        else:
            self.set_status(400)
            self.finish(json.dumps({
                'error': {
                    'code': 400,
                    'message': "A user with that email already exists!",
                }
            }))

As you can see, the code does what I described above. I’ve deleted out the part handling JWTs, but we will talk about that in its own section.

Storing Passwords in Neo4j

Let’s get to encoding the password now, since decoding the password and checking against the password given is a large part of what the GET endpoint does. To encrypt a password in our Neo4j database and store it as an attribute to the user node we’ve used the bcrypt package. The package can generate its own salt to use within its hashpw() method.

Make sure to import bcrypt:

from bcrypt import hashpw, gensalt

Now we can make sense of the code above:

hashed_pw = hashpw(utf8_pw, gensalt()).decode('utf-8')

Essentially, bcrypt generates a salt variable to encode the password we give it (i.e. utf8_pw) to generate a hashed password. Of note, hashpw() takes a utf-8 value. Once it spits out the encoded password, we change it back to a plain string to keep things consistent across our system.

The GET endpoint

Now let’s tackle the GET endpoint. The purpose of this endpoint is to check to see if a user with the given email exists, and then see if it matches a password retrieved from the database which we must decode. If these conditions are true, we send an ok (and later a JWT), if not we send an error message. Here’s the code below:

     def get(self):
        """Gets exising user and signs them in"""
        email = self.get_query_argument('email')
        password = self.get_query_argument('password')
        error_message = ""
        with DRIVER.session() as session:
            user = session.write_transaction(
                get_user, email)
            hashed_pw = user[0].get("password")

        hashedpw_utf8 = hashed_pw.encode('utf-8')
        pw_utf8 = password.encode('utf-8')
        if not user:
            error_message = "No user with that email and password " \
                "combination exists!"
        elif hashpw(pw_utf8, hashedpw_utf8) == hashedpw_utf8:
            #jwt token code here
        else:
            error_message = "Incorrect password"

        self.set_status(400)
        return self.finish(json.dumps({
            'error': {
                'code': 400,
                'message': error_message,
            }
        }))

Let’s walk through it. We get the email and password from the body of the request. We use the email to retrieve the user node from the database. If there is no user, we throw our first error. If not, we grab the encoded password from the user node and pass it in as the salt for the hashpw() function. If the output is the hashed password, then they match and we can authenticate the user. If not, the password is incorrect and we can throw the correct error back to the user.

JWT Authentication

Let’s talk about what a JWT is. A JSON Web Token (JWT) is a secure way of communicating information in JSON. The token can be signed via use of a secret key, or a public/private key pair. JWTs are nice because they have small overhead and can accomplish much of user authentication in a very light manner. The basic idea is this, once a user successfully logs in, we create a token for them using the secret key on the server. Any request after this, will need that token in the body. When it hits the API, we decode the token and check if the value we originally passed is the same. If so, the action is authorized and we allow the logic to take place.

First we will show how we are handling sending the token to the user, and then we will follow that up by using a decorator to allow easy authentication to certain actions.

Let’s do some setup, we need to have the jwt python package installed and a secret key in our application. We decided to store our jwt_secret in a config.ini file. Remember, never allow this to reach your repo and instead have some sort of instructions for new developers to obtain this key. We can then pass this key as a global variable for all Tornado classes as follows:

  def make_app():
    """Returns application object for Tornado server."""
    config = configparser.ConfigParser()
    config.read('config.ini')
    cookie_secret = config['DEFAULT']['COOKIE_SECRET_KEY']
    jwt_secret = config['DEFAULT']['JWT_SECRET_KEY']
    return tornado.web.Application([
        (r"/", MainHandler),
        (r"/csv", CSVHandler),
        (r"/tree/([^/]+)", TreeHandler),
        (r"/registration", RegistrationHandler)],
                                   autoreload=True,
                                   cookie_secret=cookie_secret,
                                   jwt_secret=jwt_secret)


if __name__ == "__main__":
    APP = make_app()
    APP.listen(8888)
    tornado.ioloop.IOLoop.current().start()

The commented out code above was hiding what we are doing with the tokens. It looks as follows:

     session_token = jwt.encode({'email': email},
                     self.settings['jwt_secret'],
                     algorithm='HS256')
     self.write(json.dumps({
                    "session_token": session_token.decode('utf-8')
               }))

We can get the secret we passed into tornado using self.settings. This allows us to encode our user’s email into a JWT. We then pass this back to the user to store within the state of the front-end React (this will be the last part of our tutorial). We then expect this token to be in any request hitting other parts of our API. Once we retrieve the token from the request we want to decode it, ensure it matches the user’s email, and if it does, allow him to perform an action. This logic is below:

    def require_auth(self, *args, **kwargs):
        token = self.request.headers.get_list('Authorization').pop()
        if token:
            default_error_msg = "Invalid header authorization"
            parts = token.split()
            if parts[0].lower() != 'bearer':
                throw_authorization_error(self, default_error_msg)
            elif len(parts) == 1:
                throw_authorization_error(self, default_error_msg)
            elif len(parts) > 2:
                throw_authorization_error(self, default_error_msg)

            jwt_token = parts[1]
            try:
                jwt_secret = self.settings['jwt_secret']
                decoded_token = jwt.decode(jwt_token, jwt_secret)
                if user_exists(decoded_token['email']):
                    return True
                else:
                    throw_authorization_error(self, default_error_msg)

            except jwt.exceptions.DecodeError as error:
                error_msg = f'Invalid header authorization: {str(error)}'
                throw_authorization_error(self, error_msg)

        else:
            throw_authorization_error(self, default_error_msg)

   def throw_authorization_error(handler, error_msg):
       """Responds to invalid request with error"""
       handler.set_status(401)
       handler.finish(json.dumps({
        'error': {
            'code': 401,
            'message': error_msg,
        }
       }))
      return False

It is convention to send a jwt with a bearer prefix, so we do some checks to ensure the format matches, if not, throwing errors along the way. If the format looks good we decode the token to retrieve the email and then query our database to ensure a user exists with that email.

Of course, we don’t want to be repeating this code every single time we want to perform auth before grating API usage, so instead we can use the decorator pattern to allow us to quickly apply auth to an action, which you can read about here.

And that’s it (for our back end…), we have an api that has the ability to sign a user in, authenticate it, and allow any further action to always ensure authentication.

The Front End

Your front end could vary widely; however, if you used React we can talk about strategies of storing the JWT and passing back and forth in the following blog post.

That’s it! It’s been a long journey but we have some basic authentication in a lightweight app using neo4j as our backend, tornado as our web server, and react as our front end.

If you have any questions, queries, quandaries or concerns feel free to email me at [email protected]. Moreover, if you think any of the code can be improved also feel free to email me, as I am always on a mission to improve my code.

Cheers!