Selaa lähdekoodia

jwt-авторизация

Евгений Колесников 8 kuukautta sitten
vanhempi
sitoutus
5659d3a2a6
5 muutettua tiedostoa jossa 501 lisäystä ja 1 poistoa
  1. 15 1
      api/api.rest
  2. 109 0
      api/app.js
  3. 238 0
      api/express01.md
  4. 137 0
      api/package-lock.json
  5. 2 0
      api/package.json

+ 15 - 1
api/api.rest

@@ -21,4 +21,18 @@ Content-Type: application/json
 }
 
 ### Удаление блюда из корзины
-DELETE {{url}}/cart/1
+DELETE {{url}}/cart/1
+
+### Авторизация
+POST {{url}}/user/login
+Content-Type: application/json
+
+{
+    "login": "login",
+    "password": "123456"
+}
+
+@token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwicm9sZUlkIjoxLCJpYXQiOjE3NDc3MjQ4MDJ9.qX7eLwPNxXm_LE-TgA2P9Z-G-RBF5WmYD3IwJZaK4Nc
+### Получение корзины пользователя
+GET {{url}}/cart
+Authorization: Bearer {{token}}

+ 109 - 0
api/app.js

@@ -1,6 +1,8 @@
 const express = require('express')
 const { sequelize } = require('./models')
 const { QueryTypes } = require('sequelize')
+const md5 = require('md5')
+const jwt = require('jsonwebtoken')
 
 const app = express()
 const port = 3000
@@ -23,6 +25,51 @@ app.get('/api/menu-item', async (req, res) => {
   }
 })
 
