Рубрики
Без рубрики

Аутентификация и авторизация с помощью JWTs в Express.js

JWT очень популярны для обработки HTTP-аутентификации и авторизации, что мы будем делать с помощью Node.js и Express в этой статье.

Введение

В этой статье мы поговорим о том, как работают JSON Web Tokens, в чем их преимущества, какова их структура и как использовать их для базовой аутентификации и авторизации в Express.

Вам не обязательно иметь опыт работы с JSON Web Tokens (JWT), поскольку мы будем говорить об этом с нуля.

Для раздела, посвященного реализации, будет предпочтительнее, если у вас есть опыт работы с Express, Javascript ES6 и REST-клиентами.

Что такое веб-токены JSON?

JSON Web Tokens (JWT) были введены в качестве метода безопасного обмена данными между двумя сторонами. Он был представлен в спецификации RFC 7519, разработанной рабочей группой инженеров Интернета (IETF).

Хотя мы можем использовать JWT с любым методом связи, сегодня JWT очень популярен для обработки аутентификации и авторизации через HTTP.

Для начала вам нужно знать несколько особенностей HTTP.

HTTP – это протокол без статических данных, что означает, что HTTP-запрос не сохраняет состояние. Сервер не знает о предыдущих запросах, отправленных тем же клиентом.

HTTP-запросы должны быть самодостаточными. Они должны содержать информацию о предыдущих запросах пользователя в самом запросе.

Существует несколько способов сделать это, однако наиболее популярным способом является установка идентификатора сессии, который является ссылкой на информацию о пользователе.

Сервер будет хранить этот идентификатор сессии в памяти или в базе данных. Клиент будет отправлять каждый запрос с этим идентификатором сессии. Затем сервер может получить информацию о клиенте, используя эту ссылку.

Вот схема того, как работает аутентификация на основе сеансов:

session_based_authentication.

Обычно этот идентификатор сессии отправляется пользователю в виде cookie. Мы уже подробно обсуждали это в нашей предыдущей статье Обработка аутентификации в Express.js.

С другой стороны, при использовании JWT, когда клиент посылает запрос на аутентификацию серверу, тот отправляет обратно JSON-токен, который включает всю информацию о пользователе вместе с ответом.

Клиент будет отправлять этот токен вместе со всеми последующими запросами. Таким образом, серверу не придется хранить информацию о сессии. Но в таком подходе есть проблема. Любой человек может отправить поддельный запрос с поддельным JSON-токеном и выдать себя за того, кем он не является.

Допустим, после аутентификации сервер отправляет обратно клиенту объект JSON с именем пользователя и временем истечения срока действия. Так как объект JSON доступен для чтения, любой может отредактировать эту информацию и отправить запрос. Проблема в том, что нет способа проверить такой запрос.

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

Мы рассмотрим это более подробно позже в этой статье.

Вот схема того, как работает JWT:

JSON WEB TOKENS.

Структура JWT

Давайте поговорим о структуре JWT через токен образец:

sample_json_web_token_jwt.

Как видно на изображении, в этом JWT есть три раздела, каждый из которых разделен точкой.

Примечание: Кодирование Base64 – это один из способов обеспечения сохранности данных, поскольку оно не сжимает и не шифрует данные, а просто кодирует их в понятном большинству систем виде. Вы можете прочитать любой текст в кодировке Base64, просто декодировав его.

Первый раздел JWT – это заголовок, который представляет собой строку в кодировке Base64. Если вы расшифруете заголовок, он будет выглядеть примерно так:

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

Раздел заголовка содержит алгоритм хэширования, который был использован для генерации знака и тип токена.

Второй раздел – полезная нагрузка, содержащая объект JSON, который был отправлен пользователю. Поскольку он закодирован только в Base64, его может легко декодировать любой.

Рекомендуется не включать в JWT какие-либо конфиденциальные данные, такие как пароли или личная информация.

Обычно тело JWT выглядит примерно так, хотя это не обязательно соблюдается:

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

