DEV Community

Cover image for Part 4: Portal login & authorization of socket connection
Evertvdw
Evertvdw

Posted on

Part 4: Portal login & authorization of socket connection

The code for this part can be found here

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 (JSON web token) to authenticate ourselves when communicating with the server.

Setting up stuff at the server end

Commit for this section is here

I will be implementing the OAuth 2.0 protocol with refresh and access tokens as described here. 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:

yarn add -W -D bcrypt yargs
Enter fullscreen mode Exit fullscreen mode

And the create a file hash-password.js at the root of our project:

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

email: string;
hash: string;
Enter fullscreen mode Exit fullscreen mode

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:

yarn workspace server add bcrypt cookie-parser helmet jsonwebtoken
yarn workspace server add -D @types/bcrypt @types/cookie-parser @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

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:

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();
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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';
Enter fullscreen mode Exit fullscreen mode

Then some express app plugins:

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

// remove: 
app.use(cors());
Enter fullscreen mode Exit fullscreen mode

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:

socketMiddleware(io, db);
app.use('/auth', authRoutes(db));
Enter fullscreen mode Exit fullscreen mode

Adding the login screen in the portal

Commit for this section can be found here

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:

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 = '';
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

const socket = io(URL, {
  autoConnect: false,
});
Enter fullscreen mode Exit fullscreen mode

Now we need to handle connecting elsewhere, we are going to create a packages/portal/src/boot/auth.ts file for that:

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

{
  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') }],
},
Enter fullscreen mode Exit fullscreen mode

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:

<q-btn outline @click="logout"> Logout </q-btn>
Enter fullscreen mode Exit fullscreen mode

In the script block:

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('/');
}
Enter fullscreen mode Exit fullscreen mode

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!๐Ÿ‘‹

Top comments (0)