+/**
+ * Middleware авторизации
+ */
+const authenticateJWT = (req, res, next) => {
+  const authHeader = req.headers.authorization;
+
+  if (authHeader) {
+    const token = authHeader.split(' ')
+
+    if (token[0].toLowerCase() != 'bearer')
+      return res.status(400).send('не поддерживаемый тип авторизации')
+
+    jwt.verify(token[1], JWT_SECRET, (err, user) => {
+      if (err) return res.status(403).send(err)
+      req.user = user
+      next()
+    })
+  } else {
+    res.status(401).send('нет заголовка авторизации')
+  }
+}
+
+/**
+ * Вторым параметром запроса можно добавить массив middleware
+ */
+app.get('/api/cart', [authenticateJWT], async (req, res) => {
+  try {
+    res.json(await sequelize.query(`
+      SELECT *
+      FROM Cart
+      -- WHERE userId=:userId
+    `, {
+      logging: false,
+      type: QueryTypes.SELECT
+      // replacements: {
+      //   userId: req.user.id
+      // }
+    }))
+  } catch (error) {
+    console.error(error)
+  } finally {
+    res.end()
+  }
+})
+
 app.post('/api/cart', async (req, res) => {
   try {
     await sequelize.query(`
@@ -85,6 +132,68 @@ app.delete('/api/cart/:id', async (req, res) => {
   }
 }) 
 
+const JWT_SECRET = process.env.JWT_SECRET
+
+/**
+ * В теле запроса должен быть объект с логином и паролем:
+ * {
+ *  "login": "ваш логин",
+ *  "password": "пароль"
+ * }
+ */
+app.post('/api/user/login', async (req, res) => {
+  try {
+    // const user = await sequelize.query(`
+    //   SELECT *
+    //   FROM User
+    //   WHERE login=:login
+    // `, {
+    //   // параметр plain нужен, чтобы запрос вернул не массив записей, а конкретную запись
+    //   // если записи с таким логином нет, то вернет null
+    //   plain: true,
+    //   logging: false,
+    //   type: QueryTypes.SELECT,
+    //   replacements: {
+    //     login: req.body.login
+    //   }
+    // })
+
+    const user = {
+      password: md5('123456'),
+      id: 1,
+      roleId: 1
+    }
+
+    if (user) {
+      // хешируем пароль
+      const passwordMD5 = md5(req.body.password)
+
+      if (user.password == passwordMD5) {
+
+        // формируем токен
+        const jwtToken = jwt.sign({
+            id: user.id, 
+            firstName: user.firstName,
+            roleId: user.roleId 
+          }, 
+          JWT_SECRET
+        )
+
+        res.json(jwtToken)
+      } else {
+        res.status(401).send('не верный пароль')
+      }
+    } else {
+      res.status(404).send('пользователь не найден')
+    }
+  } catch (error) {
+    console.warn('ошибка при авторизации:', error.message)
+    res.status(500).send(error.message)
+  } finally {
+    res.end()
+  }
+})
+
 
 app.listen(port, () => {
   console.log(`Example app listening on port ${port}`)

+ 238 - 0
api/express01.md

@@ -13,6 +13,7 @@
 * [Использование sequelize для получения данных из БД](#использование-sequelize-для-получения-данных-из-бд)
 * [Создание скрипта для наполнения БД начальными данными](#создание-скрипта-для-наполнения-бд-начальными-данными)
 * [Добавление сущностей БД, реализация REST.](#добавление-сущностей-бд-реализация-rest)
+* [JWT-авторизация](#jwt-авторизация)
 
 ## Создание проекта
 
@@ -563,6 +564,243 @@ app.delete('/api/cart/:id', async (req, res) => {
 
 ---
 
+## JWT-авторизация
+
+>JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и т.д. и подписи (signature).
+
+### Структура JWT
+
+JWT состоит из трех частей: заголовок `header`, полезные данные `payload` и подпись `signature`. Давайте пройдемся по каждой из них.
+
+
+#### Шаг 1. Создаем HEADER
+
+Хедер JWT содержит информацию о том, как должна вычисляться JWT подпись. Хедер — это тоже JSON объект, который выглядит следующим образом:
+
+```js
+header = { "alg": "HS256", "typ": "JWT"}
+```
+
+Поле `typ` не говорит нам ничего нового, только то, что это _JSON Web Token_. Интереснее здесь будет поле `alg`, которое определяет алгоритм хеширования. Он будет использоваться при создании подписи. HS256 — не что иное, как HMAC-SHA256, для его вычисления нужен лишь один секретный ключ (более подробно об этом в шаге 3). Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.
+
+
+#### Шаг 2. Создаем PAYLOAD
+
+`Payload` — это полезные данные, которые хранятся внутри **JWT**. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает **JWT** с информацией об `id` пользователя — `userId`.
+
+```js
+payload = { "userId": "b08f86af-35da-48f2-8fab-cef3904660bd" }
+```
+
+Мы положили только одну заявку (claim) в `payload`. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:
+
+* iss (issuer) — определяет приложение, из которого отправляется токен.
+* sub (subject) — определяет тему токена.
+* exp (expiration time) — время жизни токена.
+
+Эти поля могут быть полезными при создании **JWT**, но они не являются обязательными. Если хотите знать весь список доступных полей для **JWT**, можете заглянуть в **Wiki**. Но стоит помнить, что чем больше передается информации, тем больший получится в итоге сам **JWT**. Обычно с этим не бывает проблем, но все-таки это может негативно сказаться на производительности и вызвать задержки во взаимодействии с сервером.
+
+#### Шаг 3. Создаем SIGNATURE
+
+Подпись вычисляется с использованием следующего псевдо-кода:
+
+```
+const SECRET_KEY = 'cAtwa1kkEy'
+const unsignedToken = base64urlEncode(header) + '.' + base64urlEncode(payload)
+const signature = HMAC-SHA256(unsignedToken, SECRET_KEY)
+```
+
+Алгоритм _base64url_ кодирует `хедер` и `payload`, созданные на 1 и 2 шаге. Алгоритм соединяет закодированные строки через точку. Затем полученная строка хешируется алгоритмом, заданным в хедере на основе нашего секретного ключа.
+
+```
+// header eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
+// payload eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ
+// signature -xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM
+```
+
+#### Шаг 4. Теперь объединим все три JWT компонента вместе
+
+Теперь, когда у нас есть все три составляющих, мы можем создать наш **JWT**. Это довольно просто, мы соединяем все полученные элементы в строку через точку.
+
+```js
+const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature)
+// JWT Token
+// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM
+```
+
+Вы можете попробовать создать свой собственный JWT на сайте jwt.io.
+
+Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.
+
+### Как JWT защищает наши данные?
+
+Очень важно понимать, что использование **JWT** НЕ скрывает и не маскирует данные. Причина, почему **JWT** используются — это проверка, что отправленные данные были действительно отправлены авторизованным источником. Как было продемонстрировано выше, данные внутри **JWT** закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных. Таким образом закодирование и подпись данных не защищает их. С другой стороны, главная цель шифрования — это защита данных от неавторизованного доступа.
+
+### Простыми словами
+
+1. При авторизации по логину/паролю сервер убеждается, что такой пользователь есть, формирует **JWT**-токен, содержащий полезную информацию и подпись, и возвращает этот токен клиенту (сайт, приложение)
+
+1. Клиент при последующих запрсах добавляет **JWT**-токен в заголовок запроса:
+
+    ```
+    Authorization: Bearer <JWT-токен>
+    ```
+
+1. Сервер, получая **JWT**-токен уже не лезет в таблицу пользователей, а просто проверяет подпись токена, убеждаясь что она не подделана (ключ подписи есть только у севрера). А затем может использовать полезную информацию из токена в запросах к БД. Таким образом мы экономим на каждом входящем запросе одно обращение к БД (а работа с БД самое медленное действие, вычисление подписи намного дешевле)
+
+### Подключаем JWT-авторизацию к нашему проекту
+
+#### Устанавливаем необходимые пакеты:
+
+```
+npm i jsonwebtoken md5
+```
+
+* **jsonwebtoken** - нужен для формирования и проверки токена
+* **md5** - для хеширования пароля
+
+#### Подключаем зависимости
+
+```js
+const md5 = require('md5')
+const jwt = require('jsonwebtoken')
+```
+
+#### Задаём подпись
+
+Этот параметр тоже нельзя светить, поэтому считываем из переменных окружения (добавьте в `jaunch.json`)
+
+```js
+const JWT_SECRET = process.env.JWT_SECRET
+```
+
+#### Реализуем метод авторизации
+
+```js
+/**
+ * В теле запроса должен быть объект с логином и паролем:
+ * {
+ *  "login": "ваш логин",
+ *  "password": "пароль"
+ * }
+ */
+app.post('/api/user/login', async (req, res) => {
+  try {
+    // const user = await sequelize.query(`
+    //   SELECT *
+    //   FROM User
+    //   WHERE login=:login
+    // `, {
+    //   // параметр plain нужен, чтобы запрос вернул не массив записей, а конкретную запись
+    //   // если записи с таким логином нет, то вернет null
+    //   plain: true,
+    //   logging: false,
+    //   type: QueryTypes.SELECT,
+    //   replacements: {
+    //     login: req.body.login
+    //   }
+    // })
+
+    // у меня нет таблицы User, поэтому я сделал заглушку
+    // вам нужно брать данные для авторизации из БД
+    const user = {
+      password: md5('123456'),
+      id: 1,
+      roleId: 1
+    }
+
+    if (user) {
+      // хешируем пароль
+      const passwordMD5 = md5(req.body.password)
+
+      if (user.password == passwordMD5) {
+
+        // формируем токен
+        const jwtToken = jwt.sign({
+            id: user.id, 
+            firstName: user.firstName,
+            roleId: user.roleId 
+          }, 
+          JWT_SECRET
+        )
+
+        res.json(jwtToken)
+      } else {
+        res.status(401).send('не верный пароль')
+      }
+    } else {
+      res.status(404).send('пользователь не найден')
+    }
+  } catch (error) {
+    console.warn('ошибка при авторизации:', error.message)
+    res.status(500).send(error.message)
+  } finally {
+    res.end()
+  }
+})
+```
+
+#### Реализуем аутентификацию пользователя в запросе корзины, используя middleware
+
+**Middleware** функция принимает на входе параметры `req`, `res`, `next`, где `req`, `res` соответственно запрос и ответ, а `next` нужно вызвать, если всё нормально и можно вызывать следующую middleware функцию (их может быть несколько)
+
+```js
+/**
+ * Middleware авторизации
+ */
+const authenticateJWT = (req, res, next) => {
+  const authHeader = req.headers.authorization
+
+  if (authHeader) {
+    const token = authHeader.split(' ')
+
+    if (token[0].toLowerCase() != 'bearer')
+      return res.status(400).send('не поддерживаемый тип авторизации')
+
+    jwt.verify(token[1], JWT_SECRET, (err, data) => {
+      // если в результате есть ошибка (err)
+      // то возвращаем статус forbidden
+      if (err) return res.status(403).send(err)
+
+      // иначе сохраняем полезные данные в объект user запроса
+      req.user = data
+      next()
+    })
+  } else {
+    res.status(401).send('нет заголовка авторизации')
+  }
+}
+```
+
+Теперь добавляем эту функцию в запрос получения корзины (обратите внимание, у меня в бд нет пользователей, поэтому я закомментировал часть кода, в вашем проекте нужно раскомментировать):
+
+```js
+/**
+ * Вторым параметром запроса можно добавить массив middleware
+ */
+app.get('/api/cart', [authenticateJWT], async (req, res) => {
+  try {
+    res.json(await sequelize.query(`
+      SELECT *
+      FROM Cart
+      -- в моей БД нет пользователей
+      -- WHERE userId=:userId
+    `, {
+      logging: false,
+      type: QueryTypes.SELECT
+      // replacements: {
+      //   userId: req.user.id
+      //               ^^^^ обратите внимание, тут используется объект user, заполненный в middleware аутентификации
+      // }
+    }))
+  } catch (error) {
+    console.error(error)
+  } finally {
+    res.end()
+  }
+})
+```
+
 ## Задание
 
 Реализовать АПИ для своего курсового проекта по образцу. В следующем году постараюсь написать лекции по DevOps (завернём АПИ, базу и приложение в контейнеры).

+ 137 - 0
api/package-lock.json

@@ -10,6 +10,8 @@
       "license": "ISC",
       "dependencies": {
         "express": "^5.1.0",
+        "jsonwebtoken": "^9.0.2",
+        "md5": "^2.3.0",
         "mysql2": "^3.14.1",
         "sequelize": "^6.37.7",
         "sequelize-cli": "^6.6.3"
@@ -183,6 +185,12 @@
         "balanced-match": "^1.0.0"
       }
     },
+    "node_modules/buffer-equal-constant-time": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -221,6 +229,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/charenc": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+      "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/cliui": {
       "version": "7.0.4",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -395,6 +412,15 @@
         "node": ">= 8"
       }
     },
+    "node_modules/crypt": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+      "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/debug": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -456,6 +482,15 @@
       "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
       "license": "MIT"
     },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/editorconfig": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
@@ -856,6 +891,12 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/is-buffer": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+      "license": "MIT"
+    },
     "node_modules/is-core-module": {
       "version": "2.16.1",
       "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -955,12 +996,97 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/jsonwebtoken": {
+      "version": "9.0.2",
+      "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+      "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "jws": "^3.2.2",
+        "lodash.includes": "^4.3.0",
+        "lodash.isboolean": "^3.0.3",
+        "lodash.isinteger": "^4.0.4",
+        "lodash.isnumber": "^3.0.3",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.isstring": "^4.0.1",
+        "lodash.once": "^4.0.0",
+        "ms": "^2.1.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/jwa": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+      "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-equal-constant-time": "^1.0.1",
+        "ecdsa-sig-formatter": "1.0.11",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/jws": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+      "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+      "license": "MIT",
+      "dependencies": {
+        "jwa": "^1.4.1",
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "license": "MIT"
     },
+    "node_modules/lodash.includes": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isboolean": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isinteger": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isnumber": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isstring": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+      "license": "MIT"
+    },
     "node_modules/long": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -997,6 +1123,17 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/md5": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+      "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "charenc": "0.0.2",
+        "crypt": "0.0.2",
+        "is-buffer": "~1.1.6"
+      }
+    },
     "node_modules/media-typer": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",

+ 2 - 0
api/package.json

@@ -11,6 +11,8 @@
   },
   "dependencies": {
     "express": "^5.1.0",
+    "jsonwebtoken": "^9.0.2",
+    "md5": "^2.3.0",
     "mysql2": "^3.14.1",
     "sequelize": "^6.37.7",
     "sequelize-cli": "^6.6.3"