В большинстве случаев свойство sub будет содержать идентификатор пользователя, свойство iat, которое сокращенно называется issued at, – это временная метка, когда токен был выпущен.

Вы также можете увидеть некоторые общие свойства, такие как eat или exp – время истечения срока действия токена.

Последний раздел – это подпись токена. Она генерируется путем хэширования строки base64UrlEncode(header) + “.”. + base64UrlEncode(payload) + secret с использованием алгоритма, указанного в разделе заголовка.

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

Когда эта подпись отправляется обратно на сервер, он может проверить, что клиент не изменил никаких деталей в объекте.

Согласно стандартам, клиент должен отправить этот токен на сервер через HTTP-запрос в заголовке Authorization с формой Bearer [JWT_TOKEN]. Таким образом, значение заголовка Authorization будет выглядеть примерно так:

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o

Если вы хотите прочитать больше о структуре токена JWT, вы можете ознакомиться с нашей подробной статьей “Понимание веб-токенов JSON”. Вы также можете посетить сайт jwt.io и поиграть с их отладчиком:

jwt_debugger.

Преимущество использования JWT перед традиционными методами

Как мы уже говорили ранее, JWT может содержать всю информацию о самом пользователе, в отличие от аутентификации на основе сеанса.

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

web_application_architecture.

Все эти сервисы могут быть одним и тем же сервисом, который будет перенаправляться балансировщиком нагрузки в соответствии с использованием ресурсов (CPU или Memory Usage) каждого сервера, или некоторыми различными сервисами, такими как аутентификация и т.д.

Если мы используем традиционные методы авторизации, такие как cookies, нам придется использовать общую базу данных, например Redis, для обмена сложной информацией между серверами или внутренними службами. Но если мы поделимся секретом между микросервисами, мы можем просто использовать JWT, и тогда для авторизации пользователей не потребуется никаких других внешних ресурсов.

Использование JWT с Express

В этом уроке мы создадим простое веб-приложение на основе микросервиса для управления книгами в библиотеке с двумя сервисами. Один сервис будет отвечать за аутентификацию пользователей, а другой – за управление книгами.

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

Чтобы начать работу, в терминале инициализируйте пустой проект Node.js с настройками по умолчанию:

$ npm init -y

Затем установим фреймворк Express:

$ npm install --save express

Служба аутентификации

Затем создадим файл под названием auth.js, который будет нашей службой аутентификации:

const express = require('express');
const app = express();

app.listen(3000, () => {
    console.log('Authentication service started on port 3000');
});

В идеале мы должны использовать базу данных для хранения информации о пользователях. Но для простоты давайте создадим массив пользователей, который мы будем использовать для их аутентификации.

Для каждого пользователя будет определена роль – admin или member, привязанная к его объекту user. Также не забудьте хэшировать пароль, если вы работаете в производственной среде:

const users = [
    {
        username: 'john',
        password: 'password123admin',
        role: 'admin'
    }, {
        username: 'anna',
        password: 'password123member',
        role: 'member'
    }
];

Теперь мы можем создать обработчик запроса для входа пользователя в систему. Установим модуль jsonwebtoken, который используется для генерации и проверки JWT-токенов.

Также установим промежуточное ПО body-parser для разбора тела JSON из HTTP-запроса:

$ npm i --save body-parser jsonwebtoken

Теперь давайте установим эти модули и настроим их в приложении Express:

const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

app.use(bodyParser.json());

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

const accessTokenSecret = 'youraccesstokensecret';

Это ваш секрет для подписи токена JWT. Вы никогда не должны делиться этим секретом, иначе злоумышленник может использовать его для подделки JWT-токенов, чтобы получить несанкционированный доступ к вашему сервису. Чем сложнее этот токен доступа, тем более безопасным будет ваше приложение. Поэтому старайтесь использовать для этого токена сложную случайную строку:

