JWT: назначение, структура и принцип работы

Обмен данными между клиентом и сервером является фундаментальным аспектом большинства веб-приложений. Одним из способов обеспечить безопасный обмен данными является использование JSON Web Tokens (JWT). JWT представляет собой закодированную строку, которая содержит некоторые данные, позволяющие определить подлинность запроса и безопасно передать информацию между сторонами. В статье разберем структуру и принцип работы JWT, а также рассмотрим его применение на практическом примере.

Структура JWT

JWT состоит из трёх основных частей, которые разделены между собой точками. Эти части называются header (заголовок), payload (полезная нагрузка) и signature (подпись). Схематично структуру токена можно изобразить так:

Структура JWT

Разберем каждую часть.

Заголовок содержит JSON-объект с метаданными, которые необходимы для вычисления подписи (signature):

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

Эти метаданные включают тип токена (type), который равен JWT, а также используемый алгоритм шифрования (alg). Обычно таким алгоритмом является HMAC SHA256 (HS256) или RSA. HMAC SHA-256 использует симметричный ключ, что делает его быстрее. RSA SHA-256 (RS256), с другой стороны, основан на асимметричных ключах, что позволяет достичь более высокого уровня защиты.

Payload

В payload содержится основная информация (клеймы, claims), которую необходимо передать.

Существуют зарезервированные клеймы, стандартизированные в спецификации, такие как iss (издатель токена), exp (время истечения срока действия токена), sub (тема токена), aud (получатель токена) и другие. Они не являются обязательными, но широко используются. Эти клеймы помогают управлять жизненным циклом токена и его назначением. Полный список зарезервированных клеймов можно найти, например, здесь.

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

{
    "user_id": 26,
    "role": "admin",
    "theme": "dark"
}

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

Signature

Подпись JWT создаётся для обеспечения целостности данных токена и подтверждения его подлинности.

Процесс создания подписи:

  • Header и payload кодируются с помощью алгоритма Base64Url.
  • Это закодированные части токена присоединяются друг к другу с помощью точки.
  • Полученная строка подписывается с использованием секретного ключа и указанного в заголовке алгоритма (например, HS256 или RS256).
// HMACSHA256 — выбранный алгоритм подписи (может быть и другим, например, RSA).
// secret — секретный ключ, известный только серверу

HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    secret
)

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

Принцип работы JWT

Принцип коммуникации между сервером и клиентом с помощью JWT происходит следующим образом:

  1. Пользователь отправляет запрос со своими учетными данными (например, логином и паролем) на сервер.
  2. Сервер проверяет переданные данные. Если они верны, он генерирует JWT и отправляет токен клиенту.
  3. Клиент получает JWT и сохраняет его в локальном хранилище либо cookies.
  4. При каждом последующем запросе на сервер клиент добавляет токен в HTTP-заголовок (обычно в Authorization: Bearer ).
  5. Сервер декодирует полученный JWT и проверяет его подпись с помощью секретного ключа. Если подпись корректна и токен действителен, запрос считается авторизованным и сервер выполняет его.

Отметим, что JSON Web Token не шифрует данные, а лишь подписывает их. Это означает, что содержимое токена доступно в открытом виде (если не используется дополнительное шифрование), но его нельзя подделать без знания секретного ключа. Любой желающий может декодировать токен, например, с помощью онлайн-инструмента jwt.io, и увидеть его payload. Поэтому не следует передавать в JWT конфиденциальные данные, такие как пароли или платежные реквизиты, без дополнительной защиты.

Пример авторизации на PHP

Продемонстрируем авторизацию запроса на практическом примере. Создадим форму авторизации. При успешном входе в систему на странице отобразится блок с кнопкой «Отобразить», при нажатии на которую пользователь сможет увидеть информацию о своем платежном балансе.

Технически это будет реализовано следующим образом. Пользователь вводит свои данные в форму и отправляет на сервер. В случае успешной авторизации сервер создает JWT и возвращает клиенту. Теперь при нажатии на кнопку «Отобразить» клиент отправляет запрос с содержащимся в заголовке JWT. Сервер валидирует токен и в случае успеха возвращает информацию о балансе.

Структура примера будет выглядеть так:

Структура проекта JWT

Для генерации и проверки JWT нам понадобится библиотека firebase/php-jwt. Мы установим ее с помощью Composer. После установки Composer создаст нам файлы composer.json и composer.lock, а также папку vendor с необходимыми зависимостями.

Для простоты мы не будем хранить информацию о пользователях в базе данных, а создадим для них файл users.php, который будет возвращаться массив пользователей с их данными:

