Vanilla GraphQL With NodeJS And PostgreSQL: Session & Authorization

Reading Time: 8 minutes

Hello, everyone! 2021 finally came and I hope you all had a wonderful holiday (it’s weird to say that during this crazy time, I know). In the first post of 2021, let’s continue what we left off of this mini-series. In the previous post, we have gone through a long long journey to rewrite our app to use PostgreSQL. Today, we will get introduced to session management and also have some fun with authorization (i.e. user roles).

For those who missed out my previous posts, or want to jump to the next ones, please use the links below:

  1. Setting Up Application
  2. Refactoring
  3. Adding Database
  4. Session & Authorization (this post)
  5. Rewrite Schema Definition (TBA)

Now, let’s get started!

Preparation

As usual, if you came from my previous code, then you likely have everything set up. In case you didn’t, you can clone my Github repository and check out the right branch as follows:

git clone https://github.com/ChunML/vanilla-graphql
cd vanilla-graphql
git checkout 3-adding-database
yarn

After that, let’s use docker-compose to fire up PostgreSQL as well as adminer. For more detailed instructions, please check my previous post: Adding Database.

docker-compose up -d

Finally, let’s start our application server:

yarn start

If nodemon complained about not being able to find index.js, just hit Ctrl+C and run yarn start again, it should work properly. All right, let’s rock and roll.

Adding User Roles

Let’s start off with an easy task: adding user roles. We will need it for authorization later on in this post. By the way, if you’re still confused between authentication and authorization (like I used to), here’s a quick tip to remember:

  • authentication: to verify whether someone is a user or not (happens when you log in)
  • authorization: to determine whether someone has the privilege to access a specific resource

For simplicity’s sake, there are some privileges we don’t want to give out to random users but just a few possess (we call them administrators), such as accessing other user’s information, deactivate other users, etc. So, what we need is to specify whether a use is an administrator or not.

So, we will need to alter the users table. Let’s add a column of type boolean named is_admin with a default value of false. Open ./src/index.ts and modify the SQL query to create users table as follows:

  await db.runQuery(
    `CREATE TABLE IF NOT EXISTS users (
    id serial PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    is_admin BOOLEAN DEFAULT FALSE,          // new
    created_at TIMESTAMP NOT NULL,
    last_login TIMESTAMP NOT NULL
  )`,
    []
  );

We also need to add the new attribute to the UserEntity class.

/* eslint-disable camelcase */
export default class UserEntity {
  id: string;

  username: string;

  password: string;

  created_at: Date;

  last_login: Date;

  is_admin: boolean;    // new
}

Save both files then head to http://localhost:8000 to access adminer. From there, let’s delete users table (we could just alter the existing table, but that would be kind of tedious so let’s just re-create the whole table).

Fig 1: delete old users table

Then, we have to restart the server. After that, we will see the new users table with is_admin column:

Fig 2: users table with is_admin column

Okay, great. Let’s add two new users with register resolver. Let’s call then normalUser and adminUser, respectively.

Fig 3: create normalUser
Fig 4: create adminUser

Next, let’s head to adminer to change adminUser‘s is_admin to TRUE:

Fig 5: set is_admin for adminUser

Let’s confirm the change we just made real quick:

Fig 6: adminUser is admin

Cool! Let’s move on to the next step.

Installing Dependencies

First thing first, we need to install a few dependencies. What we need is express-session, which helps us manage sessions (more details in a second). We also need to store the session information somewhere and one of the best choices is Redis. So, the packages that we want to install is as follows:

yarn add redis express-session connect-redis
yarn add -D @types/connect-redis @types/redis

We also need to start a Redis server and we will use docker-compose for that too. Let’s stop the currently running containers:

docker-compose down

Then modify docker-compose.yml as follows. I love to call Redis server cache, since that’s what it’s famous for.

version: "3.7"

services:
  db:
    ...

  cache:                        // new
    image: redis:6.0-alpine     // new
    depends_on:                 // new
      - db                      // new
    ports:                      // new
      - 6380:6379               // new

  adminer:
    ...

volumes:
  postgres_data:

We can now fire everything up again:

docker-compose up -d

Now, we can verify if our Redis server is working properly by monitoring it like below:

docker-compose exec cache redis-cli monitor

If you see OK get printed out, it means that Redis server is listening. Great! Let’s leave that terminal window for now (don’t hit Ctrl+C). Later on, when we work with session information, you can see how it’s actually stored and retrieved.

Configuring RedisStore & express-session