app.post('/login', (req, res) => {
    // Read username and password from request body
    const { username, password } = req.body;

    // Filter user from the users array by username and password
    const user = users.find(u => { return u.username === username && u.password === password });

    if (user) {
        // Generate an access token
        const accessToken = jwt.sign({ username: user.username,  role: user.role }, accessTokenSecret);

        res.json({
            accessToken
        });
    } else {
        res.send('Username or password incorrect');
    }
});

В этом обработчике мы выполнили поиск пользователя, который соответствует имени пользователя и паролю в теле запроса. Затем мы сгенерировали маркер доступа с объектом JSON с именем пользователя и ролью пользователя.

Наша служба аутентификации готова. Давайте загрузим его, выполнив команду :

$ node auth.js

После того как служба аутентификации запущена, давайте отправим POST-запрос и проверим, работает ли он.

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

Давайте отправим post-запрос на конечную точку http://localhost:3000/login со следующим JSON:

{
    "username": "john",
    "password": "password123admin"
}

В качестве ответа вы должны получить маркер доступа:

{
  "accessToken": "eyJhbGciOiJIUz..."
}
Insomnia_access.

Сервис книг

После этого давайте создадим файл books.js для нашего сервиса книг.

Начнем с импорта необходимых библиотек и настройки приложения Express:

const express = require('express');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');

const app = express();

app.use(bodyParser.json());

app.listen(4000, () => {
    console.log('Books service started on port 4000');
});

После настройки, чтобы имитировать базу данных, давайте просто создадим массив книг:

const books = [
    {
        "author": "Chinua Achebe",
        "country": "Nigeria",
        "language": "English",
        "pages": 209,
        "title": "Things Fall Apart",
        "year": 1958
    },
    {
        "author": "Hans Christian Andersen",
        "country": "Denmark",
        "language": "Danish",
        "pages": 784,
        "title": "Fairy tales",
        "year": 1836
    },
    {
        "author": "Dante Alighieri",
        "country": "Italy",
        "language": "Italian",
        "pages": 928,
        "title": "The Divine Comedy",
        "year": 1315
    },
];

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

app.get('/books', (req, res) => {
    res.json(books);
});

Поскольку наши книги должны быть видны только аутентифицированным пользователям. Мы должны создать промежуточное ПО для аутентификации.

Перед этим создайте секрет маркера доступа для подписания JWT, как и раньше:

const accessTokenSecret = 'youraccesstokensecret';

Этот токен должен быть тем же самым, который используется в службе аутентификации. Благодаря тому, что секрет между ними общий, мы можем аутентифицироваться с помощью службы аутентификации, а затем авторизовать пользователей в службе book.

На этом этапе давайте создадим промежуточное ПО Express, которое будет обрабатывать процесс аутентификации:

const authenticateJWT = (req, res, next) => {
    const authHeader = req.headers.authorization;

    if (authHeader) {
        const token = authHeader.split(' ')[1];

        jwt.verify(token, accessTokenSecret, (err, user) => {
            if (err) {
                return res.sendStatus(403);
            }

            req.user = user;
            next();
        });
    } else {
        res.sendStatus(401);
    }
};

В этом промежуточном ПО мы считываем значение заголовка authorization. Поскольку заголовок авторизации имеет значение в формате Bearer [JWT_TOKEN], мы разделили значение пробелом и выделили токен.

Затем мы проверили токен с помощью JWT. После проверки мы присоединяем объект пользователя к запросу и продолжаем. В противном случае мы отправим клиенту ошибку.

Мы можем настроить это промежуточное ПО в обработчике запроса GET следующим образом:

app.get('/books', authenticateJWT, (req, res) => {
    res.json(books);
});

Давайте загрузим сервер и проверим, все ли работает правильно:

$ node books.js

Теперь мы можем отправить запрос на конечную точку http://localhost:4000/books, чтобы получить все книги из базы данных.

Обязательно измените заголовок “Authorization“, чтобы он содержал значение “Bearer [JWT_TOKEN]“, как показано на рисунке ниже:

Insomnia_get_books.

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

Для этого мы можем использовать промежуточное ПО аутентификации, которое мы использовали выше:

