Перейти до основного вмісту

Аутентифікація за допомогою JSON Web Token (JWT)

Тепер ми розглянемо аутентифікацію за допомогою JWT, яка в основному використовується при розробці API, який у свою чергу використовуватиметься веб-додатком на якомусь сучасному фреймворку типу React, Angular, Vue.js або аналогічним фронтенд фреймворком. Веб-додаток буде надсилати jwt-токен з кожним запитом, а значить ми не використовуємо сесію як у попередній локальній стратегії, а просто поміщаємо токен у кожен запит, який ми робимо до API.

Ми розглянемо для нашого API три роути:

  • /registration — Реєстрація нового користувача
  • /login — для отримання jwt-токена
  • /list, — який буде доступний тільки для користувачів, які заходять у систему за допомогою jwt-токену.

Кінцевий результат, який у нас вийде наступний:

Що таке веб-токени JSON?

JSON Web Token (JWT) у своїй найпростішій формі представляє безпечний URL-рядок, що містить закодований об'єкт JSON. JWT – це відкритий промисловий стандарт, повністю описаний RFC 7519, який містить велику кількість деталей, зокрема, про те, як JWT вимагає функцію для забезпечення безпеки створеного маркеру. Давайте подивимося на приклад токена із сайту https://jwt.io/ :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Зверніть увагу, що токен містить три частини, які розділені точкою "." Ці три частини є такими:

header Header містить інформацію, яка визначає алгоритм хешування, щоб його можна було використовувати для правильної розшифровки та перевірки справжності.

{
"alg": "HS256",
"typ": "JWT"
}

payload Ця частина містить інформацію, яку ви бажаєте надіслати за допомогою JWT. Зверніть увагу, що payload не захищена і може бути розшифрована без секретного ключа це звичайне кодування Base64. JWT не призначені для надсилання конфіденційної інформації, такої як паролі або іншої особистої інформації.

{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}

signature Signature об'єднує закодований header та payload із секретним ключем і надійно кодує це з використанням алгоритму хешування, визначеного в header.

Таким чином щоразу, коли створюється токен: header залишиться постійним (якщо ви не зміните алгоритм хешування); payload залишиться постійною, якщо знову ж таки ми не змінимо його зміст, а ми змінюватимемо його вказуючи тривалість життя токена; signature буде шифрувати ці дві частини інформації на основі алгоритму хешування за допомогою секретного ключа Це означає, що якщо ви не створите унікальний секретний ключ або не зміните payload, то signature також залишиться попереднім.

Кодування та декодування JWT

Для створення jwt-токенів ми будемо використовувати пакет npm з ім'ям jsonwebtoken, який дозволить шифрувати та розшифровувати jwt-токени. Наприклад розглянемо наступний код:

const jwt = require('jsonwebtoken');

const payload = { id: 123456, username: 'Larson' };
const secret = 'secret word';
const token = jwt.sign(payload, secret);

console.log(token);

Давайте подивимося, що відбувається. Програма починається з увімкнення модуля jsonwebtoken

const jwt = require('jsonwebtoken');

Потім ми створюємо payload об'єкт.

const payload = { id: 123456, username: 'Larson' };

Цей об'єкт — це те, що ми кодуватимемо всередині токена. Ми створили об'єкт, який містить властивість id зі значенням 123456 і властивість username: 'Larson'. Токен має бути зашифрований (і розшифрований) секретним ключем. Ми створюємо рядок, який буде використовуватися для підпису токена, щоб його не можна було підробити. Тільки сервер знає секретне слово.

const secret = 'secret word';

З набором попередніх умов ми нарешті можемо створити наш токен. Це робиться шляхом виклику функції кодування з модуля jsonwebtoken.

const token = jwt.sign(payload, secret);

Ця функція приймає payload та секретний ключ. Результатом цієї функції є токен, який містить наш закодований header, payload та signature. Останній рядок виводить наш маркер у консоль. Запустивши нашу програму, ми отримаємо наступне:

$ node app.js
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJ1c2VybmFtZSI6IkxhcnNvbiIsImlhdCI6MTYwMjQ2NDkxNX0.73IZBwNckdPPr0j813BBfFMU0ooitR5UrmaQwLaK6AI

Це такий самий токен, який ми взяли із сайту https://jwt.io/. Він містить ті ж три частини (header, payload та signature). Якщо вставити наш токен в пісочницю він декодується

jwt

Давайте додамо у файл app.js декодування токена і теж виведемо його в консоль:

const decode = jwt.decode(token);

console.log(decode);

Тепер, коли ми запускаємо програму, ми отримуємо наступний результат:

$ node app.js
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJ1c2VybmFtZSI6IkxhcnNvbiIsImlhdCI6MTYwMjQ2NTQ5NH0.UOZww-C3SSZJz_I4o0vZNAAkTwFQFPM8cn1HQwnxRU4
{ id: 123456, username: 'Larson', iat: 1602465494 }

Видно, що токен успішно декодований і містить ті ж властивості, що й кодувалися

Для автентифікації токена необхідно використовувати функцію верифікації.

const verify = jwt.verify(token, secret);

console.log(verify);

Значення змінної verify, таке ж, що й у decode, але якщо токен буде підроблений, то буде згенеровано виняток 'JsonWebTokenError: invalid signature'.

Авторизація за допомогою JWT

Давайте повернемося до нашої програми, після того як ми розібралися, що являє собою сам JWT

Стратегія JWT