Now, it’s time to put things together. First, we need to import from the packages that we just installed:

import redis from "redis";
import session from "express-session";
import connectRedis from "connect-redis";
...

Then, we will create a Redis client that connects to the Redis server:

const main = async (): Promise<void> => {
  ...
  const redisClient = redis.createClient({
    host: "localhost",
    port: 6380, 
  });       
                
  redisClient.on("connect", () => {
    console.log("Redis server is listening at localhost:6380");
  });
  ...

The server will restart if you save the file. If you see Redis server is listening at localhost:6380, then the client has connected to the Redis server successfully.

Great! We also need to create a store, which is a place for express-session to save the session information. connect-redis will have us create such stores with a Redis client:

const main = async (): Promise<void> => {
  ...
  redisClient.on(...)

  const RedisStore = connectRedis(session);
  const store = new RedisStore({
    client: redisClient,
  });
  ...

Finally, we can configure express-session middleware as below. For more details on how to configure express-session, please visit their page. What I want you to care about is the name of the session, which is being set to userId.

  ...
  const app = express();

  app.use(
    session({
      name: "userId",
      store,
      cookie: {
        maxAge: 1000 * 3600 * 24,   // 1 day
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
      },
      secret: "hahahaha",
      saveUninitialized: false,
      resave: false,
    })
  );
  ...

That’s it. We’re now done with the session configuration.

Storing Session Information

Now what? It’s time to use session. As a quick recap, the only way we know if someone is a valid user is through the action of logging in.

For instance, if someone wants to access some user-only resource, they would have to go through two steps: logging in and then retrieving the resource. Why is it bad? It’s redundant and more importantly, slow since logging in involves SQL query execution.

So, it would be better if the status of a logged-in user got stored somewhere and can be retrieved from the user’s request to the server. That’s the idea of session.

To demonstrate what I just said, let’s see how it’s actually done in reality. Let’s modify the login resolver so that after a user logs in, we will store the id of that user to the request object.

  login: async (input: UserInput, context: MyContext): Promise<User | null> => {
    ...
    await context.db.update(
      "users",
      { last_login: user.lastLogin },
      { username }
    );

    req.session.userId = user.id;   // new, we can store the user's id to request object

    return user;
  },

The piece of code above will not work. Why? Because the req object is coming from nowhere. How can we get the request object inside a resolver function?

Just like the db object, we can pass it through via context.

Let’s open up ./src/index.ts and add req object into context. Remember graphqlHTTP is a middleware? We can be sure that it’s passed a tuple of req and res just like normal middlewares. Here’s how we can get those:

  ...
  app.use(
    "/graphql",
    graphqlHTTP((req, res) => ({
      schema,
      rootValue,
      graphiql: true,
      context: { req, res, db },
    }))
  );
  ...

Okay, we have more stuff in context now. Don’t forget to update MyContext type:

import { Request, Response } from "express";

...

export interface MyContext {
  req: Request;
  res: Response;
  db: DB;
}

But that’s not enough. Since we’re using express-session, the req object now has a session object inside it to store session information. Moreover, we will add userId inside that session object. So the type of req is as follows:

import { Session, SessionData } from "express-session";

...

export interface MyContext {
  req: Request &                                                  // new
    Session &                                                     // new
    Partial<SessionData> & { session: { userId?: string } };      // new
  res: Response;
  db: DB;
}

Now we can go back to the login resolver and update it like this:

  login: async (input: UserInput, context: MyContext): Promise<User | null> => {
    ...

    await context.db.update(
      "users",
      { last_login: user.lastLogin },
      { username }
    );

    context.req.session.userId = user.id;          // new

    return user;
  },

Are you ready, guys? It’s time to check it out. Let’s go to our GraphQL playground and try to log in. I hope that you’re still monitoring the Redis server. If you’re not, let’s open a new terminal window and run:

docker-compose exec cache redis-cli monitor

Okay, let’s log normalUser in:

Fig 7: log normalUser in

The first thing I want you to confirm is the Redis server’s terminal. You will likely see something like this:

1610522844.653300 [0 172.25.0.1:60824] "set" "sess:5Ev6KN2ciKtHagcsXLjxtp5BSVFkb2Xx" "{\"cookie\":{\"originalMaxAge\":86400000,\"expires\":\"2021-01-14T07:27:24.583Z\",\"secure\":false,\"httpOnly\":true,\"path\":\"/\",\"sameSite\":\"lax\"},\"userId\":1}" "EX" "86400"

I know it’s long but basically, the format is: "set" KEY VALUE OTHER_STUFF. Okay, a pair of (key, value) is set to Redis store. How does that matter, then?

Let’s reveal the magic. Open your browser’s dev tools to inspect your browser’s storage. Inside cookies, you will see one cookie named userId being stored:

Fig 8: userId is stored in cookies

Now if you look closely at the cookie’s value, it’s not a random string. It’s 5Ev6KN2ciKtHagcsXLjxtp5BSVFkb2Xx.<things_after_dot>". Do you find it familiar? What’s the key to the value stored in the Redis store? It’s sess:5Ev6KN2ciKtHagcsXLjxtp5BSVFkb2Xx. Yes, they match!

To conclude, here’s how session works:

  1. a user logs in, a (key, value) pair is stored in Redis where value contains that user’s information (i.e. user’s id)
  2. a cookie named userId is set in the browser with its value set to the key above
  3. when the same user sends a request to the server, its userId is analyzed
  4. if userId exists, its value is used to get the value from Redis store
  5. if the key exists in Redis store, user’s id can be retrieved from the value

And that’s pretty much it! Now let’s go ahead and do the same to register resolver, which means that we want to log a user in after they successfully registers:

  register: async (
    input: UserInput,
    context: MyContext
  ): Promise<User | null> => {
    ...
    const user = new User({
      id: row.id,
      username: row.username,
      password: row.password,
      createdAt: row.created_at,
      lastLogin: row.last_login,
    });

    context.req.session.userId = user.id;    // new

    return user;
  },

Let’s test it out, shall we?

Fig 9: create anotherUser

Check the Redis server console, did you see something like this:

1610524331.564376 [0 172.25.0.1:60846] "set" "sess:5Ev6KN2ciKtHagcsXLjxtp5BSVFkb2Xx" "{\"cookie\":{\"originalMaxAge\":86400000,\"expires\":\"2021-01-14T07:52:11.563Z\",\"secure\":false,\"httpOnly\":true,\"path\":\"/\",\"sameSite\":\"lax\"},\"userId\":3}" "EX" "86400"

We can see userId: 3, which proves that we are doing the right thing. Let’s check the browser cookie. Do we see a cookie named userId with the same key with above?

Fig 10: anotherUser cookie

Both are 5Ev6KN2ciKtHagcsXLjxtp5BSVFkb2Xx. They match! Cool!

Basically, we have finished today’s mission. But let’s have some fun. I want to make three more changes to utilize the whole session and authorization:

  1. only logged-in user can run getUser to retrieve their own information
  2. no one except administrators can run getUsers to retrieve all users’ information
  3. add a changeRole resolver to make a normal user an administrator, of course only administrators can call it

They shouldn’t be too hard. Let’s tackle them one by one.

Rewriting getUser

Right now, getUser accept an id argument and return the associated user. We want to change that behavior. The id will now be retrieved from sessions. It means the user must be logged in, otherwise, we will return null.

Retrieving userId from sessions is pretty straight-forward, we can get it directly from req.session.userId. Just make sure to verify its value before passing it down to the query. Our new getUser resolver will look like this:

  ...
  getUser: async (context: MyContext): Promise<User | null> => {
    const id = context.req.session.userId;
    if (!id) {
      return null;
    }

    const result = await context.db.findOne("users", { id });
    if (result.rowCount === 0) {
      return null;
    }
    const user = new User({
      id: result.rows[0].id,
      username: result.rows[0].username,
      password: result.rows[0].password,
      createdAt: result.rows[0].created_at,
      lastLogin: result.rows[0].last_login,
    });
    return user;
  },
  ...

Don’t forget to update the schema as well:

const schema = buildSchema(`
  ...

  type Query {
    getUser: User     // new
    getUsers: [User]
    hello: String
  }

  ...
`);

Now we can test it out:

Fig 11: getUser when user is logged in

To test for the case when a user is not logged in, we must manually delete userId in the browser’s cookies. Then we likely get a null result:

Fig 12: getUser when user is not logged in

Not hard, was it? Let’s move on to the next task.

Rewriting getUsers

Okay, this one should be easy as well. We want this resolver to return results just in case run by an administrator, otherwise, we just return null. In order to do that, we must retrieve userId from sessions and check if it exists. If it does, we need to check if it’s from an admin account.

    const id = context.req.session.userId;
    if (!id) {
      return null;
    }

    const adminResult = await context.db.findOne("users", { id });
    if (!adminResult.rows[0].is_admin) {
      return null;
    }

Then we can write the rest the same way. But as you can see, doing it this way is not good because we have to make two SQL queries. A better way is to just query one time to get all users and then use filter to check if the user is an administrator. So, our new getUsers will look like below:

  ...
  getUsers: async (_args: null, context: MyContext): Promise<User[] | null> => {
    const id = context.req.session.userId;
    if (!id) {
      return null;
    }

    const result = await context.db.findAll("users");

    const isAdmin = result.rows.filter((row) => row.id === id)[0].is_admin;

    if (isAdmin) {
      return result.rows.map(
        (row) =>
          new User({
            id: row.id,
            username: row.username,
            password: row.password,
            createdAt: row.created_at,
            lastLogin: row.last_login,
          })
      );
    }

    return null;
  },
  ...

It’s time to test it out. First, let’s log in with normalUser and see how it goes:

Fig 13: getUsers when normalUser is logged in

Great. Nothing was returned. Now let’s try with adminUser:

Fig 14: getUsers with adminUser

This time, we got all users back. Fantastic! One last mission to go, guys!

Implementing changeRole Resolver

Just like getUsers, we want to make sure that only administrators can execute this resolver. Therefore, if that’s the case, we update some account’s is_admin and return true, otherwise, we’ll do nothing and return false.

First, here’s the signature of our resolver function:

  changeRole: async (
    args: { username: string },
    context: MyContext
  ): Promise<boolean> => {}

Then, we will check if userId exists. If it does, we also check if it’s from an admin account:

    const id = context.req.session.userId;
    if (!id) {
      return false;
    }

    const adminResult = await context.db.findOne("users", { id });
    if (!adminResult.rowCount || !adminResult.rows[0].is_admin) {
      return false;
    }

After verifying that we are logged in as an administrator, we will check if the username that got passed in exists in the database:

    const { username } = args;
    const result = await context.db.findOne("users", { username });
    if (!result.rowCount) {
      return false;
    }

Now, we can change the role of the user and return true:

    await context.db.update("users", { is_admin: true }, { username });

    return true;

So the whole changeRole resolver looks like this:

  changeRole: async (
    args: { username: string },
    context: MyContext
  ): Promise<boolean> => {
    const id = context.req.session.userId;
    if (!id) {
      return false;
    }

    const adminResult = await context.db.findOne("users", { id });
    if (!adminResult.rowCount || !adminResult.rows[0].is_admin) {
      return false;
    }

    const { username } = args;
    const result = await context.db.findOne("users", { username });
    if (!result.rowCount) {
      return false;
    }

    await context.db.update("users", { is_admin: true }, { username });

    return true;
  },

As you can see, we are now updating the table with a boolean value, which is violating the type (string | Date) that we defined for the values object. So let’s open up ./src/db/index.ts and modify the update and runQuery methods real quick:

  async runQuery(
    query: string,
    values: (string | Date | boolean)[]   // new
  ): Promise<QueryResult<UserEntity>> {
    ...
  }
  ...
  update(
    table: string,
    newObj: { [id: string]: string | Date | boolean },  // new
    conditions: { [id: string]: string | Date }
  ): Promise<QueryResult<UserEntity>> {
    ...
  }

Before we forget, let’s also modify the schema to add changeRole into Mutation type:

const schema = buildSchema(`
  ...

  type Mutation {
    register(username: String, password: String): User
    login(username: String, password: String): User
    changeRole(username: String): Boolean               // new
  }
`);

We can finally test that out. Like before, let’s start with nornalUser and try to change anotherUser‘s role.

Fig 15: changeRole failed with normalUser

That’s what we expected. Now how about logging in as adminUser:

Fig 16: changeRole succeeded with adminUser

It returned true, yay! We have to inspect the database to be fully sure:

Fig 17: proof that anotherUser is now an admin

anotherUser is now an administrator. We’ve made it to the end, you guys! Beautifully done!

If you have any troubles reproducing the results above, you can check out the branch for this post and compare to your implementation:

git clone https://github.com/ChunML/vanilla-graphql/
cd vanilla-graphql
git checkout 4-session-and-authorization

Conclusions

That’s it for today, guys! Great job, everyone! Continuing from adding PostgreSQL database, we have implemented a login status managing mechanism using sessions and had some introduction to basic authorization. If you have any questions, feel free to contact me on the project repository. Thank you for your time and I will see you in the next post.

Trung Tran is a software developer + AI engineer. He also works on networking & cybersecurity on the side. He loves blogging about new technologies and all posts are from his own experiences and opinions.

Leave a reply:

Your email address will not be published.