app.post('/books', authenticateJWT, (req, res) => {
    const { role } = req.user;

    if (role !== 'admin') {
        return res.sendStatus(403);
    }


    const book = req.body;
    books.push(book);

    res.send('Book added successfully');
});

Поскольку промежуточное ПО аутентификации привязывает пользователя к запросу, мы можем получить роль из объекта req.user и просто проверить, является ли пользователь администратором. Если да, то книга будет добавлена, в противном случае будет выдана ошибка.

Давайте попробуем это сделать с помощью нашего REST-клиента. Войдите в систему как пользователь admin (используя тот же метод, что и выше), затем скопируйте accessToken и отправьте его с заголовком Authorization, как мы делали в предыдущем примере.

Затем мы можем отправить POST-запрос на конечную точку http://localhost:4000/books:

{
    "author": "Jane Austen",
    "country": "United Kingdom",
    "language": "English",
    "pages": 226,
    "title": "Pride and Prejudice",
    "year": 1813
}
Insomnia_add_book.add_book.

Обновление токена

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

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

Чтобы устранить эту возможность, давайте обновим обработчик запроса на вход, чтобы срок действия токена истекал через определенный период. Мы можем сделать это, передав свойство expiresIn в качестве опции для подписи JWT.

Когда мы истекает срок действия токена, у нас также должна быть стратегия генерации нового токена на случай истечения срока действия. Для этого мы создадим отдельный JWT-токен, называемый refresh-токеном, который можно использовать для генерации нового.

Сначала создайте секрет маркера обновления и пустой массив для хранения маркеров обновления:

const refreshTokenSecret = 'yourrefreshtokensecrethere';
const refreshTokens = [];

Когда пользователь входит в систему, вместо генерации одного токена генерируют как токены обновления, так и аутентификации:

app.post('/login', (req, res) => {
    // read username and password from request body
    const { username, password } = req.body;

    // filter user from the users array by username and password
    const user = users.find(u => { return u.username === username && u.password === password });

    if (user) {
        // generate an access token
        const accessToken = jwt.sign({ username: user.username, role: user.role }, accessTokenSecret, { expiresIn: '20m' });
        const refreshToken = jwt.sign({ username: user.username, role: user.role }, refreshTokenSecret);

        refreshTokens.push(refreshToken);

        res.json({
            accessToken,
            refreshToken
        });
    } else {
        res.send('Username or password incorrect');
    }
});

А теперь давайте создадим обработчик запроса, который генерирует новые токены на основе обновленных токенов:

app.post('/token', (req, res) => {
    const { token } = req.body;

    if (!token) {
        return res.sendStatus(401);
    }

    if (!refreshTokens.includes(token)) {
        return res.sendStatus(403);
    }

    jwt.verify(token, refreshTokenSecret, (err, user) => {
        if (err) {
            return res.sendStatus(403);
        }

        const accessToken = jwt.sign({ username: user.username, role: user.role }, accessTokenSecret, { expiresIn: '20m' });

        res.json({
            accessToken
        });
    });
});

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

Чтобы избежать этого, давайте реализуем простую функцию выхода из системы:

app.post('/logout', (req, res) => {
    const { token } = req.body;
    refreshTokens = refreshTokens.filter(token => t !== token);

    res.send("Logout successful");
});

Когда пользователь запрашивает выйти из системы, мы удалите токен обновления из нашего массива. Он гарантирует, чКогда пользователь попросит выйти из системы, мы удалим маркер обновления из нашего массива. Это гарантирует, что когда пользователь выйдет из системы, никто не сможет использовать маркер обновления для создания нового маркера аутентификации.

Заключение

В этой статье мы познакомили вас с JWT и тем, как реализовать JWT с помощью Express. Я надеюсь, что теперь у вас есть часть хороших знаний о том, как работает JWT и как реализовать его в вашем проекте.

Как всегда, исходный код доступен на GitHub.

Оригинал: “https://stackabuse.com/authentication-and-authorization-with-jwts-in-express-js/”