Part 4: Portal login & authorization of socket connection
Tags: javascript, webdev, vue, tutorial
Published: 2022-05-15 17:52:30

> The code for this part can be found [here](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/tree/part-4)

Welcome to part four of this series where we are setting up an embeddable chat widget. In this part we are going to add authentication to the portal. I want to:

- Login when accessing the portal
- Secure the communication between portal <> server

Currently anyone who sends the right events to the server can be added to the `admin` room and receive all chat communication with all clients. This is what we are going to prevent by adding a login to the portal and creating a [JWT](https://jwt.io/) (JSON web token) to authenticate ourselves when communicating with the server.

## Setting up stuff at the server end
> Commit for this section is [here](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/b0d67650c7937646d26f650ff3c53e68c4f67b0f)

I will be implementing the OAuth 2.0 protocol with refresh and access tokens as described [here](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5). An alternative would be to use an existing auth provider, but I wanted to learn more about it by doing it myself. If you can spot any errors in my implementation, please let me know :) 

### Storing the password in the database
> Just kidding, never do that!🤨

But when someone at the portal side will login we have to verify that they provided the correct password. In order to do that we are going to store the hashed version of the password in our database.

We are creating the admins based on a seed file in `packages/server/database/admins.ts`, in here we need to add that information. In order to make our life a bit easier when adding future admins, I created a little CLI tool that will hash a password for us.

First run:
```bash
yarn add -W -D bcrypt yargs
```

And the create a file `hash-password.js` at the root of our project:
```js
const yargs = require('yargs');
const bcrypt = require('bcrypt');

const options = yargs
  .usage('Usage: -p <password>')
  .option('p', {
    alias: 'password',
    describe: 'Password to hash',
    type: 'string',
    demandOption: true,
  }).argv;

bcrypt.hash(options.p, 10, function (err, hash) {
  console.log(hash);
});
```
What this does it take a password and output the hash of it to the console. We can use it like: `node ./hash-password.js -p <password_to_hash>`.

Before we add a password to our seed we have to update the `Admin` type interface in `types.ts` and add:
```ts
email: string;
hash: string;
```

Then hash a password using the tool and the add that hash and an email to the admins array in `packages/server/database/admins.ts`. In the example code you can see my hash, but you have to use your own that you generated with a password of your choice. 

### Adding packages to the server package
We will need to install some extra packages to secure our server:
```bash
yarn workspace server add bcrypt cookie-parser helmet jsonwebtoken
yarn workspace server add -D @types/bcrypt @types/cookie-parser @types/jsonwebtoken
```

### Refactoring and adding socket middleware
To add authentication to our socket connection we can add another middleware function. As this will be our second one (the first is the creation of a clientID) it is a good time to put them together in a separate file to keep things organized. Create a `packages/server/middleware/socket.ts` file with the following content:
```ts
import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { Database } from '../types';

const secret = 'alksjd;kl3lkrjtokensfklhklkef';

export default function (io: Server, db: Database) {
  // Verify jwt token on socket connection
  io.use((socket, next) => {
    if (
      socket.handshake.query &&
      socket.handshake.query.token &&
      typeof socket.handshake.query.token === 'string'
    ) {
      jwt.verify(
        socket.handshake.query.token,
        secret,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        function (err, admin: any) {
          if (err) {
            console.log('[DEBUG] socket middleware jwt error');
            return next(new Error('Authentication error'));
          }
          socket.admin = admin;
        }
      );
    }
    next();
  });

  // Socket middleware to set a clientID
  const randomId = () => crypto.randomBytes(8).toString('hex');
  io.use((socket, next) => {
    const clientID = socket.handshake.auth.clientID;
    if (clientID) {
      const client = db.clients.findOne({ id: clientID });
      if (client) {
        socket.clientID = clientID;
        return next();
      }
    }
    socket.clientID = randomId();
    next();
  });
}
```
Let's break it down:
- We export a function that can be called to register the middleware
- To create a JWT we have to provide a secret. The idea of a secret is that it is secret and that you don't commit this into version control. We are going to change this in part 5 when we will use environment variables.
- When a socket connection is setup it will do a `handshake` and you can send some custom information along with that handshake when you initialize the connection at the client side (either portal or widget). In our case from the portal side we are going to pass an access token, which we will verify in this middleware. - If the verification is successful we set the admin object on the  `socket` object and continue. If it is not we call `next` with an Error that will cause the connection setup to abort.
- Notice that in case a token is not provided we just call `next()`. Our widget users will not use authentication so we have to do this in order for those connections to be setup and not aborted.