<?php
return [
    'admin' => [
        'id' => 1001,
        'password' => password_hash('123456', PASSWORD_DEFAULT),
        'role' => 'admin',
        'balance' => 1250.75,
        'card_last4' => '**** **** **** 4321',
        'last_transaction' => '2025-03-10 14:23:33'
    ],
    'user' => [
        'id' => 2002,
        'password' => password_hash('qwerty', PASSWORD_DEFAULT),
        'role' => 'user',
        'balance' => 4280.24,
        'card_last4' => '**** **** **** 2573',
        'last_transaction' => '2025-03-12 11:46:12'
    ],
];

Файл jwt.php будет содержать функции для генерации и валидации JWT:

<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// Функция генерации JWT
function generateJwt(array $user): string
{
    // Получаем секретный ключ и тип алгоритма из конфигурационного файла
    $config = require 'config.php';

    // Создаем массив данных для хранения в JWT, включая данные пользователя
    $payload = [
        'iat' => time(),
        'exp' => time() + 3600,
        'user' => $user
    ];

    // Генерируем JWT и возвращаем
    return JWT::encode($payload, $config['secret_key'], $config['algorithm']);
}

// Функция извлечения данных из JWT
function verifyJwt(string $token): ?stdClass
{
    // Получаем секретный ключ и тип алгоритма из конфигурационного файла
    $config = require 'config.php';

    try {
        // Извлекаем данные из JWT и возвращаем
        return JWT::decode($token, new Key($config['secret_key'], $config['algorithm']));
    } catch (Exception $e) {
        return null;
    }
}

Эти функции используют секретный ключ и алгоритм подписи, которые находятся в файле конфигурации config.php:

<?php
return [
    'secret_key' => '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
    'algorithm'  => 'HS256',
];

Главный файл index.php будет содержать HTML-разметку формы авторизации и блоков для отображения платежного баланса. Для базовой стилизации подключим библиотеку Bootstrap в шапке страницы. Выполнять запросы будем асинхронно с помощью Fetch API:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
    <title>JWT Авторизация</title>
</head>
<body class="bg-light">

<div class="container mt-5">

    <div class="row justify-content-center">
        <div class="col-md-5">
            <div class="card p-4 shadow-sm" id="loginCard">
                <h3 class="mb-3">Авторизация</h3>
                <div class="mb-3">
                    <input type="text" id="username" class="form-control" placeholder="логин">
                </div>
                <div class="mb-3">
                    <input type="password" id="password" class="form-control" placeholder="пароль">
                </div>
                <button id="loginBtn" class="btn btn-primary">Войти</button>
            </div>

            <div class="card p-4 shadow-sm mt-4 d-none" id="logoutCard">
                <button id="logoutBtn" class="btn btn-secondary mt-2">Выйти</button>
            </div>

            <div class="card p-4 shadow-sm mt-4 d-none" id="userCard">
                <h5>Баланс</h5>
                <p id="userData"></p>
                <button id="getData" class="btn btn-success mt-3">Отобразить</button>
                <p class="mt-3 p-3 bg-dark text-white rounded" id="result" style="display: none;"></p>
            </div>
        </div>
    </div>

</div>

<script>
    // Обработчик нажатия на кнопку "Войти"
    document.getElementById('loginBtn').addEventListener('click', async () => {
        // Получаем данные пользователя из формы
        const username = document.getElementById('username').value;
        const password = document.getElementById('password').value;

        // Отправляем на сервер и получаем ответ
        const response = await fetch('login.php', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username, password })
        });

        const data = await response.json();

        // Если в ответе содержится сгенерированный токен, значит, авторизация прошла успешно
        if (data.token) {
            // Сохраняем JWT и имя пользователя в локальном хранилище
            localStorage.setItem('demo_jwt', data.token);
            localStorage.setItem('demo_user_name', data.user.username);

            // Показываем нужный UI при авторизованном пользователе
            setUIByAuthorizationStatus();
        } else {
            alert('Неверные данные авторизации');
        }
    });

    // Обработчик нажатия на кнопку "Отобразить"
    document.getElementById('getData').addEventListener('click', async () => {
        const token = localStorage.getItem('demo_jwt');
        if (!token) {
            alert('Сперва необходимо пройти авторизацию');
            return;
        }

        // Устанавливаем токен в заголовок, отправляем запрос на сервер и получаем ответ
        const response = await fetch('protected.php', {
            headers: { 'Authorization': `Bearer ${token}` }
        });

        const data = await response.json();

        // Если запрос успешно обработан, показываем данные баланса
        if (response.status === 200) {
            document.getElementById('result').style.display = 'block';
            document.getElementById('result').innerHTML = `
            <ul class="list-group mt-2">
                <li class="list-group-item"><strong>Balance:</strong> $${data.data.balance}</li>
                <li class="list-group-item"><strong>Card:</strong> ${data.data.card_last4}</li>
                <li class="list-group-item"><strong>Last Transaction:</strong> ${data.data.last_transaction}</li>
            </ul>
        `;
        } else {
            alert('Доступ запрещен');
        }
    });

    // Отобразим блоки правильно при перезагрузке страницы
    window.addEventListener('DOMContentLoaded', () => {
        setUIByAuthorizationStatus();
    });

    // Очистим локальное хранилище при нажатии на кнопку "Выйти" и перезагрузим страницу
    document.getElementById('logoutBtn').addEventListener('click', () => {
        localStorage.removeItem('demo_jwt');
        localStorage.removeItem('demo_user_name');
        location.reload();
    });

    // Функция для отображения блоков, в зависимости от статуса авторизации пользователя
    function setUIByAuthorizationStatus() {
        const token = localStorage.getItem('demo_jwt');
        const userName = localStorage.getItem('demo_user_name');
        if (token) {
            document.getElementById('loginCard').classList.add('d-none');
            document.getElementById('logoutCard').classList.remove('d-none');
            document.getElementById('userCard').classList.remove('d-none');
            document.getElementById('userData').innerHTML = `
                Пользователь: ${userName}
            `;
        } else {
            document.getElementById('loginCard').classList.remove('d-none');
            document.getElementById('logoutCard').classList.add('d-none');
            document.getElementById('userCard').classList.add('d-none');
            document.getElementById('userData').innerHTML = '';
        }
    }
