Часть 4: Вход на портал и авторизация сокетного соединения


Код для этой части можно найти здесь

Добро пожаловать в четвертую часть этой серии статей, в которой мы устанавливаем встраиваемый виджет чата. В этой части мы добавим аутентификацию на портал. Я хочу, чтобы:

  • Авторизация при доступе к порталу
  • Защитить связь между порталом и <> сервером.

В настоящее время любой, кто посылает нужные события на сервер, может быть добавлен в комнату admin и получать все общение в чате со всеми клиентами. Мы собираемся предотвратить это, добавив логин на портал и создав JWT (JSON web token) для аутентификации при общении с сервером.

Настройка на стороне сервера

Коммит для этого раздела находится здесь

Я буду реализовывать протокол OAuth 2.0 с маркерами обновления и доступа, как описано здесь. Альтернативой может быть использование существующего поставщика услуг аутентификации, но я хотел узнать больше об этом, сделав это самостоятельно. Если вы заметите какие-либо ошибки в моей реализации, пожалуйста, дайте мне знать 🙂

Хранение пароля в базе данных

Шучу, никогда не делайте этого! 🤨

Но когда кто-то на стороне портала будет входить в систему, мы должны проверить, что он ввел правильный пароль. Для этого мы будем хранить хэшированную версию пароля в нашей базе данных.

Мы создаем администраторов на основе seed-файла в packages/server/database/admins.ts, здесь нам нужно добавить эту информацию. Чтобы немного облегчить нам жизнь при добавлении будущих администраторов, я создал небольшой CLI инструмент, который будет хэшировать пароль за нас.

Первый запуск:

yarn add -W -D bcrypt yargs
Вход в полноэкранный режим Выход из полноэкранного режима

И создайте файл hash-password.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);
});
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот файл принимает пароль и выводит его хэш в консоль. Мы можем использовать его следующим образом: node ./hash-password.js -p <password_to_hash>.

Прежде чем мы добавим пароль к нашему семени, мы должны обновить интерфейс типа Admin в types.ts и добавить:

email: string;
hash: string;
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем хэшируйте пароль с помощью инструмента и добавьте этот хэш и email в массив admins в packages/server/database/admins.ts. В примере кода вы можете видеть мой хэш, но вы должны использовать свой собственный, сгенерированный вами пароль по вашему выбору.

Добавление пакетов в пакет сервера

Нам потребуется установить несколько дополнительных пакетов для обеспечения безопасности нашего сервера:

yarn workspace server add bcrypt cookie-parser helmet jsonwebtoken
yarn workspace server add -D @types/bcrypt @types/cookie-parser @types/jsonwebtoken
Вход в полноэкранный режим Выход из полноэкранного режима

Рефакторинг и добавление промежуточного ПО сокета

Чтобы добавить аутентификацию к нашему сокетному соединению, мы можем добавить еще одну функцию промежуточного ПО. Поскольку это будет наша вторая функция (первая — создание clientID), самое время собрать их в отдельный файл, чтобы все было упорядочено. Создайте файл packages/server/middleware/socket.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();
  });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте разложим это на части:

  • Мы экспортируем функцию, которая может быть вызвана для регистрации промежуточного ПО.
  • Для создания JWT мы должны предоставить секрет. Идея секрета заключается в том, что он является секретным и что вы не фиксируете его в системе контроля версий. Мы изменим это в части 5, когда будем использовать переменные окружения.
  • Когда устанавливается сокетное соединение, оно выполняет handshake, и вы можете отправить некоторую пользовательскую информацию вместе с этим handshake, когда инициализируете соединение на стороне клиента (портала или виджета). В нашем случае со стороны портала мы собираемся передать токен доступа, который мы проверим в этом промежуточном ПО. — Если проверка прошла успешно, мы устанавливаем объект admin на объект socket и продолжаем. В противном случае мы вызываем next с ошибкой, которая приведет к прерыванию установки соединения.
  • Обратите внимание, что в случае, если токен не предоставлен, мы просто вызываем next(). Пользователи нашего виджета не будут использовать аутентификацию, поэтому мы должны сделать это для того, чтобы эти соединения были установлены и не прервались.

Поскольку мы добавляем дополнительное свойство для socket typescript будет жаловаться, поэтому в packages/server/types.ts добавьте

Добавление маршрутов авторизации

Наш сервер — это Socket.IO сервер, но также и обычное Express приложение. Это означает, что мы можем легко добавлять конечные точки, и нам нужно создать две конечные точки

  1. /login для приема email и пароля и возврата accessToken
  2. /refresh_token для приема refreshToken (установленного в cookie) и возврата нового accessToken, если refreshToken все еще действителен.

Для этого мы создаем отдельный файл 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;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Быстрая разбивка двух конечных точек, первая /login:

  • Возвращать статус 400 (Плохой запрос), если не указан email или пароль.
  • Проверьте, существует ли администратор с таким email в БД, если нет, верните 401 (Unauthorized).
  • Сравните сохраненный хэш с хэшем пароля, если они не совпадают, верните 401.
  • Если они совпадают, создайте accessToken и refreshToken с разными сроками действия. AccessToken имеет короткий срок действия, а refreshToken — более длительный.
  • refreshToken устанавливается как cookie в ответе, который установит его в браузере на стороне клиента, который будет передаваться при выполнении запросов к конечной точке /refresh_token.
  • Токен accessToken возвращается в виде текста.
  • Флаг httpOnly означает, что это файл cookie, который не может быть доступен или изменен javascript на стороне клиента.