As we are adding an extra property on `socket` typescript will complain, so in `packages/server/types.ts` add
`admin?: { email: Admin['email'] };` to the `Socket` interface, below the already defined `clientID`.

### Adding auth routes
Our server is a Socket.IO server but also a regular Express app. That means that we can easily add endpoints, and we need to create two endpoints 
1. A `/login` to accept a email and password and return an accessToken
2. A `/refresh_token` to accept a refreshToken (set in a cookie) and return a new accessToken if the refreshToken is still valid.

We create a separate file for this, called `packages/server/routes/auth.ts`:
```ts
import express from 'express';
import { Database } from '../types';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';

const router = express.Router();
const secret = 'alksjd;kl3lkrjtokensfklhklkef';

export default function (db: Database) {
  router.post('/login', async (req, res) => {
    console.log('POST /login', [req.body.email]);
    if (!req.body.email || !req.body.password) {
      return res.sendStatus(400);
    }

    const admin = db.admins.findOne({ email: req.body.email });
    if (!admin) return res.sendStatus(401);

    const match = await bcrypt.compare(req.body.password, admin.hash);
    if (match) {
      const token = jwt.sign({ email: admin.email }, secret, {
        expiresIn: '1h',
      });
      const refreshToken = jwt.sign({ email: admin.email }, secret, {
        expiresIn: '30d',
      });
      res.cookie('jwt-refresh', refreshToken, {
        httpOnly: true,
        secure: true,
        maxAge: 30 * 24 * 60 * 60 * 1000, // Equivalent of 30 days
      });
      return res.send(token);
    } else {
      return res.sendStatus(401);
    }
  });

  router.get('/refresh_token', async (req, res) => {
    const refreshToken = req.cookies['jwt-refresh'];
    if (!refreshToken) {
      res.sendStatus(401);
    } else {
      jwt.verify(
        refreshToken,
        secret,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        function (err: any, admin: any) {
          if (err) {
            console.log('[DEBUG] jwt.verify error', err);
            res.sendStatus(401);
          } else {
            console.log('[DEBUG] jwt verify success: ', [admin.email]);
            const token = jwt.sign({ email: admin.email }, secret, {
              expiresIn: '1h',
            });
            res.send(token);
          }
        }
      );
    }
  });

  return router;
}
```
Quick breakdown of the two endpoints, first `/login`:
- Return a 400 status (Bad request) if no email or password is provided
- Check if an admin with that email exists in the DB, if not return 401 (Unauthorized)
- Compare the stored hash with the hashed password, if the do not match return 401
- If they do match, create an `accessToken` and a `refreshToken` with different expiration times. The accessToken is shortlived, and the refreshToken has a longer lifetime. 
- The `refreshToken` is set as a cookie on the response, which will set it in the browser on the client side, which will be passed along when making requests to the `/refresh_token` endpoint.
- The `accessToken` is returned as text.
- The `httpOnly` flag means that it is a cookie that cannot be accessed or modified by client side javascript.

Second, the `/refresh_token` endpoint:
- This endpoint is used by the client when the accessToken has expired, instead of logging out when that happens the client requests another accessToken by calling this endpoint.
- We get the token from the `jwt-refresh` cookie, if it is not present return 401
- If the token is verified return a new accessToken

### Put everything together in the server entry
Inside the `packages/server/index.ts` file we need to use the created endpoints and middleware.

First the imports at the top:
```ts
// add:
import authRoutes from './routes/auth';
import socketMiddleware from './middleware/socket';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';

// remove:
import crypto from 'crypto';
```

