
JWT — это популярная форма аутентификации пользователей для веб-приложений. Я не буду вдаваться в дебаты о том, следует ли нам использовать JWT для этой цели, потому что это совсем другое обсуждение. Есть варианты использования, которые лучше подходят для использования JWT, чем другие, и я ожидаю, что вы попали сюда после того, как уже определили, что JWT хорошо подходят для того, чего вы надеетесь достичь.
JWT — это удобный способ для нас хранить небольшую часть общедоступной информации, сгенерированной сервером, выступающим в качестве источника правды, который может быть распространен где угодно. Сервер, который создает и подписывает JWT, имеет доступ к закрытому ключу, в то время как открытый ключ необходимо будет распространить на серверы, использующие JWT. Используя асимметричные ключи, мы можем гарантировать, что каждый может прочитать информацию, содержащуюся в JWT, и убедиться, что только сервер аутентификации может подписывать эти JWT.
Для простоты я буду предполагать, что целью подписания и проверки JWT является аутентификация. Хотя JWT можно использовать и по другим причинам, я полагаю, что аутентификация является наиболее широко используемым случаем, и это то, что планирует делать каждый, кто нашел эту статью. Я также собираюсь предположить, что у вас еще ничего из этого не настроено. Я проведу вас через следующее: создание открытых и закрытых ключей, создание JWKS, подписание JWT и, наконец, проверка JWT.
Одной из предпосылок для всего этого является пакет npm jose.
Создание открытых и закрытых ключей
Я видел небольшое количество дебатов в Интернете о том, какие алгоритмы следует использовать для создания ваших ключей. С моей точки зрения (и на момент написания этой статьи) лучший способ сделать это — использовать RSA. Для этого RS256. Есть два способа генерации этих пар ключей: с помощью командной строки и с помощью jose. Лично я предпочитаю использовать jose, но инструменты командной строки столь же эффективны.
Чтобы сгенерировать пары ключей с помощью командной строки, выполните следующие команды.
ssh-keygen -t rsa -b 4096 -m PEM -f jwt.key -N ""
openssl rsa -in jwt.key -pubout -outform PEM -out jwt.key.pub
cat jwt.key
cat jwt.key.pub
Чтобы сгенерировать пары ключей с помощью jose, запустите следующий скрипт или его вариант. Было бы полезно хранить созданные строки в файлах.
const { generateKeyPair } = require('jose/util/generate_key_pair');
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
console.log(publicKeyString);
console.log(privateKeyString);
})();
Оба эти метода дают одинаковый результат. Они генерируют асимметричные ключи RSA. Это то, что позволяет нам правильно подписать JWT.
Закрытый ключ не должен передаваться никому, кроме вашего сервера аутентификации. Закрытый ключ используется для подписи JWT, а потребители JWT используют открытый ключ для проверки того, что JWT поступил с нашего сервера аутентификации, поэтому, если кто-то еще получит к нему доступ, он может притвориться, что он наш сервер аутентификации. Это явно нет-нет.
Все это работает так, что закрытый ключ используется для шифрования JWT. Затем открытый ключ можно использовать для расшифровки JWT. Открытый ключ может расшифровать только то, что зашифровано соответствующим закрытым ключом. Никакой другой открытый ключ не может расшифровать JWT. И наоборот, никакой другой закрытый ключ не может шифровать так же, как наш новый закрытый ключ. Это означает, что всякий раз, когда наш новый открытый ключ используется для успешной проверки JWT, мы получаем информацию о том, что наш сервер аутентификации с самого начала подтвердил подлинность JWT.
Это предпосылка асимметричного шифрования. Хотя нет необходимости знать что-то большее, чем то, что я изложил, не помешает получить более глубокое понимание этой концепции. Асимметричное шифрование — это глубокая кроличья нора, поэтому учитесь в своем собственном темпе.
Вернемся к поставленной задаче. Как вы уже могли догадаться, открытый ключ должен быть выдан. Это позволяет любому убедиться, что предоставленный им JWT действительно поступил с нашего сервера аутентификации. Это хорошая вещь. Хотя допустимо выдавать открытый ключ в виде файла (например, того, что мы только что создали), существует еще один тип «JSON Web Thing», который позволяет нам распространять открытый ключ в масштабе: JWKS.
Создание JWKS
JWKS расшифровывается как JSON Web Key Set и представляет собой удобный способ распространения открытых ключей. Вот пример JWKS в дикой природе. Идея здесь заключается в том, что JWKS могут быть размещены в виде простого файла JSON, чтобы затем к ним можно было получить доступ с помощью соответствующего JWT. Создать их из открытых ключей в jose так же просто, как создать начальные пары ключей.
Если открытый ключ находится в файле (если он был создан с помощью командной строки), то файл необходимо будет прочитать, преобразовать в представление ключа библиотеки Node crypto и преобразовать с помощью jose.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { fromKeyLike } = require('jose/jwk/from_key_like');
const pubKeyPath = path.resolve(__dirname, 'path/to/jwt.key.pub');
const pubKey = fs.readFileSync(pubKeyPath, 'utf8');
const cryptoPublicKey = crypto.createPublicKey(pubKey);
fromKeyLike(cryptoPublicKey).then((publicJwk) => console.log(publicJwk));
Мы также можем создать их непосредственно после создания открытого ключа из jose.
const { generateKeyPair } = require('jose/util/generate_key_pair');
const { fromKeyLike } = require('jose/jwk/from_key_like');
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
console.log(publicKeyString);
console.log(privateKeyString);
const publicJwk = await fromKeyLike(publicKey);
console.log(publicJwk);
})();
В обоих случаях выходные данные должны выглядеть примерно так, как показано на следующем веб-ключе JSON.
{
"kty": "RSA",
"n": "wi5rLEy1U7m8rU8bQn2GeJ_g_XisJesbzI0N0QbYF3BNaEuQUwXOnh2ME8bOyKBrpXLik5AvljKp8HjwKG1x456kueJJoullYEBtrSRydnNaOQmUno1GQEcreCnRBZzodi9kw0YgsQvEfyxGwxI1NYSS8mdCSgT_BjOw5veHFfK-kdbJSa4mBkncKQCImArdAptKuvMciB3uSCfGqq1lZdBnsDR1O4isltDMBCLlAA9LQaXpksvQ99OROp965J0AFn9Vy64mwrhuonZ2c0C_dAAHJ_NSmmzI59xhB5QmCasINzGNNYBqxSzqRxCOpPcXt_D16il7nvsSIZoVuQDp3jsXO5fWONx5HtApO5zm7NpfSv800cFQjgQ8GHPqdVdufpKgAXaMxFlnLhgWP2QHTIpY2Mmy5zbKAEtTraWmYQs-cMhhj7sAzXNk6Wt25r9fyFVhmzGwhNmp4eUiVhiLKRTDuDSsFMaky__mtHcOEUZSvtyUEYQ2fqnHzlsBP4Ai8_Hr6d-qPQLmidR-69U5VQb6ftBGOeivzClSVRDfbKW7jtez2zB39FPx6Wm_FZqR1vBMmSNt1mJH2laIxkIh2qrkMCgLmMlkspZX8r3_VTRfUJcTcMWkzX16O8XzJeuI9YGgupF4K7wmzEmj1qZWgyXCSrB6L3873W8kPEui7lc",
"e": "AQAB"
}
Мы почти там. JWKS — это массив веб-ключей JSON, которые формируют набор ключей. В одном JWKS может быть несколько открытых ключей, которые идентифицируются параметром kid (идентификатор ключа). При этом я когда-либо видел JWKS только с одной записью. Помещение нашего нового веб-ключа JSON в JWKS может выглядеть примерно следующим образом.
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"n": "wi5rLEy1U7m8rU8bQn2GeJ_g_XisJesbzI0N0QbYF3BNaEuQUwXOnh2ME8bOyKBrpXLik5AvljKp8HjwKG1x456kueJJoullYEBtrSRydnNaOQmUno1GQEcreCnRBZzodi9kw0YgsQvEfyxGwxI1NYSS8mdCSgT_BjOw5veHFfK-kdbJSa4mBkncKQCImArdAptKuvMciB3uSCfGqq1lZdBnsDR1O4isltDMBCLlAA9LQaXpksvQ99OROp965J0AFn9Vy64mwrhuonZ2c0C_dAAHJ_NSmmzI59xhB5QmCasINzGNNYBqxSzqRxCOpPcXt_D16il7nvsSIZoVuQDp3jsXO5fWONx5HtApO5zm7NpfSv800cFQjgQ8GHPqdVdufpKgAXaMxFlnLhgWP2QHTIpY2Mmy5zbKAEtTraWmYQs-cMhhj7sAzXNk6Wt25r9fyFVhmzGwhNmp4eUiVhiLKRTDuDSsFMaky__mtHcOEUZSvtyUEYQ2fqnHzlsBP4Ai8_Hr6d-qPQLmidR-69U5VQb6ftBGOeivzClSVRDfbKW7jtez2zB39FPx6Wm_FZqR1vBMmSNt1mJH2laIxkIh2qrkMCgLmMlkspZX8r3_VTRfUJcTcMWkzX16O8XzJeuI9YGgupF4K7wmzEmj1qZWgyXCSrB6L3873W8kPEui7lc",
"e": "AQAB",
"kid": "3d911ijttg0k80u2k74ax0hxeuhnd9njad7oa6nf",
"alg": "RS256",
"key_ops": [
"verify"
]
}
]
}
Есть несколько настроек, которые необходимо сделать (либо программно, либо вручную). Первый и наиболее очевидный заключается в том, что JWKS должен иметь ключ верхнего уровня с именем keys со значением, представляющим собой массив веб-ключей JSON. К сгенерированному веб-ключу JSON добавлены ключи use, kid, alg и key_ops, которые обозначают использование открытого ключа, идентификатор ключа, алгоритм и ключевые операции соответственно.
use может быть либо sig (подпись), либо enc (шифрование). sig указывает, что ключ используется для проверки подписи данных. enc указывает, что ключ используется для шифрования данных.
kid — это уникальная строка, которую я сгенерировал для идентификации веб-ключей JSON в JWKS.
alg идентифицирует алгоритм, предназначенный для использования с ключом. Это то же значение, что было передано в generateKeyPair.
key_ops определяет предполагаемые операции, в которых можно использовать веб-ключ JSON. Некоторые значения для этого массива: sign, verify и encrypt среди прочих. Это ссылка на RFC, определяющий значения для этого ключа.
Как только все это построено, у нас есть JWKS. Я предложил записать эту информацию в файл и разместить его на конечной точке. Также было бы совершенно правильно записать его как BLOB в базу данных и вернуть по запросу.
Подписание JWT
Если мы правильно завершим эту часть, то проверка нашего JWT будет легкой задачей. Если что-то пойдет не так… ну, будем надеяться, что этого не произойдет. Во-первых, давайте вернемся к тому, что мы узнали об асимметричном шифровании в первом разделе. Мы используем наш закрытый ключ для шифрования полезной нагрузки и наш открытый ключ (теперь в форме JWKS) для расшифровки полезной нагрузки. Чтобы подписать наш JWT, этому коду потребуется доступ к приватному ключу, который мы создали в первом разделе. Кроме того, мы можем устанавливать претензии и создавать полезную нагрузку для нашего JWT, как изложено в RFC.
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
const token = await new SignJWT({
myClaim: true,
})
.setProtectedHeader({
typ: 'JWT',
alg: 'RS256',
})
.setIssuer('https://example.com')
.setSubject('uniqueUserId')
.setAudience('myapp.com')
.setExpirationTime('6h')
.setIssuedAt()
.sign(privateKey);
console.log(token);
})();
Зарегистрированный токен должен выглядеть примерно так
eyJhbGciOiJSUzI1NiJ9.eyJteUNsYWltIjp0cnVlLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiYXNkZiIsImF1ZCI6Im15VXNlciIsImV4cCI6MTYyNjg5OTM3MCwiaWF0IjoxNjI2ODc3NzcwfQ.z7RSsY34eyO_sOsebR92M7P2piqoP9vRDw0kp2VUCkU-2ZcGeA2Jvf4GpJDSwmjxSuXSptYwnF-Qvw9A7hb6BH6XmH9ZG3bFLR-UEUjqjAKL5LRleh3EKES2LqVvng89p9xzFsDePTyzzVmc4yWV0fGC1-lMTLAmnDXxhRFIZZdyBbtHoxt7bmgrdCkk8jV0qVy-SoxWb0KvC8A24Pkkb7eWAS1CQDwVxBTWJDa9ixc0-eKSt2xtzw6jL8o_bkoAHJV2Zk1Cu04752Z9eAExdNq3zI6_wQkwap44MR0kpNF2pMPZz6kNLEUECt_QAzobV7WKYuPtkLLKN_67P-OaLg
Полезная нагрузка (это все, что я хочу добавить в JWT) — это часть, которая выглядит как { myClaim: true }. Сервер сам решает, что он хочет добавить, и эта полезная нагрузка будет доступна после проверки JWT на другом сервере. Другими установленными утверждениями являются iss (Эмитент), sub (Тема), aud (Аудитория), exp (Срок действия) и iat (Выдано в). Защищенный заголовок говорит сам за себя, но в RFC есть дополнительная информация и пример.
iss должен быть установлен на URL вашей службы. Например, Crow Authentication устанавливает для эмитента значение https://api.crowauth.com, которое является URL-адресом API. Это утверждение является необязательным.
sub должен однозначно идентифицировать принципала, являющегося субъектом JWT. Это может быть идентификатор пользователя или адрес электронной почты. Это утверждение является необязательным.
aud должен указать предполагаемого получателя JWT. Например, сервер приложений, который его запрашивает. Это утверждение является необязательным.
exp представляет время, когда JWT больше не следует считать действительным. Это утверждение является необязательным, но я настоятельно рекомендую его использовать. Из соображений безопасности не рекомендуется выдавать JWT, который навсегда подтвердит, что владелец является тем, кем он является. Нам нужно либо заставить пользователя повторно аутентифицироваться, либо использовать токены обновления. Существует множество сведений о причинах использования заявлений об истечении срока действия и токенов обновления.
iat просто утверждает, когда был подписан JWT.
Проверка JWT
Помните тот JWKS, который мы создали пару разделов назад? Теперь мы получаем, чтобы использовать его. Чтобы сосредоточить внимание этого руководства на JWT, я предполагаю, что либо A) вы потратили время на создание работающего сервера аутентификации, который может предоставлять JWT и размещать JWKS (хотя для этого может потребоваться приличное количество времени, чтобы получить настроен, в конечном счете, это то, к чему мы здесь стремимся) или B) у вас не настроен сервер. Лучше всего подходит ситуация А, но если вы ограничены во времени или просто хотите прочитать код, я предоставлю небольшие сценарии, чтобы показать, как работают концепции.
На высоком уровне нам нужно получить распределенный открытый ключ, а затем проверить JWT, предоставленный серверу приложений, поэтому при нормальных обстоятельствах этот код будет запускаться сервером приложений для проверки личности клиента. В этом примере я буду использовать JWKS, на который я несколько раз ссылался в этом руководстве. Замените URL-адрес на соответствующий JWKS, который соответствует закрытому ключу, используемому сервером аутентификации, подписавшим JWT.
const { createRemoteJWKSet } = require('jose/jwks/remote');
const { jwtVerify } = require('jose/jwt/verify');
const jwks = createRemoteJWKSet(new URL('https://crowauth.com/v1/jwks.json'));
const { payload, protectedHeader } = await jwtVerify(jwt, jwks); // The jwt variable needs to be passed in from somewhere; cookie, hard coded, parameter, etc.
Что здесь происходит, так это то, что jose извлекает JWKS, находит ключ на основе use, являющегося sig, key_ops включая verify, и некоторых параметров, предоставленных пользователем. Затем он использует JWK, найденный в JWKS, чтобы убедиться, что подпись на JWT действительно получена от правильного закрытого ключа/сервера аутентификации. jwtVerify также может принимать какие утверждения для проверки, например, Эмитента или Аудитории. Возвращенный payload будет содержать утверждения или информацию для конкретного приложения, предоставленную при подписании JWT. Исходя из моего предыдущего примера подписи, payload будет { myClaim: true }.
Если у вас не настроен удаленный JWKS, а остальная часть сервера аутентификации готова, вот сценарий, демонстрирующий ту же концепцию, но на локальном уровне.
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
const publicJwk = await fromKeyLike(publicKey);
const token = await new SignJWT({
myClaim: true,
})
.setProtectedHeader({
typ: 'JWT',
alg: 'RS256',
})
.setIssuer('https://example.com')
.setSubject('asdf')
.setAudience('myUser')
.setExpirationTime('6h')
.setIssuedAt()
.sign(privateKey);
const parsedJwk = await parseJwk(publicJwk, 'RS256'); // For the sake of the example, parse the JWK instead of simply using the generated publicKey
const { payload, protectedHeader } = await jwtVerify(token, parsedJwk);
console.log(protectedHeader);
console.log(payload);
})();
Хотя parseJwk не совсем необходим (мы могли бы просто использовать publicKey напрямую), это демонстрирует ту же идею проверки JWT на основе JWK.
Конец
Вы сделали это. Молодец. Надеемся, что это руководство упростило изучение JWT с помощью Javascript, и вам не нужно было много гуглить на стороне. В начале руководства я упомянул, что JWT — это популярная форма аутентификации пользователей для веб-приложений. Вы заметили, что я ни разу не говорил о JWT в контексте веб-приложений? Упс.
Для этого руководства было достаточно понимания JWT и того, как с ними работать. Скоро я выпущу еще одно руководство по использованию JWT с веб-приложениями. В этом руководстве рассказывается, как аутентифицировать пользователей, где должны храниться JWT, а также о взаимосвязях между сервером аутентификации, браузером и серверами приложений. Всякий раз, когда он будет написан и опубликован, я буду редактировать этот пост со ссылкой на него.
Если вам понравилось это руководство и оно было полезным, пожалуйста, дайте мне знать как-нибудь. Отправьте электронное письмо, твитните мне, прокомментируйте один из перекрестных постов (если вы не читаете это в моем личном блоге), что угодно. Если это не помогло, есть ошибки или у вас есть предложения, применяется то же правило. Если вам нужна аутентификация для нового веб-приложения, и это ваше первое знакомство с ним, могу ли я предложить избавить себя от хлопот и использовать готовое решение, такое как Crow Authentication? До скорого!
Первоначально опубликовано на https://thomasstep.com 21 июля 2021 г.