Во-вторых, конечная точка /refresh_token:

  • Эта конечная точка используется клиентом, когда срок действия accessToken истек, вместо выхода из системы, когда это происходит, клиент запрашивает другой accessToken, вызывая эту конечную точку.
  • Мы получаем токен из куки jwt-refresh, если его нет, возвращаем 401.
  • Если токен проверен, возвращаем новый accessToken.

Соберите все вместе в записи сервера

Внутри файла packages/server/index.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';
Вход в полноэкранный режим Выход из полноэкранного режима

Затем некоторые плагины для экспресс-приложений:

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

// remove: 
app.use(cors());
Войти в полноэкранный режим Выйти из полноэкранного режима

Перед вызовом adminHandler добавьте оператор if (socket.admin), чтобы добавлять эти обработчики сокетов только при наличии подключенного администратора. Помните, что мы установили свойство admin в промежуточном ПО jwt socket, поэтому это свойство будет установлено только у аутентифицированных администраторов.

Удалите промежуточное ПО clientID в этом файле, мы перенесли его в наш файл промежуточного ПО.

Наконец, после вызова db = await initDB(); добавьте следующее:

socketMiddleware(io, db);
app.use('/auth', authRoutes(db));
Войти в полноэкранный режим Выйти из полноэкранного режима

Добавление экрана входа в портал

Коммит для этого раздела можно найти здесь

Последний раздел этой части — добавление экрана входа в систему. Он снова будет минимально оформлен, так как мы собираемся отложить его оформление на более поздний этап.

Добавление магазина авторизации

Мы начнем с добавления магазина auth, который будет содержать материалы, связанные с входом, создадим файл 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 = '';
    },
  },
});
Войдите в полноэкранный режим Выход из полноэкранного режима

Быстрая разбивка этого файла:

  • Мы определяем статус входа и accessToken, который хранится в localStorage и извлекается из него, если присутствует при запуске.
  • urlAfterLogin будет использоваться, если вы вошли в приложение портала по маршруту /something, но для доступа к этому маршруту вам необходимо пройти авторизацию. В этом случае мы можем задать url, на который мы перенаправимся после успешного входа в систему.
  • В действии login мы вызываем созданную нами конечную точку /login. Обратите внимание, что мы используем credentials: 'include' в опциях выборки, это необходимо для того, чтобы сервер мог отправить обратно cookie. Если этот параметр не установлен, куки, которые устанавливает сервер, не будут установлены на стороне клиента. Мне потребовалось время, чтобы разобраться в этом 😅.
  • В socket.io.opts.query мы задаем токен, который будет считываться промежуточным ПО jwt socket и который используется для аутентификации сокетного соединения.
  • В действии refresh_token мы возвращаем true или false, которые мы можем использовать в других местах, чтобы узнать, было ли обновление успешным.

Добавление загрузочного файла auth

В настоящее время мы подключаемся к нашему сокет-серверу автоматически при создании объекта сокета вызовом io(). Теперь нам сначала нужно войти в систему, прежде чем мы установим соединение, поэтому вместо этого мы отключим автоматическое подключение внутри packages/portal/src/boot/socket.ts:

const socket = io(URL, {
  autoConnect: false,
});
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь нам нужно обработать подключение в другом месте, для этого мы создадим файл packages/portal/src/boot/auth.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();
  });
});
Вход в полноэкранный режим Выход из полноэкранного режима

Разбивка этого файла:

  • Этот файл запускается, когда мы инициализируем наше приложение. Если токен присутствует, мы используем его для подключения к серверу сокетов.
  • Мы слушаем событие connect_error на сокете. Если оно возвращает ошибку аутентификации, мы считаем, что срок действия нашего токена истек, и пытаемся обновить его. Если это удается, мы подключаемся снова, если нет — выходим из системы и полностью отключаемся от сервера сокетов.
  • В этом файле мы также регистрируем обработчик Vue router beforeEach, который будет выполняться, как следует из названия, перед каждой навигацией маршрутизатора. Он будет проверять, пытаемся ли мы получить доступ к защищенному маршруту (обозначенному мета-свойством auth), и перенаправлять нас, если мы делаем это без аутентификации.

Мы должны зарегистрировать этот загрузочный файл внутри packages/portal/quasar.config.js, чтобы использовать его, добавив его в массив загрузочных файлов: boot: ['socket', 'auth'].

Файлы Vue для входа в систему

Страница входа в систему будет выглядеть немного иначе, чем другие наши страницы, поэтому я буду использовать отдельный макет для этой страницы. Создайте файл packages/portal/src/layouts/LoginLayout.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>
Вход в полноэкранный режим Выйти из полноэкранного режима

В нем у нас будет страница packages/portal/src/pages/LoginPage.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>
Вход в полноэкранный режим Выход из полноэкранного режима

Внутри нашего файла packages/portal/src/router/routes.ts мы должны использовать эти компоненты. Страница входа в наше приложение будет находиться по адресу /, а страница клиентов переместится в /clients. Таким образом, у нас будет два маршрута:

{
  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') }],
},
Войти в полноэкранный режим Выход из полноэкранного режима

В качестве последнего шага мы добавим кнопку выхода из приложения, чтобы нам было проще тестировать вход/выход. Добавим ее в файл packages/portal/src/layouts/MainLayout.vue.

В секции шаблона внутри элемента q-toolbar:

<q-btn outline @click="logout"> Logout </q-btn>
Войти в полноэкранный режим Выйти из полноэкранного режима

В блоке сценариев:

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('/');
}
Войти в полноэкранный режим Выход из полноэкранного режима

Подведение итогов

На этом в этой части все! 🚀 В следующей части мы увидим, как это развертывается на Heroku и сможем создать codepen и загрузить туда наш веб-компонент, до встречи! 👋

Оцените статью
Procodings.ru
Добавить комментарий