Then some express app plugins:
```ts
// add:
app.use(helmet());
app.use(
  cors({
    origin: [/http:\/\/localhost:\d*/],
    credentials: true,
  })
);
app.use(express.json());
app.use(cookieParser());

// remove: 
app.use(cors());
```

Before calling the `adminHandler` add an `if (socket.admin)` statement to only add those socket handlers if there is an admin connected. Remember we set that admin property in the jwt socket middleware, so only authenticated admins have that property set.

Remove the `clientID` middleware in this file, we moved that to our middlewares file.

Lastly, after the `db = await initDB();` call, add the following:
```ts
socketMiddleware(io, db);
app.use('/auth', authRoutes(db));
```

## Adding the login screen in the portal
> Commit for this section can be found [here](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/21a08fa74bafa435b6cd903b4a76fe8f89a128d1)

The last section of this part is to add the login screen. This again will be minimally styled, as we are going to postpone styling everything until a later stage.

### Adding an auth store
We start by adding an `auth` store which will contain login related stuff, create a file called `packages/portal/src/stores/auth.ts`:
```ts
import { defineStore } from 'pinia';
import { socket } from 'src/boot/socket';

export enum AuthStatus {
  init,
  loading,
  success,
  error,
}

export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: localStorage.getItem('jwt') || '',
    status: AuthStatus.init,
    urlAfterLogin: '/clients',
  }),
  getters: {
    isAuthenticated: (state) => state.status === AuthStatus.success,
  },
  actions: {
    async login(payload: { email: string; password: string }) {
      this.status = AuthStatus.loading;
      const response = await fetch('http://localhost:5000/auth/login', {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });
      console.log('[DEBUG] login response', response.ok, response.status);
      if (response.ok) {
        this.status = AuthStatus.success;

        const token = await response.text();
        localStorage.setItem('jwt', token);
        this.token = token;
        socket.io.opts.query = { token };

        console.log('[DEBUG]: login response', token);
      } else this.status = AuthStatus.error;
    },
    async refresh_token() {
      const response = await fetch('http://localhost:5000/auth/refresh_token', {
        credentials: 'include',
      });
      if (response.ok) {
        const token = await response.text();
        localStorage.setItem('jwt', token);
        this.token = token;
        socket.io.opts.query = { token };
        console.log('[DEBUG] refresh_token response', token);
        return true;
      } else {
        return false;
      }
    },
    logout() {
      this.status = AuthStatus.init;
      localStorage.removeItem('jwt');
      this.token = '';
    },
  },
});
```
Quick breakdown of this file:
- We define a login status and an accessToken which is stored inside localStorage and retreived from it if present at startup.
- The `urlAfterLogin` will be used if you enter the portal app at a route `/something` but you need to be authorized to access that route. In that case we can set the url that we redirect to after logging in successfully. 
- In the `login` action we call our created /login endpoint. Notice we use `credentials: 'include'` in the fetch options, this is necessary so the server can send back a cookie. If this is not set the cookie that the server sets does not get set client side. Took me a while to figure that one out 😅
- At `socket.io.opts.query` we set the token that will be read by the jwt socket middleware and which is used to authenticate the socket connection.
- In the `refresh_token` action we return true or false, which we can use elsewhere to know if the refresh was successful.

### Adding an auth boot file
Currently we connect to our socket server automatically when we create the socket object by calling `io()`. Now we first have to login before we setup the connection so instead we are going to disable auto connect inside `packages/portal/src/boot/socket.ts`:
```ts
const socket = io(URL, {
  autoConnect: false,
});
```

