Продолжаем разработку АПИ для проекта "ресторан" используя express.js.
Содержание:
JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как
idпользователя, егорольи т.д. и подписи (signature).
JWT состоит из трех частей: заголовок header, полезные данные payload и подпись signature. Давайте пройдемся по каждой из них.
Хеадер JWT содержит информацию о том, как должна вычисляться JWT подпись. Хеадер — это тоже JSON объект, который выглядит следующим образом:
{ "alg": "HS256", "typ": "JWT"}
Поле typ не говорит нам ничего нового, только то, что это JSON Web Token. Интереснее здесь будет поле alg, которое определяет алгоритм хеширования. Он будет использоваться при создании подписи. HS256 — не что иное, как HMAC-SHA256, для его вычисления нужен лишь один секретный ключ (более подробно об этом в шаге 3). Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.
Payload — это полезные данные, которые хранятся внутри JWT. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает JWT с информацией об id пользователя — userId.
{ "userId": "b08f86af-35da-48f2-8fab-cef3904660bd" }
Мы положили только одну заявку (claim) в payload. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:
Эти поля могут быть полезными при создании JWT, но они не являются обязательными. Если хотите знать весь список доступных полей для JWT, можете заглянуть в Wiki. Но стоит помнить, что чем больше передается информации, тем больший получится в итоге сам JWT. Обычно с этим не бывает проблем, но все-таки это может негативно сказаться на производительности и вызвать задержки во взаимодействии с сервером.
Подпись вычисляется с использованием следующего псевдо-кода:
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
Теперь, когда у нас есть все три составляющих, мы можем создать наш JWT. Это довольно просто, мы соединяем все полученные элементы в строку через точку.
const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature)
// JWT Token
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM
Вы можете попробовать создать свой собственный JWT на сайте jwt.io.
Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.
Очень важно понимать, что использование JWT НЕ скрывает и не маскирует данные. Причина, почему JWT используются — это проверка, что отправленные данные были действительно отправлены авторизованным источником. Как было продемонстрировано выше, данные внутри JWT закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных. Таким образом закодирование и подпись данных не защищает их. С другой стороны, главная цель шифрования — это защита данных от неавторизованного доступа.
При авторизации по логину/паролю сервер убеждается, что такой пользователь есть, формирует JWT-токен, содержащий полезную информацию и подпись, и возвращает этот токен клиенту (сайт, приложение)
sequenceDiagram
actor Клиент
Клиент->>АПИ: Запрос авторизации по логину/паролю
activate АПИ
%%box purple Запрос к базе данных
%%participant DB@{ "type" : "database" }
%%end
АПИ->>DB: Поиск пользователя по логину
activate DB
alt Пользователь существует
DB->>АПИ: Информация о пользователе
АПИ->>АПИ: Формирование токена
АПИ->>Клиент: JWT-токен
else Пользователя нет в базе
DB->>АПИ: NULL
АПИ->>Клиент: Ошибка 404 (пользователь не найден)
end
deactivate DB
deactivate АПИ
Клиент при последующих запросах добавляет JWT-токен в заголовок запроса:
Authorization: Bearer <JWT-токен>
Сервер, получая JWT-токен уже не лезет в таблицу пользователей, а просто проверяет подпись токена, убеждаясь что она не подделана (ключ подписи есть только у сервера). А затем может использовать полезную информацию из токена в запросах к БД. Таким образом мы экономим на каждом входящем запросе одно обращение к БД (а работа с БД самое медленное действие, вычисление подписи намного дешевле)
sequenceDiagram
actor Клиент
Клиент->>АПИ: Запрос с токеном
activate АПИ
АПИ->>АПИ: Проверка токена
alt Подпись валидная
АПИ->>АПИ: Обработка запроса
АПИ->>Клиент: Ответ на запрос
else Подпись не валидная
АПИ->>Клиент: Ошибка 403 (доступ запрещен)
end
deactivate АПИ
npm i jsonwebtoken md5
const md5 = require('md5')
const jwt = require('jsonwebtoken')
Этот параметр тоже нельзя "светить", поэтому считываем из переменных окружения (добавьте в jaunch.json)
const JWT_SECRET = process.env.JWT_SECRET
/**
* POST /api/user/login
* В теле запроса должен быть объект с логином и паролем:
* {
* "login": "ваш логин",
* "password": "пароль"
* }
*/
router.post('/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
}
})
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 функция принимает на входе параметры req, res, next, где req, res соответственно запрос и ответ, а next нужно вызвать, если всё нормально и можно вызывать следующую middleware функцию (их может быть несколько)
/**
* 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('нет заголовка авторизации')
}
}
Теперь добавляем эту функцию в запрос получения корзины:
/**
* GET /api/cart
* Вторым параметром метода добавлен массив middleware
*/
router.get('/', [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()
}
})
Реализовать авторизацию по JWT-токену в своем АПИ