Почнемо з конфігурації JWT стратегії, за неї відповідає модуль passport-jwt

const passport = require('passport');
const passportJWT = require('passport-jwt');
const User = require('../schemas/user');
require('dotenv').config();
const secret = process.env.SECRET;

const ExtractJWT = passportJWT.ExtractJwt;
const Strategy = passportJWT.Strategy;
const params = {
secretOrKey: secret,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
};

// JWT Strategy
passport.use(
new Strategy(params, function (payload, done) {
User.find({ _id: payload.id })
.then(([user]) => {
if (!user) {
return done(new Error('User not found'));
}
return done(null, user);
})
.catch(err => done(err));
}),
);

Давайте коротко повторимо, що представляє собою стратегія. Це функція проміжного програмного забезпечення, через яку проходять запити, перш ніж вони потраплять до оброблювача маршруту. Якщо стратегія аутентифікації не спрацьовує, це означає, що обробник маршруту не буде викликаний, а відправиться відповідь 401 Unauthorized (Неавторизований).

Стратегія JWT налаштована так, щоб читати JWT-токен із заголовка HTTP Authorization (авторизації) для кожного вхідного запиту. Замість ExtractJWT.fromAuthHeaderAsBearerToken можна визначити інші методи вилучення або навіть написати свій власний. Повний список можна знайти у сховище passport-jwt. Наш поточний спосіб передбачає, що передаватимемо заголовок у такому вигляді

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVmMWYxODhiYzdmMGRiMmJjNDVhZTdkNiIsImlhdCI6MTU5NTg3NDA5MiwiZXhwIjoxNTk1ODc3NjkyfQ.SJuXhjiNrhsZ-9Ikdw7wdkttn-KcLTztd_Rk3kf4elA

Після ключового слова Bearer стоїть пробіл, а потім розташовується наш JWT-токен

Властивість secretOrKey — це секрет, яким буде підписано наші токени. В цілях безпеки ми зберігаємо секретний ключ тільки в змінних оточення, щоб ніхто їх не бачив у коді.

У нашій стратегії ми отримуємо корисне навантаження payload в якій знаходиться id користувача. Потім звертаємося до бази даних і намагаємося знайти користувача з таким id і або ми повертаємо об'єкт користувача, або помилку якщо користувач не був знайдений. Цю конфігурацію підставляємо в основний файл програми

...
require('./config/config-passport')
app.use('/api', routerApi)
...

Роут реєстрації

Створюємо користувача з унікальним email, якщо користувач з таким email існує, повертаємо статус 409 Conflict. Якщо ж ні, успішно його створюємо та зберігаємо до бази даних

router.post('/registration', async (req, res, next) => {
const { username, email, password } = req.body;
const user = await User.findOne({ email });
if (user) {
return res.status(409).json({
status: 'error',
code: 409,
message: 'Email is already in use',
data: 'Conflict',
});
}
try {
const newUser = new User({ username, email });
newUser.setPassword(password);
await newUser.save();
res.status(201).json({
status: 'success',
code: 201,
data: {
message: 'Registration successful',
},
});
} catch (error) {
next(error);
}
});

Одержання токена для авторизації

Якщо у користувача збігся email та пароль, ми генеруємо для нього JWT-токен. Створюємо корисне навантаження payload, в яку поміщаємо id користувача та його username. Після цього ми створюємо токен за допомогою методу jwt.sign. Перший параметр наше корисне навантаження, другий секретне слово, що ми використовували у стратегії авторизації passport-jwt, а ось третій параметр це тривалість життя нашого JWT-токена, коли він протухне (Ви часто можете почути, що токен протух. Це означає, що час життя його минув). Ми вибрали час життя 1 годину, можна було вибрати 1 день - 1d, один тиждень - 1w і так далі, головне, щоб токен не був вічним

router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const user = await User.findOne({ email });

if (!user || !user.validPassword(password)) {
return res.status(400).json({
status: 'error',
code: 400,
message: 'Incorrect login or password',
data: 'Bad request',
});
}

const payload = {
id: user.id,
username: user.username,
};

const token = jwt.sign(payload, secret, { expiresIn: '1h' });
res.json({
status: 'success',
code: 200,
data: {
token,
},
});
});

Закритий маршрут

Тут все просто - обробник маршруту спрацює лише якщо токен буде валідним та за перевірку валідності відповідає проміжне ПЗ auth

router.get('/list', auth, (req, res, next) => {
const { username } = req.user;
res.json({
status: 'success',
code: 200,
data: {
message: `Authorization was successful: ${username}`,
},
});
});

У функції auth за допомогою passport.authenticate ми запускаємо стратегію jwt та перевіряємо отриманий JWT-токен. Якщо користувач не знайден або відбулася помилка ми повертаємо 401 'Unauthorized'. У разі успішного результату ми поміщаємо поточного користувача в req.user і передаємо управління наступному проміжному ПЗ або обробнику за допомогою виклику next()

const auth = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => {
if (!user || err) {
return res.status(401).json({
status: 'error',
code: 401,
message: 'Unauthorized',
data: 'Unauthorized',
});
}
req.user = user;
next();
})(req, res, next);
};

За допомогою цього проміжного ПЗ auth ми можемо закривати доступ до будь-якого нашому маршруту. Все це і є jwt авторизація.

У цьому розділі ми реалізували авторизацію за допомогою Passport, JWT та bcryptjs, інтегрували в існуючий API, і його можна використовувати для будь-якого клієнта.