Now we need to handle connecting elsewhere, we are going to create a `packages/portal/src/boot/auth.ts` file for that:
```ts
import { boot } from 'quasar/wrappers';
import { AuthStatus, useAuthStore } from 'src/stores/auth';
import { socket } from 'src/boot/socket';

export default boot(({ store, router }) => {
  const authStore = useAuthStore(store);

  if (authStore.token) {
    authStore.status = AuthStatus.success;
    socket.io.opts.query = { token: authStore.token };
    socket.connect();
  }

  socket.on('connect_error', async (err) => {
    console.log('[DEBUG] connect_error', err);
    if (err.message === 'Authentication error') {
      const refresh = await authStore.refresh_token();
      if (!refresh) {
        authStore.logout();
        router.push('/');
        socket.disconnect();
      } else {
        socket.connect();
      }
    }
  });

  router.beforeEach((to, from, next) => {
    if (to.matched.some((record) => record.meta.auth)) {
      if (!authStore.isAuthenticated) {
        authStore.urlAfterLogin = to.fullPath;
        next({
          path: '/',
        });
      } else {
        next();
      }
    }
    if (to.fullPath === '/' && authStore.isAuthenticated)
      next({ path: '/clients' });
    next();
  });
});
```
Breakdown of this file:
- This file is run when we initialize our app. If a token is present we use that token to connect to the socket server.
- We listen for the `connect_error` event on the socket. If it returns an authentication error, we assume our token has expired and try to refresh it. If that succeeds we connect again, if it does not we logout and disconnect completely from the socket server.
- In this file we also register a Vue router `beforeEach` handler, which will run, as the name suggests, before each router navigation. It will check if we try to access a protected route (signalled by a meta property called auth), and redirect us if we do that unauthenticated.

We have to register this boot file inside `packages/portal/quasar.config.js` to use it, by adding it to the boot files array: `boot: ['socket', 'auth']`.

### Vue files for the login
The login page will look a bit different from our other pages so I will use a separate layout for that page. Create a `packages/portal/src/layouts/LoginLayout.vue` file:
```vue
<template>
  <q-layout view="lHh Lpr lFf">
    <q-header>
      <q-toolbar>
        <q-toolbar-title> Portal login </q-toolbar-title>
      </q-toolbar>
    </q-header>

    <q-page-container>
      <router-view />
    </q-page-container>
  </q-layout>
</template>
```
In there we will have a page `packages/portal/src/pages/LoginPage.vue`, which will be a simple form with two inputs and a submit button:
```vue
<template>
  <q-page class="row justify-center items-center">
    <q-form class="q-gutter-md" @submit="onSubmit" @reset="onReset">
      <q-input v-model="email" filled label="Emailadress" />
      <q-input v-model="password" filled type="password" label="Password" />
      <div>
        <q-btn
          label="Login"
          type="submit"
          color="primary"
          :loading="authStore.status === AuthStatus.loading"
        />
      </div>
    </q-form>
  </q-page>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore, AuthStatus } from 'src/stores/auth';
import { useRouter } from 'vue-router';
import { socket } from 'src/boot/socket';

const email = ref('');
const password = ref('');
const authStore = useAuthStore();
const router = useRouter();

async function onSubmit() {
  await authStore.login({ email: email.value, password: password.value });
  socket.connect();
  if (authStore.isAuthenticated) router.push(authStore.urlAfterLogin);
  onReset();
}

function onReset() {
  email.value = '';
  password.value = '';
}
</script>
```

Inside our `packages/portal/src/router/routes.ts` file we have to use these components. Our apps login page will be at `/` and the clients page will move to `/clients`. So our two routes will be:
```ts
{
  path: '/',
  component: () => import('layouts/LoginLayout.vue'),
  children: [{ path: '', component: () => import('pages/LoginPage.vue') }],
},
{
  path: '/clients',
  meta: {
    auth: true,
  },
  component: () => import('layouts/MainLayout.vue'),
  children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
},
```

As a final thing we are going to add a logout button to our app, so that we can test logging/logging out a bit easier. Lets add this to the `packages/portal/src/layouts/MainLayout.vue` file.

In the template section inside the `q-toolbar` element:
```html
<q-btn outline @click="logout"> Logout </q-btn>
```

In the script block:
```ts 
import { useAuthStore } from 'src/stores/auth';
import { socket } from 'src/boot/socket';
import { useRouter } from 'vue-router';

const authStore = useAuthStore();
const router = useRouter();

function logout() {
  authStore.logout();
  socket.disconnect();
  router.push('/');
}
```

## Wrapping up
That is it for this part!🚀 In the next one we are going to see this deployed to Heroku and be able to create an codepen and load in our webcomponent in there, see you then!👋