</script>

</body>
</html>

Для обработки процесса входа пользователя в систему будем использовать файл login.php:

<?php
require 'jwt.php';

// В демонстрационных целях возьмем информацию о пользователях из файла вместо базы данных
$users = require 'users.php';

// Получаем данные из POST-запроса
$data = json_decode(file_get_contents('php://input'), true);
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';

// Валидируем имя пользователя и пароль
if (isset($users[$username]) && password_verify($password, $users[$username]['password'])) {
    // При успешной валидации подготавливаем данные для JWT
    $userData = [
        'id' => $users[$username]['id'],
        'username' => $username,
        'role' => $users[$username]['role'],
    ];

    // Генерируем JWT
    $token = generateJwt($userData);

    // Возвращаем данные пользователя с токеном
    echo json_encode([
        'token' => $token,
        'user' => $userData
    ]);
} else {
    // Если данные авторизации не валидны, возвращаем ошибку
    http_response_code(401);
    echo json_encode(['message' => 'Неверные данные авторизации']);
}

Чтобы получить данные о балансе, будем посылать запрос на protected.php, где будем верифицировать пользователя по JWT:

<?php
// Указываем, что будем возвращать JSON
header('Content-Type: application/json');

// Подключаем функции для работы с JWT
require 'jwt.php';

// Вместо базы данных берем данные пользователей из файла в демонстрационных целях
$users = require 'users.php';

// Проверяем наличие заголовка Authorization
$headers = getallheaders();
if (!isset($headers['Authorization'])) {
    http_response_code(401);
    echo json_encode(['message' => 'Отсутствует токен']);
    exit;
}

// Если заголовок Authorization присутствует, разбиваем его на тип (Bearer) и сам токен
list($type, $token) = explode(' ', $headers['Authorization'], 2);

// Если схема авторизации не Bearer, возвращаем ошибку 401 (Unauthorized)
if (strcasecmp($type, 'Bearer') !== 0) {
    http_response_code(401);
    echo json_encode(['message' => 'Неверный тип токена']);
    exit;
}

try {
    // Извлекаем даные из токена
    $decoded = verifyJwt($token);

    // Получаем имя пользователя
    $username = $decoded->user->username;

    // Берем нужные данные из файла с пользователями и подготавливаем ответ
    $protectedData = [
        'balance' => $users[$username]['balance'],
        'card_last4' => $users[$username]['card_last4'],
        'last_transaction' => $users[$username]['last_transaction']
    ];

    // Возвращаем данные
    echo json_encode(['data' => $protectedData]);
} catch (Exception $e) {
    // Если токен не прошел валидацию, возвращаем ошибку 401 (Unauthorized)
    http_response_code(401);
    echo json_encode(['message' => 'Неверный токен', 'error' => $e->getMessage()]);
}

Главная страница до авторизации выглядит так:

Форма авторизации

После процесса авторизации и нажатия на кнопку «Отобразить» результат будет следующим:

Отображение баланса

Заключение

JSON Web Token — это компактный и безопасный способ передачи информации между сторонами. Он состоит из трех частей: заголовка, полезной нагрузки (payload) и подписи, обеспечивающей целостность данных. Важно понимать, что JWT не шифрует, а лишь подписывает данные, поэтому его нельзя использовать для хранения конфиденциальной информации без дополнительного шифрования. JWT удобен для аутентификации и передачи данных в API, но требует внимательного управления сроком действия и хранения токена на клиенте для обеспечения безопасности.

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