Как создать безопасную авторизацию пользователей с помощью UUID

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем привет, я Никита, Node.js-разработчик в компании OBRIO, которая входит в экосистему бизнесов Genesis. Компания существует с мая 2019-го, и сейчас команда развивает четыре продуктовых направления: Mobile, Web, GameDev, SaaS.

Я разрабатываю платформу для автоматизации маркетинга AdBraze и недавно столкнулся с задачей создания прозрачной, расширяемой и безопасной системы авторизации пользователей. Так и появилась идея написания этой статьи.

Тема авторизации будет актуальна для большинства приложений. В статье мы сравним существующие подходы, разберем, с какими подводными камнями сталкиваются при создании модуля авторизации, и напишем авторизацию в приложении с нуля на примере Node.js.

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

С каждым новым бизнес-запросом мне становилось все сложнее решать, как его удовлетворить в текущей архитектуре. И проблема была в нашей системе авторизации. Она накладывала несколько ограничений, кроме того, Passport.js добавлял слишком много «магии» в код, что осложняло дебаг. Потому было принято решение отказаться от Passport.js, а потом и от использования JWT-токенов.

С Passport.js все было предельно просто: я довольно быстро написал собственную реализацию авторизации через JWT-токены, которая делала то же самое, что и стратегия от Passport.js, но более прозрачно и понятно для восприятия. Но, что делать с заменой JWT-токенов, было не совсем понятно. И тут встал вопрос: как создать систему авторизации, которую потом не придется переписывать, которая не будет накладывать функциональных ограничений на систему и которой мы будем удовлетворены с точки зрения безопасности.

После ресерча и обсуждения в команде мы нашли очень простое и действенное решение — использование UUID-токенов. За все время существования проекта я ни разу не видел ситуации, в которой JWT-токены справились бы лучше, чем UUID. Как правило, было наоборот. В итоге я ни разу не пожалел об идее их использовать. Думаю, это было одним из лучших решений на проекте, потому эта статья и вышла в мир.

Про авторизацию в целом

Если вы хорошо знакомы с процессом авторизации и аутентификации в целом, смело переходите к следующему разделу. Этот — для тех, кто только знакомится с этими понятиями.

Итак, что такое аутентификация и авторизация пользователей? В целом, это два процесса, которые происходят при каждом запросе на какой-то ресурс, чтобы определить:

  • кто делает этот запрос;
  • есть ли у него на это право.

Предположим, вам скинули ссылку на папку в Google Drive. Вы по ней переходите. Чтобы сервер Google понял, что у вас есть к ней доступ, ему нужно сначала определить, кто вы. Этот процесс называется аутентификацией. Самый простой способ — перенаправить вас на страницу с вводом логина и пароля, дождаться, пока вы введете данные, и продолжить начатое. Однако вы ведь не вводите логин/пароль каждый раз, когда хотите войти в Drive? Это возможно, так как при первом входе Google отправляет вам в cookies сгенерированный токен доступа. Это просто строка, которую ваш браузер сохраняет в сторедж и при каждом следующем запросе автоматически отправляет все cookies, которые ранее были получены от Google. И в таком случае Google уже аутентифицирует вас не по логину и паролю, а по токену доступа. Как и связка логин+пароль, так и токен доступа однозначно дают понять серверу, какой пользователь делает запрос.

После того как пользователь аутентифицирован сервером, его нужно авторизовать, то есть проверить, имеет ли он вообще доступ к запрашиваемому ресурсу. В нашем случае — проверить, была ли расшарена папка на пользователя, который пытается ее открыть. Тут уже механизмы могут отличаться от приложения к приложению. В случае если пользователь ранее был аутентифицирован с логином и паролем, серверу нужно проверить в базе данных, есть ли у него право на запрашиваемое действие. Если аутентификация была осуществлена по токену, то порядок авторизации зависит от того, какую информацию зашивает сервер в токене. Если только идентификатор пользователя, то механизм аналогичный — нужно пойти в БД, найти пользователя и проверить у него наличие данного права. Но иногда прямо в токене зашиваются и права. В таком случае по токену можно сразу понять, что за пользователь делает запрос и проверить наличие у него необходимых прав.

Приведу еще один пример. Вы решили зайти на ДОУ посмотреть, не появилось ли каких-то интересных статей. В этом случае серверу необязательно проверять, есть ли у вас на это права (если предположить, что страница со списком статей публичная и не имеет никаких черных списков). То есть авторизацию проводить необязательно. Зато аутентификация тут нужна не только для безопасности, но и для того, чтобы отобразить для вас подходящий контент. Поэтому, перейдя по одной и той же ссылке на ДОУ с разных аккаунтов, вы увидите разные списки статей, актуализированные под каждого пользователя. Чтобы это работало, серверу нужно понять, кто делает запрос. Делает он это по токену, который был ранее отправлен клиенту. Если же токена нет, то пользователю можно отправить форму регистрации или входа в систему (ввод логина и пароля), или же показать ему дефолтный список статей, которые интересны большинству читателей.

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

Токены доступа

Итак, в прошлом разделе мы сказали, что после ввода учетных данных сервер отправляет пользователю токен в файле cookie, чтобы ему не приходилось вводить данные при каждом запросе и авторизация происходила автоматически. Что это за токен? Самое простое решение, которое приходит в голову, — отправлять в качестве токена идентификатор пользователя в системе, тогда при каждом запросе на сервере нам нужно просто запросить пользователя из базы данных по идентификатору из cookie. Но тогда, если пользователь узнает идентификатор другого человека, например зайдет на его профиль и увидит в URL-параметрах (сложно представить, что кто-то создаст такую «безопасную» систему, но все же), тогда первый пользователь сможет выдать себя за второго и авторизоваться от его имени. Кроме того, мы не сможем отозвать токен на сервере, если нам станет известно, что он был скомпрометирован, поскольку токен — это идентификатор самого пользователя. Чтобы деактиваровать токен, нам придется удалить пользователя из системы, а нам бы этого явно не хотелось. Тут нам приходит на помощь первое возможное решение — JWT-токены, те же идентификаторы пользователя, но проапгрейденные.

JWT-токены

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

JWT-токен — это строка символов, состоящая из трех частей, разделенных точкой:

  • заголовок;
  • полезная нагрузка;
  • подпись.

Полезная нагрузка — это JSON-объект с информацией о пользователе, которой мы хотим обмениваться с браузером. Например, идентификатор пользователя. Но в отличие от первого варианта, в котором мы просто отправляли идентификатор пользователя и использовали его как токен доступа, тут есть третья часть — подпись. Это то, что обеспечивает нам некоторую защиту. Чтобы получить подпись, нам нужно захешировать полезную нагрузку алгоритмом получения кода аутентификации, использующего хеш-функцию, например HMAC-SHA256. Передав этому алгоритму нашу полезную нагрузку (например, идентификатор пользователя) и секретный ключ, который хранится на сервере, мы получаем последовательность символов, которая называется хеш. По хешу невозможно за адекватное время получить исходную информацию, не зная секретного ключа, который знает только наш сервер. Это и обеспечит безопасность общения клиента и сервера.

Итак, как это работает на практике: пользователь авторизуется на сайте с использованием учетных данных. Мы создаем для него объект (полезную нагрузку). Этот объект хешируется некоторой хеш-функцией, таким образом получается подпись. И это все мы отправляем клиенту. При получении запроса от клиента, мы также получаем в cookie сгенерированный ранее токен, который теперь нам нужно проверить на подлинность. Для этого мы возьмем вторую часть из полученного токена (полезную нагрузку) и заново ее захешируем, используя ту же хеш-функцию и тот же секретный ключ. Если на выходе мы получаем хеш, идентичный подписи в полученном токене, значит аутентификация прошла успешно. Запрос действительно отправил пользователь, чьи данные были получены в полезной нагрузке.

Что же будет, если клиент подменит свой идентификатор в cookie на чей-то другой? В таком случае, получив запрос, мы в качестве проверки захешируем идентификатор, но получим совершенно иной хеш (благодаря лавинному эффекту современных хеш-функций, достаточно поменять один бит в исходном сообщении, чтобы хеш получился абсолютно другим) и на этом этапе мы поймем, что пользователь — не тот, за кого себя выдает, потому отклоняем запрос и не аутентифицируем его. Аналогичная ситуация будет, если как-то изменить хеш.

Однако злоумышленник может получить доступ ко всему токену целиком, отправить запрос с его использованием, тогда у сервера не будет поводов не аутентифицировать его. Потому время жизни токенов доступа устанавливается в 10–15 минут. Чтобы минимизировать риски, причиненные воровством токенов. Но если мы будем просить пользователя ввести логин и пароль или войти другим образом в систему каждые 10–15 минут, это плохо отразится на впечатлении от сайта. Потому вместе с токеном доступа используют токен обновления, время жизни которого гораздо больше, например несколько недель. Для обычных запросов клиент использует только токен доступа.

Получая в ответ на какой-то запрос UNAUTHORIZED, клиентское приложение понимает, что токен просрочился, потому его нужно обновить. Для этого он берет токен обновления и отправляет его на эндпоинт обновления токенов, который вернет новую пару токенов (или только токен доступа, в зависимости от реализации). И уже с новым токеном клиентское приложение дальше общается с сервером. Благодаря тому, что это происходит незаметно для пользователя, это оставляет хорошее впечатление о сайте.

А теперь о грустном. С какими проблемами мы столкнулись при использовании JWT-токенов и почему впоследствии вообще решили от них отказаться.

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

Все, что мы можем сделать — это или ничего не делать и дать возможность злоумышленникам пользоваться сайтом ограниченное количество времени, пока действует токен доступа (надеясь, что они не украли еще и токен обновления), или добавить украденный токен в некий черный список и при каждом запросе на сервер проверять, не находится ли токен доступа в этом списке. Конечно, если построить его правильно и задавать записям TTL, равный времени жизни токена, то этот список большую часть времени вообще должен быть пустым.

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

2. Несколько сессий для одного аккаунта. Однажды в нашем приложении появилась необходимость запретить одновременный доступ к одному аккаунту с нескольких устройств. Это стандартная практика для b2b-приложений. Но хотелось сделать это без лишней боли и эксцентричных манипуляций в коде, так как к авторизации уже и так были вопросы. Оказалось, что сделать это не так просто. Одним из решений было сохранять access- и refresh-токены в базе данных для каждого пользователя и перезатирать их в случае нового логина. Но тогда теряется смысл самих JWT-токенов. Зачем мне какая-то логика по конструированию payload (полезной нагрузки), подписыванию, кодированию, повторного хеширования для проверки подлинности (а это ресурсозатратная операция), если я просто могу сгенерировать криптографически стойкую случайную последовательность, сохранить ее в базу данных и по ней проверять пользователей?

На этом моменте пришло ощущение, что это начало конца использования JWT в нашем продукте. В качестве временного решения я решил сохранять только refresh-токен в БД, таким образом давать возможность существования нескольких одновременных сессий в системе для одного аккаунта на время жизни access-токена. Это тоже не очень хорошо сказывается на впечатлении о сайте, так как если бы пользователю кидало ошибку UNAUTHORIZED сразу после создания сессии, ему было бы намного проще отследить причину. А так получилось, что его выбивает из системы через 10–12 минут. В любом случае это стало причиной обсуждений в команде и поиска новых решений.

3. Logout. Еще одна проблема заключается в том, что после выхода из системы, когда пользователь считает, что окончил свою сессию, его токены доступа и обновления продолжают жить, пока не закончится их время жизни. Чтобы решить эту проблему, мы бы могли записывать токены в списки из первого пункта. Но тогда эти списки быстро начали бы раздуваться до нежелательных размеров, чего бы тоже не хотелось.

4. Использование стандарта. Еще одна проблема более абстрактного характера, но о которой нельзя не сказать — из тех, кто использует JWT-токены, мало кто знает этот стандарт целиком. С каждой добавленной возможностью или пунктом настройки в какой-то стандарт потенциально растет количество людей, которые могут сделать ошибку в том или ином месте. Даже у Auth0 в прошлом году нашли критическую уязвимость с использованием JWT. Потому, мне кажется, этот стандарт недостаточно прост, чтобы его можно повсеместно использовать всем и не бояться, что что-то может пойти не так.

UUID

Наконец, перейдем к решению, которое оказалось на удивление удачным.

Что такое UUID (Universally Unique Identifiers)? Как и JWT-токен, это просто набор символов, но, в отличие от JWT, фиксированной длины.

Формат UUID:

123e4567-e89b-12d3-a456-426655440000

xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

M — версия UUID (на момент написания статьи всего 5).

N — вариант UUID. Не буду заострять на этом внимание. Подробнее можно почитать на вики.

Как его применить и безопасно ли это для авторизации? Итак, UUID — это просто случайный 128-битный набор символов. В силу того, какое необъятное количество вариантов UUID существует, принято считать, что практически невозможно получить в одной системе два одинаковых идентификатора, потому их даже используют, как первичные ключи в сущностях БД.

Что если в нашей системе авторизации мы заменим JWT-токен на UUID? То есть на первом входе пользователя в систему будем генерировать для него два обычных UUID, сохранять их в базу данных и отдавать ему. Один — как токен доступа, второй — как токен обновления. И затем при каждом запросе пользователя на сервер, мы будем получать в cookie его токен доступа, проверять, соответствует ли он хоть одному токену, сохраненному на текущий момент в БД, если да — аутентифицировать его как пользователя, для которого в базе сохранен этот токен. Аналогично с обновлением: пользователь отправляет на сервер токен обновления на специальный эндпоинт, мы проверяем, имеется ли у нас в базе такой токен обновления, и если да, какому пользователю он соответствует.

Давайте посмотрим, как такой подход решает вышеуказанные проблемы:

Усложненная логика отзыва токенов

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

Несколько сессий для одного аккаунта

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

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

Logout

Если пользователь хочет выйти из системы, мы просто удаляем его токены из БД. Они больше не будут валидными и не смогут использоваться кем-то для входа/обновления. Profit.

Использование стандарта

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

Все проблемы, как по мне, решены в полном объеме. Получили ли мы какие-то дополнительные проблемы? За полгода, которые мы используем этот подход, я не увидел ни соринки, ни задоринки в UUID. Кроме одного...

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

UUID — secure or not secure?

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

  1. Самая страшная из них — возможность с некоторой вероятностью, большей чем чистый рандом, угадывать токены, которые будут сгенерированы следующими, зная один из токенов. На практике это даст злоумышленнику возможность провернуть такой сценарий: он авторизуется в нашем приложении, получает свой токен доступа, и, зная свой токен доступа, каким-то образом угадывает токены, которые будут сгенерированы сервером клиентам сразу после него. И затем, подождав некоторое время, за которое потенциально кто-то из пользователей авторизуется в приложении, он берет предполагаемый им сгенерированный токен доступа и пытается зайти с его использованием. Если он угадает, то сможет войти в систему от имени другого пользователя. А дальше уже делать все, что душа пожелает.
  2. Возможность брутфорса токенов. То есть перебора возможных вариантов сгенерированных токенов и попытки войти в систему с каждым из них.

Разберемся с первой уязвимостью и тем, опасна ли она для нас.

Всего есть пять версий UUID. Четыре из них используют неподходящие для нас варианты генерации: на основе текущего таймстемпа, MAC-адреса сервера и так далее. То есть способы генерации, при котором по одному идентификатору можно предугадать следующий с большой вероятностью.

Остановимся подробнее на версии № 4.

a2dada9b-gae8-4bdd-af76-af89bed2262f

Почти весь идентификатор генерируется случайным образом. Только два символа установлены в определенное значение. Это первый символ третьей группы, отвечающий за версию и первый символ четвертой группы, отвечающий за тип. Он, как правило, будет иметь значение «a». Таким образом, алгоритм генерации UUID делает следующее:

  1. Генерирует 30 случайных символов.
  2. Превращает их в правильную форму, добавляет версию с типом и возвращает сериализированное значение.

Второй пункт довольно тривиален, самое интересное кроется в первом пункте. Генерация 30 случайных 16-ричных символов для UUID является процессом, при ошибке в котором вся безопасность системы накроется медным тазом. По сути, этот процесс ничем не отличается от генерации обычной последовательности случайных чисел. Так как мы можем сгенерировать случайные числа?

Для этого мы используем генератор случайных чисел — программную абстракцию, хранящую свое состояние и возвращающую новые порции случайных данных по запросу. Генератор случайных чисел берет предыдущее сгенерированное число, пропускает его через некоторую функцию и на выходе получает новое число, которое отдает пользователю, а также сохраняет у себя для следующей генерации числа.

Для генерации первого числа используется начальное значение, которое передается генератору, так называемый seed. Для безопасного использования генератора критически важно, чтобы этот seed был абсолютно случайным. Даже случайная строка, предоставленная разработчиками в конфиге приложения типа lkO#93pfo)/apasdfl, не является подходящим вариантом. Считается, что человек в принципе не может сгенерировать действительно случайную последовательность. Например, random.org для генерации случайных чисел использует атмосферный шум, то есть нечто, что крайне трудно предсказать.

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

Потому источник этого seed-а и функция, генерирующая новые значения, будут определять безопасность генератора и его устойчивость к разным видам атак. Именно так работает функция Math.random, отдающая каждый раз новое случайное число. Но можем ли мы ее использовать для генерации нашего UUID?

Увы, ответ — не можем. Нам мало обычного генератора случайных чисел (pseudorandom number generator, PRNG) в криптографических целях. Чтобы препятствовать потенциальному злоумышленнику, мы должны использовать криптографически стойкий генератор случайных чисел (Cryptographically secure pseudorandom number generator, CSPRNG). Обычный PRNG не обладает особыми требованиями к безопасности, есть лишь рекомендации, в соответствии с которыми он должен делать хорошую видимость генерации равномерно распределенных случайных чисел. В свою очередь CSPRNG имеет жесткие требования, среди которых:

  • проходить статистические тесты на равномерное распределение полученных значений;
  • проходить тест на следующий бит, который заключается в том, что не существует алгоритма, который за адекватное время, зная первые n битов, сгенерированных генератором, позволит предсказать n+1 бит с вероятностью > 50%;
  • если n подряд идущих битов, сгенерированных генератором, оказались известны злоумышленнику, у него не должно быть возможности за адекватное время вычислить предыдущие значения.

В нашем случае Math.random не соответствует ни одному из этих свойств.

Чтобы в этом убедиться, я рекомендую почитать отличную статью, которая описывает тонкости подкапотной жизни Math.random. Тут доступно объясняется, почему функция, которую используют для получения новых случайных чисел из предыдущих чисел в Math.random, совершенно непригодна для любых целей, когда нужна криптографическая стойкость. Я лишь добавлю, что seed эта функция генерирует сама, и он тоже является небезопасным. Если злоумышленник узнает seed, с которым был запущен генератор случайных чисел на сервере, это его значительно приблизит к возможности полного контроля над состоянием генератора и определения всех последующих чисел, которые будут в нем сгенерированы. Так вот, Math.random в качестве сида использует преобразованное локальное время на сервере. Таким образом достаточно узнать время, когда сервер был запущен и сделав всего несколько тысяч попыток, можно угадать сид. Посмотреть своими глазами исходники генератора, который используется для Math.random, можно тут.

Итак, мы определили, что нам нужен CSPRNG для генерации случайных UUID. Но не писать же его самим?

В Node.js есть целый встроенный модуль crypto, в котором есть необходимая функция randomUUID(). Эта функция генерирует нужный нам UUID четвертой версии. Исходники функции здесь. Сама функция ничего интересного не делает: получает набор случайных чисел из более низкоуровневой функции на С++ и представляет их в необходимом формате, осуществляя несколько проверок.

Потому, чтобы убедиться в безопасности данной функции для наших целей, нужно спуститься на несколько слоев абстракции вниз. Проанализировав исходники, становится понятно, что Node.js использует OpenSSL для получения случайных чисел для модуля crypto. OpenSSL — это большая криптографическая библиотека, которая предоставляет много криптографических примитивов. Это хорошая практика, когда такие критически важные с точки зрения безопасности функции реализовывает один поставщик, который следит за актуальностью их реализации и оперативно их обновляет в случае обнаружения какой-то уязвимости. А все остальные, например поставщики стандартных библиотек языков программирования, просто используют готовые функции OpenSSL через API.

Итак, изучив исходники и документацию OpenSSL, мы наконец находим функцию, непосредственно отвечающую за генерацию случайных чисел. Именно эта функция через много слоев абстракций будет вызвана, чтобы сгенерировать случайные биты для нашего будущего UUID.

Чтобы убедиться в безопасности, изучим эту функцию. Она реализует один из методов, описанных в этом документе центра стандартизации США. Если вы намерены глубже разобраться в теме, этот документ — одно из лучших руководств. В нем даны рекомендации по построению генераторов. Реализация OpenSSL следует этому документу.

Всего есть два метода построения генераторов. Это использование хеш-функций и использование блочных шифров. Оба метода применяются в разных условиях, и это тема для отдельной статьи. В OpenSSL же используется первый метод, называемый HMAC_DRBG, который использует некоторую хеш-функцию. Эта хеш-функция (в коде md — message digest) может быть предоставлена клиентом (в нашем случае Node.js). Если клиент ее не предоставляет, берется дефолтное значение. В текущей версии OpenSSL дефолтным является SHA256. Node ничего не передает в OpenSSL, потому библиотека будет использовать именно SHA256.

Таким образом, OpenSSL делает следующее для генерации последовательности случайных чисел: сначала берет необходимое количество бит энтропии из доверенных источников. Этим источником считаются внешние механизмы, которые предоставляет ОС: движения мыши, сетевая активность, прерывания системного ввода-вывода, активность жесткого диска и так далее. Источники энтропии из среды в Linux, например, включают тайминги прерывания, межклавиатурные тайминги и другие события, которые являются недетерминированными и трудно измеримыми для внешнего наблюдателя. OpenSSL ждет, пока этих данных насобирается 256 бит. Эти значения можно назвать с некоторым теоретическим отклонением абсолютно случайными.

Но, поскольку их имеется ограниченное количество, OpenSSL на основе этих данных начинает генерировать псевдослучайные числа, используя HMAK_SHA256 (обратите внимание, в названии алгоритма сначала пишется сам алгоритм, а потом хеш-функция, которая используется. Точно также это мог бы быть HMAK_RIPEMD-160, HMAK_MD5 или любая другая реализация, если бы, к примеру, Node.js ее предоставила OpenSSL). Итак, OpenSSL берет сид и хеширует его, используя HMAK_SHA256, таким образом получая первую порцию псевдослучайных данных. Затем отдает ее вызывающему процессу, а также обновляет свое состояние:

  • сохраняет полученное значение хеша как текущее значение генератора;
  • обновляет счетчик энтропии. Когда этот счетчик достигнет граничного значения (через n запросов на псевдослучайные числа), OpenSSL запросит у ОС новые значения энтропии для минимизации вероятности деградации процесса генерации.

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

Да, SHA256 считается безопасной с точки зрения криптографии. Об этом детальнее можно почитать, например, в этой статье. Мы в эти детали вдаваться не будем.

Подытоживая, можно сказать, что UUID, которые генерирует нам модуль crypto, являются безопасными к разным интерполяционным атакам. Если же вы пишите не на Node.js, вам нужно найти в документации к вашему языку, какую хеш-функцию использует его генератор случайных чисел. И затем загуглить статус этой функции. Если это, например, xorshift128+, как в случае с Math.random в стандарте JS, то вы довольно быстро найдете информацию о том, что данный алгоритм не является криптографически стойким, значит, он не может использоваться. Если это что-то вроде SHA256 — это то, что вам нужно. Как же быть со второй уязвимостью?

Тут все намного проще. Просто представим, сколько возможных значений UUID 4 версии существует. Их 16^30 ~ 2^120. Это число мало с чем можно сравнить, настолько оно огромно. Если предположить, что у нас в системе 100 тысяч пользователей, которые одновременно взаимодействуют с системой и имеют токены доступа, то если сгенерировать миллион токенов и попробовать с каждым из них авторизоваться, то вероятность угадать хотя бы один токен будет (1.3e+25)%, то есть практически равна нулю. Кроме того, на уровне инфраструктуры мы должны ограничивать количество возможных запросов за единицу времени. А если учесть, что все токены должны быть проверены в ограниченный промежуток времени, так как потом токены, которые злоумышленник уже проверял, могут задействоваться, а те, которые он только собирается проверить — заэкспайрятся. Кроме того, их генерация — тоже не быстрая операция (HMAK_SHA256 требует вычислительных ресурсов для работы), поэтому вероятность угадать за адекватное время хотя бы один токен практически равна 0.

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

Реализация

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

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

Для начала создадим базовое приложение Nest.js и удалим все ненужные файлы.

Так выглядит наш проект на старте. Для демонстрационных целей упростим способ хранения данных и вместо БД просто будем писать в объект. В файле permission.ts перечислим доступные права в нашей системе:

export enum Permission {
  READ = 'READ',
  WRITE = 'WRITE',
}

Опишем две сущности: для пользователя и продукта. Тут все тривиально. Покажу пример для пользователя:

import { Permission } from '../permission';

export class User {
  constructor(
    public userId: string,
    public username: string,
    public password: string,
    public permissions: Permission[],
  ) {}
}

Теперь опишем сервис для хранения наших данных:

import { User } from './entities/user';
import { Product } from './entities/product';

export class StorageService {
  private users: User[];
  private products: Product[];
}

В силу того, что Nest.js по умолчанию создает по одному инстансу каждого сервиса и затем прокидывает его всем сервисам, которые его используют через DI-контейнер, мы таким образом можем создать удобный общий сторедж данных для всего приложения.

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

import { SetMetadata } from '@nestjs/common';

export const Public = () => SetMetadata('isPublic', true);

export const Permissions = (...permissions: Permission[]) =>
  SetMetadata('permissions', permissions);

И теперь опишем контроллер, который будет обрабатывать клиентские запросы:

import { Body, Controller, Get, Post } from '@nestjs/common';
import { StorageService } from './data-storage/storage.service';
import { Product } from './data-storage/entities/product';
import { Permission } from './data-storage/permission';
import { Permissions } from './auth/authorization/permissions.decorator';

@Controller()
export class AppController {
  constructor(private storageService: StorageService) {}
  @Get()
  @Permissions(Permission.READ)
  getProducts() {
    return this.storageService.products;
  }

  @Post()
  @Permissions(Permission.WRITE)
  updateProduct(@Body() product: Partial<Product>) {
    const oldProduct = this.storageService.products.find(
      ({ productId }) => productId === product.productId,
    );
    oldProduct.productTitle = product.productTitle ?? oldProduct.productTitle;
    oldProduct.price = product.price ?? oldProduct.price;
    oldProduct.amount = product.amount ?? oldProduct.amount;
    return { success: true };
  }
}

Пришло время делать авторизацию. Общая схема такая:

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

  • через username + password;
  • через accessToken;
  • через refreshToken.

Давайте сначала создадим аутентификацию через username + password. Мы будем использовать гварды. Это специальные мидлвары, которые предназначены для авторизации пользователей и запрета выполнения действий, на которые у них нет разрешений.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { StorageService } from '../../storage.service';

@Injectable()
export class CredentialsAuthenticateGuard implements CanActivate {
  constructor(private readonly storageService: StorageService) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const { username, password } = request.body;

    const user = await this.storageService.users.find(
      ({ username: savedUsername }) => savedUsername === username,
    );

    if (!user || user.password !== password) throw new UnauthorizedException();

    request.user = user;
    return true;
  }
}

Все, что делает этот гвард — получает учетные данные из тела запроса, проверяет их на соответствие одной из записей в хранилище юзеров и, если все правильно, записывает полученного пользователя в поле user в запросе (так делать очень плохо, это «привет» из эпохи мидлваров express, лучше как минимум использовать поле с символьным ключом, а как максимум — что-то вроде промежуточного хранилища на WeakRef, но мы так делаем для упрощения, чтобы потом была возможность быстро получить пользователя в контроллере). В противном случае кидаем ошибку.

Для того чтобы дать возможность пользователю авторизоваться через логин и пароль, создадим контроллер auth.controller.ts, в котором будет эндпоинт для этого. Также в этом файле будут эндпоинты на рефреш токен и логаут из системы.

import { Controller,Post, UseGuards } from '@nestjs/common';
import { CredentialsAuthenticateGuard } from './authentication/credentials.authenticate.guard';

@Controller('auth')
export class AuthController {
  @Post('login')
  @Public()
  @UseGuards(CredentialsAuthenticateGuard)
  login() {}

  @Post('logout')
  logout() {}

  @Post('refresh-token')
  refreshToken() {}
}

Как видите, с помощью декоратора @UseGuards (CredentialsAuthenticateGuard) мы применили к эндпоинту на логин созданный ранее гвард. Теперь nest будет вызывать метод нашего гварда перед тем, как начать обработку запроса. Обратите внимание, что метод обозначается декоратором Public, так как к нему имеет доступ любой пользователь.

После того как пользователь будет аутентифицирован, нам нужно отдать ему в cookie две пары токенов: access и refresh. Перед этим мы их сгенерируем и сохраним на сервере.

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

  • в реляционной базе данных, прямо рядом с данными пользователей;
  • в памяти, в объект;
  • в in-memory БД типа redis.

Давайте подумаем, какой вариант выбрать. Если мы будем хранить токены в реляционной БД, то получим слишком большие временные издержки, так как нам при каждом запросе в систему нужно будет сделать FULL SCAN таблицы с пользователями, чтобы проверить наличие полученного токена доступа в запросе. Сразу отпадает.

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

Остается третий вариант. Хорошо ли? Все хранится в памяти, потому времени на запросы потребуется в разы меньше, чем с реляционной БД. При этом токены отвязаны от приложения. Потому система будет масштабируемой, и нам не придется разлогинивать пользователей при редеплое приложения (хорошо бы настроить blue-green deploy, но если его нет, то проблема будет очень заметной). Выбор очевиден. Выбираем in-memory БД. В качестве примера возьмем redis. Кроме того, redis позволяет удобно установить TTL, что позволит работать с недолговечными токенами.

Теперь нужно продумать, как мы будем хранить токены в redis.

Конечно, нам нужно сохранить пары:

  • accessToken → userId;
  • refreshToken → userId.

чтобы понять, какому пользователю отвечает полученный токен в запросе. Достаточно ли этого? Не совсем, так как для обеспечения логаута пользователей нам нужно уметь по идентификатору пользователя получить оба токена, чтобы удалить их из redis, потому дополнительно мы будем хранить пару:

  • userId → {accessToken, refreshToken} (хеш-таблица).

Создадим промежуточный сервис, который будет предоставлять нам необходимое API для работы с redis в качестве хранилища токенов (для простоты восприятия сервис напрямую использует API драйвера redis. В реальном коде было бы лучше использовать собственный сервис для работы с redis):

import { Injectable } from '@nestjs/common';
import { RedisService } from 'nestjs-redis';
import { Redis } from 'ioredis';

@Injectable()
export class AuthCacheService {
  public readonly redisClient: Redis;

  constructor(redisService: RedisService) {
    this.redisClient = redisService.getClient();
  }

  async setTokens(
    userId: string,
    accessToken: string,
    refreshToken: string,
    accessTokenExpire: number,
    refreshTokenExpire: number,
  ) {
    await this.redisClient.set(`access-token:${accessToken}`, userId);
    await this.redisClient.set(`refresh-token:${refreshToken}`, userId);
    await this.redisClient.hset(`usr:${userId}`, { refreshToken, accessToken });

    await this.redisClient.expire(
      `access-token:${accessToken}`,
      accessTokenExpire,
    );
    await this.redisClient.expire(
      `refresh-token:${refreshToken}`,
      refreshTokenExpire,
    );
    await this.redisClient.expire(`usr:${userId}`, refreshTokenExpire);
  }

  async getUserIdByAccessToken(accessToken) {
    return this.redisClient.get(`access-token:${accessToken}`);
  }

  async getUserIdByRefreshToken(refreshToken) {
    return this.redisClient.get(`refresh-token:${refreshToken}`);
  }

  async deleteCache(userId: string) {
    const { accessToken, refreshToken } = await this.redisClient.hgetall(
      `usr:${userId}`,
    );

    await this.redisClient.unlink(
      `access-token:${accessToken}`,
      `refresh-token:${refreshToken}`,
      `usr:${userId}`,
    );
  }
}

Теперь опишем сервис, который будет делать всю работу по генерации, сохранении токенов и оформлении их в виде cookies (для простоты будем создавать cookies в виде plain строк. Для импрува это можно вынести в отдельный сервис, там типизировать ответ и отдавать не строки, а сконструированные объекты. И затем их обрабатывать где нужно):

import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ConfigService } from '../system/config/config.service';
import { AuthCookiesService } from './auth.cookies.service';
import { AuthCacheService } from './auth.cashe.service';

@Injectable()
export class AuthService {
  constructor(
    private readonly configService: ConfigService,
    private cookiesService: AuthCookiesService,
    private authCacheService: AuthCacheService,
  ) {}

  private static getExpireDate(seconds: number): Date {
    return newDate(newDate(Date.now() + seconds * 1000).toUTCString());
  }

  async createAndSaveTokenPair(userId: string) {
    const accessToken = randomUUID();
    const accessTokenExpire = this.configService.tokens.accessTokenExpire;
    const accessTokenExpireDate = AuthService.getExpireDate(accessTokenExpire);
    const accessTokenCookie = `Authentication=${accessToken}; Path=/; Expires=${accessTokenExpireDate}; HttpOnly`


    this.cookiesService.getAccessTokenCookie(
      accessToken,
      accessTokenExpireDate,
    );

    const refreshToken = randomUUID();
    const refreshTokenExpire = this.configService.tokens.refreshTokenExpire;
    const refreshTokenExpireDate = AuthService.getExpireDate(refreshTokenExpireDate);
    const refreshTokenCookie = `Refresh=${refreshToken}; Path=/api/auth/refresh-token; Expires=${refreshTokenExpireDate}; HttpOnly`

    await this.authCacheService.deleteCache(userId);
    await this.authCacheService.setTokens(
      userId,
      accessToken,
      refreshToken,
      accessTokenExpire,
      refreshTokenExpire,
    );
    return { accessTokenCookie, refreshTokenCookie, accessTokenExpireDate };
  }
}

Метод createAndSaveTokenPair генерирует пару токенов, достает из configService (на нем не буду заострять внимание, но это любое место, где может храниться подобная информация) время жизни токена, затирает прошлые значения токенов из redis и отдает вызывающему методу в виде cookies.

На что тут важно обратить внимание:

  • мы обязательно ставим флаг httpOnly, который обеспечивает нам защиту токенов в браузере, не давая читать их js-коду. Это важно в случае использования third-party библиотек на клиенте;
  • ограничиваем область действия refreshToken только на эндпоинт по рефрешу. Если мы этого не сделаем, он будет отправляться при запросах на все эндпоинты. И тогда в нем не будет смысла, так как его можно будет украсть с той же вероятностью, что и токен доступа;
  • ставим время жизни токена, чтобы у клиента исчезли cookies тогда же, когда они исчезнут на сервере.

Теперь обновим метод login в контроллере. Для этого опишем утилитарный декоратор:

export const GetUser = createParamDecorator((_, request) => {

return request.switchToHttp().getRequest().user;

});

Этот декоратор просто отдает пользователя из полученного объекта запроса.

Теперь обновим метод login в контроллере:

@Post('login')
@UseGuards(CredentialsAuthenticateGuard)
async login(@GetUser() { userId }, @Res() response: any) {
  const { accessTokenCookie, refreshTokenCookie, accessTokenExpireDate } =
    await this.authService.createAndSaveTokenPair(userId);
    response.header('Set-Cookie', [accessTokenCookie, refreshTokenCookie]);
    return response.send({ accessTokenExpireDate });
}

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

Итак, логин пользователей через пароль мы закончили. Дело за малым.
Опишем наш основной гвард. Сначала он аутентифицирует пользователя по токенам из cookies, потом проверит наличие необходимых прав для выполнения желаемого действия. Для прозрачности кода это должно быть два разнесенных гварда. Затем объединенных третьим гвардом. Однако для простоты, мы все напишем в одном:

import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthCacheService } from './auth.cashe.service';
import { StorageService } from '../../storage.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authCacheService: AuthCacheService,
    private storageService: StorageService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    if (this.reflector.get<boolean>('isPublic', context.getHandler()))
      return true;

    const request = context.switchToHttp().getRequest();

    const userId = await this.authCacheService.getUserIdByAccessToken(
      request?.cookies?.Authentication,
    );
    const user = await this.storageService.users.find(
      ({ userId: savedUserId }) => savedUserId === userId,
    );
    if (!user) throw new UnauthorizedException();

    const expectedPermissions = request.user.permissions;
    const needed = this.reflector.get('permissions', context.getHandler());
    needed.forEach(needed => {
      if (!expectedPermissions.includes(needed)) throw new ForbiddenException();
    });
    return true;
  }
}

Наш гвард будет проверять наличие специального флага Public у вызываемого эндпоинта. Если он имеется, то будет пропускать юзеров дальше без проверок, иначе сначала запустит процесс аутентификации через UUID-токен, потом проверит наличие прав и вернет результат. Если у нас что-то пойдет не так, будет выкинута ошибка, которую Nest.js вернет пользователю.

Для того чтобы этот гвард стал глобальным гвардом, то есть запускался при каждом запросе на сервер, кроме тех эндпоинтов, на которых установлены другие гварды, нужно в любом модуле, например app.module, определить гвард с использованием APP_GUARD константы, импортированной из @nestjs/core.

{ provide: APP_GUARD, useClass: AuthGuard }

Теперь сделаем возможность обновлять токен доступа. По сути, все, что нам нужно это чуть подправить гвард (доставать cookie из другого поля и вызывать другой метод для получения идентификатора пользователя из кеш-сервиса). Для самого процессинга обновления у нас уже есть метод createAndSaveTokenPair. Прелесть заключается в том, что он полностью покрывает функциональность обновления. Удаляет старые токены, генерирует новые, сохраняет их в redis и отдает токены в виде cookies.

Гвард для рефреша:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthCacheService } from '../auth.cashe.service';
import { StorageService } from '../../storage.service';

@Injectable()
export class RefreshTokenGuard implements CanActivate {
  constructor(
    private authCacheService: AuthCacheService,
    private storageService: StorageService,
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const userId = await this.authCacheService.getUserIdByRefreshToken(
      request?.cookies.Refresh,
    );
    const user = await this.storageService.users.find(
      ({ userId: savedUserId }) => savedUserId === userId,
    );
    if (!user) throw new UnauthorizedException();
    request.user = user;
    return true;
  }
}

Для большей переиспользуемости кода, мы могли бы добавить опциональный аргумент в метод canActivate глобального гварда — isRefresh, который по умолчанию будет false. И все, что бы делал RefreshTokenGuard — вызывал бы canActivate в UuidAuthenticateGuard со значением isRefresh в true.

Сам метод refreshToken в контроллере аналогичный login-y.

И теперь сделаем логаут. Для этого опишем необходимый метод в auth сервисе:

async logout(userId: string) {
  await this.authCacheService.deleteCache(userId);
  const accessTokenCookie = `Authentication=; Path=/; Expires=${this.getExpireDate(0)}`;
  const refreshTokenCookie = `Refresh=; Path=/api/v1/auth/refresh-token; Expires=${this.getExpireDate(0)}`
  return { accessTokenCookie, refreshTokenCookie };
}

Он удаляет необходимые записи из redis и очищает cookies у пользователя: устанавливает их время жизни в 0 (они сразу окажутся заекспайреными и перезатирает их пустой строкой).

На этом реализация логики с UUID-авторизацией окончена.

Всем спасибо за внимание.

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

Поэтому обязательно включайтесь в дискуссию в комментариях, особенно если вам есть что сказать.

Буду рад вопросам и замечаниям 😌

👍НравитсяПонравилось8
В избранноеВ избранном6
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Гарна стаття, дякую. Додав в обране.

JFI есть много статей про то что не стоит заниматся велосипедированием аутентификации/авторизации. надеюсь на этом проекте все в курсе всех рисков

Завжди дивуюсь, коли ТС вигадують проблему, і як китайські піонери, героїчно її вирішують, а потім ще й звітують про виконання перед самими собою... )
Чого не скористатись вже перевіреними рішеннями від Okta наприклад? ;))
Хіба для Pet-проекта.. тоді ще якось можливо так заморочуватися... якщо це дійсно серйозний проект, я б брав саме перевірені в роботі сервіси...

Справедливости ради стоит отметить, что «Okta», где работает предыдущий оратор занимается ровно тем же, а именно

вигадують проблему, і як китайські піонери, героїчно її вирішують, а потім

продают за деньги как сервис.

Вот только по сравнению с самописными велосипедами окта — нірм, нормально так аутентифицирует :)

Таке собі порівняння... ;)

Про random-based UUID:

> Почти весь идентификатор генерируется случайным образом. Только два символа установлены в определенное значение. Это первый символ третьей группы, отвечающий за версию и первый символ четвертой группы, отвечающий за тип. Он, как правило, будет иметь значение «a».

Первый символ четвёртой группы — 8, 9, A, B. Всего 6 фиксированных бит и соответственно 122 рандомных, и соответственно 2**122 значений (а не 2**120, как вы ниже написали). Если у вас «как правило» там ’a’, то что-то не то с генератором.

На самом деле главное тут, что непонятно — почему вы вцепились конкретно в UUID. Да, для него есть готовые генераторы и всё такое. Но: он заточен на использование в протоколах/форматах, где идентификатор должен быть жёстко фиксированной длины в битах. Вам точно нужно такое ограничение? Я подозреваю, что случайная строка символов из набора /A-Za-z0-9/ произвольной длины сработала бы не хуже, не портя все остальные аспекты общей цели — и эту длину вы бы регулировали по вкусу. Мало 30 символов? ok, делаем 230. Ну или сколько нужно.

Далее, про отмену самоподписанного токена.
Как уже сказали, JWT придуманы, чтобы в базу лишний раз не лезть. Вы принципиально это устранили. И что вы получили в принципе? Вы пишете:

> Потому вместе с токеном доступа используют токен обновления, время жизни которого гораздо больше, например несколько недель. Для обычных запросов клиент использует только токен доступа.

Или вы как-то очень криво описываете задачу, или просто смешиваете две задачи, не разобравшись. Если речь про веб-доступ, то там после начальной авторизации (считаем для примера OAuth2) JWT использоваться не должен. Используются обычные куки, которые таки смотрятся в базе (включая кэши), чтобы поднять не только факт доступа и под каким логином, но и 100500 параметров сессии. Если речь про доступ API, то дело API периодически запрашивать через рефреш-токены новые аксесс-токены, и рефреш-токены по любому опять же смотрятся в базе (вот в них уже никакой подписи самой по себе не доверяют). Наконец, ничто не мешает сделать логику — если есть глобальный флаг «недавно был отзыв токенов» — проверять по спискам отзыва, а если нет — смотреть на корректность подписи и значение поля iat.

> Тем не менее абсолютно при каждом запросе на сервер, требующем аутентификацию пользователя, придется идти в этот список и проверять, нет ли в нем полученного токена.

В том и дело, что при любом — не нужно — см. выше — если последний отзыв был раньше, чем максимальное время жизни аксесс-токена назад, то проверка не нужна — а это наверняка >99% случаев. И отозванные более старые можно тупо выкидывать из списка отзыва.

> запретить одновременный доступ к одному аккаунту с нескольких устройств. Это стандартная практика для b2b-приложений.

Отлично, а как вы будете проверять факт выхода с предыдущего устройства?
Если у вас есть явная функция logout, то можете превратить её в команду отмены доступа, включая дискардинг рефреш-токена.
Если нет, можно её считать просто по таймауту предыдущего аксесс-токена — когда он состоялся, новый вход с другого места требует реавторизации с отменой всех накопленных токенов для этого акаунта.

По менее центральным вопросам:

> Для генерации первого числа используется начальное значение, которое передается генератору, так называемый seed.

И зачем был весь этот шум, если вы в итоге стали рассказывать про CSPRNG?
Кстати, и это рассказываете странно. Да, в OpenSSL свой генератор. Но сейчас и какой-нибудь /dev/urandom выдаёт уже неплохие значения. Или у вас ОС, где такого нет?

> вероятность угадать хотя бы один токен будет (1.3e+25)%

может, таки 1.3e-25?

Резюмируя: и решение, и статья о нём сырые и малопонятные. JWT не идеал, но и ваш закат солнца вручную выглядит ещё страньше™, обоснование (описанное) не выдерживает критики. Требуется грамотный пересмотр решения, на уровне получше, чем «и увидел он UUID и понял, что это хорошо».

1. HMAK => HMAC

2. Для токенів немає сенсу притримуватись стандарту UDID. UrlSafeBase64(sha256(secure random)) — це ок.

3. Питання корисності refresh token-ів не розкрито: stackoverflow.com/...​access-and-refresh-tokens

4. Так, ви перевинайшли серверні сесії. Але ви розібрали в цьому механізмі краще, ніж 90% тих, хто роками цю стратегію використовуює ... і це круто.

5. Питання statefull та stateless хоч і розкрито, але неявно. JWT придумали щоб серверу не потрібен був redis для сесій ... коли користувачів мільярди, а серверів тисячі, тримати один редіс для токенів — то є проблема (хоча, шардування ще ніхто не скасовував)

Один Redis — це один інстанс, чи кластер ;). Тому що перший варіант годиться лише для концепту

Если с первых строк не понятно чем закончится, лучше сначала комментарии глянуть))

Изначальный выбор JWT для сессий был ошибкой. Достаточно было чуток погуглить:
— developer.okta.com/...​ts-suck-as-session-tokens
— redis.com/...​gerous-for-user-sessions

После этого любые аргументы, почему эта технология «плоха» утрачивают свой смысл.

Про alg: none стало известно не в прошлом году из-за Auth0, а намного раньше. Об этом даже упомянуто на сайте jwt.io

Проблема отзыва токенов легко решается отзывом refresh token который хранится из коробки в любом IdP.
Проблема Logout при наличии short lived jwt в 99% случаев не является проблемой.
Стандарт позволяет не городить костылей и везде иметь стандартную реализацию, не говоря уже о том что использование стандартного idp поддерживающего разные OAuth флоу позволит вам организовать авторизацию и на UI (auth code + pkce), и service2service (client creds) и для legacy сервисов (password grant).
Кроме того токен уже в себе может нести информацию касательно скоупов/пермиссий/доступов (хотя бы базовую) с помощью которой уменьшается/исчезает необходимость ходить к некому централизованному auth сервису за этими данными уменьшая нагрузку и повышая масштабируемость системы. Без упоминания этого не уверен что сравнение в принципе корректно.
Статья о велосипеде из 90-00х aka cookie based auth.

Надо бы дописать про OpenID/OAuth2. Впрочем в интернете и без того об этом документации полно.

Вроде все что тут описано — банальная сессия на куках)

Как создавать защищенный доступ без jwt, oauth2 и uuid:
1. Делаем генерацию клиентского сертификата
2. Пускаем юзеров только с клиентским сертификатом
3. Айдишник юзера возвращается прям с гейта
4. Опционально можно сделать подключение только с 1 ip
5. Работает максимально быстро

1. Делаем генерацию клиентского сертификата

И слать его на клиент открытым текстом, ога ;)

5. Работает максимально быстро

Если отключиць асиметричною криптографию для валидации «сертификата» потому что смотри предыдущий пункт

И слать его на клиент открытым текстом, ога ;)

Можно передать фельдъегерской почтой. Но вообще-то ключ остается на стороне клиента, так что можно слать и открытым.

Если отключиць асиметричною криптографию для валидации «сертификата» потому что смотри предыдущий пункт

А если отключить интернет — то вообще ничего работать не будет.

Ключ + csr — клиент
Сертификат — сервер

И слать его на клиент открытым текстом, ога ;)

Если это делать через страницы с другим стилем доступа и проверки (или вообще через отдельный сайт), то можно на нём навернуть максимальное секьюрити на куках. А уже с готовыми приватным ключом и сертификатом клиента дальше можно лететь спокойнее. Хотя тут всё равно без HTTPS стрёмно :)

Хотя тут всё равно без HTTPS стрёмно

Roll your own! :) Но тогда пункт «максимально быстро» не катит.

Так же не совсем понятно как «максимально просто» отличать csr, которые шлют Элис с Бобом от csr, которые шлёт Оскар

Roll your own! :)

Есть же готовое.

Так же не совсем понятно как «максимально просто» отличать csr, которые шлют Элис с Бобом от csr, которые шлёт Оскар

А как обычный сайт проверяет регистрирующихся на нём пользователей?

А как обычный сайт проверяет регистрирующихся на нём пользователей?

Да, но ради чего тогда все эти танцы с ассиметричной криптографией?

Да, но ради чего тогда все эти танцы с ассиметричной криптографией?

Вопрос, конечно, резонный :))
Ну, например, если обычную авторизацию строят люди, но дальше они строят PKI для роботов. Как в ssh, когда зашли паролем, а дальше нарисовали команды по ключам (и, возможно, запретили вход по паролям после этого).
В топике-то речь про разную автоматику — или скрипты в браузере, или вообще произвольное API...

Я вот как думаю,
Механизм.
1 с shared key, то тогда наверное проще всего настроить вариант керберос кдц чтобы ключ по медиуму лишний раз не гонять. Но в нагрузку идут варианты jwt, auth_token и возможно renewal_token
2 без shared key, то тогда асимметричная криптография:
2а с ЦА и секъюрным способом раздать ключики
2б пгп/гпг

Но или свой велосипед пилить, но там пилить дольше чем ехать, если политики определить до выбора механизма :)

Мені теж не сподобався Passport.js через його заточеність для ExpressJS, і я теж в кінцевому підсумку писав свій велосипед. Хоча починав із @ts-stack/client-sessions — JWT для сесії на боці клієнта (по-суті, access токен).

На скільки я розумію OAuth2, коли використовують access та refresh токени, ця пара існує щоб:
1. зменшити кількість звернень до бази даних завдяки тому, що на сервер частіше будуть приходити запити із access token, який вже містить у собі інформацію про ресурси, права на які має пред’явник цього токену;
2. дозволити впровадити механізм відмови у видачі нових access token завдяки тому, що access token має коротке життя, і власник refresh token повинен перевіряти свої права після кожної смерті access token.

Access token не повинен перевірятись у чорних списках, навіть якщо у пред’явника забрали права ще до завершення життя цього токена. Цей очевидний мінус намагаються компенсувати коротким життям access токена і потребою використовувати refresh токен.

До речі, окрім зменшення навантаження на базу даних, access токен також дозволяє відділити сервер ресурсів від сервера авторизації, і тим самим значно покращити масштабування.

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

Смысл jwt в том, чтобы при каждом запросе не лезть в базу. Вы убили этот смысл. И придумали велосипед, которому тыща лет...

на ПXП сессии так делали еще в нулевые. шел 2021й год....

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

Поздравляю, вы изобрели сессионные куки, но с кучей ненужного барахла типа

refreshTokenCookie

.

PS: Вот если бы вы вместо UUID заюзали бы рандомные емоджи, токены стали бы гораздо веселее.

Проверил — работает. Можно спокойно вместо UUID использовать ребусы.
imgur.com/a/tArUaCZ

но везде ли оно работает?...

це круте використання емоджі, я таке бачив тільки в валідації енкріпшну в телеграм дзвінках)

уяви токен 🍆💦 👌👈 🍑🍌

Прийшов сказати те саме.
Це звичайна sessionId аутентифікація, тільки з UUID, який нікому не потрібен.
Взагалі часто стикаюсь, коли приходять і кажуть: давайте робити JWT/OAuth аутентифікацію. Коли питаю: а знаєте яка ціль такої технології/підходу взагалі, про SSO та окремий сервер авторизації чули? Ніхто не знає. Кажуть: а от у нас на прошлому проекті ми JWT у себе в загальній базі зберігали і нічого...
А потім з’являються цілі статті з «відкриттями» «нових» технологій аутентифікації

так одне іншому не заважає, береш готовий uuid і кожен символ кастиш в смайлик по словнику))

Отримав задоволення від статі, хоча від web далеко

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

Я когда писал свою реализацию JWT аутентификации (без сторонних библиотек, сугубот в образовательных целях), так же задавался вопросами отзыва токена, одновременным логаутом и множественными/единичными сессиями. По сути спецификация JWT вообще ни каким образом не касается этих реализаций, так как это уже дело реализации логики, но размышляя у меня появилось несколько достаточно безумных идей (чур не кидать помидорами)
1. Для того чтобы создать подпись токена мы используем некий secret-key который хранится у нас где нибудь в секретном месте. И к примеру, если для каждого пользователя в базе данных генерировать такой secret-key, то если мы хотим разлогинить юзера или отозвать токен, достаточно сгенерировать ему новый secret-key :)
2. По стандарту JWT у нас есть «jti» (JWT ID) Claim — в который мы можем генерировать уникальный ключ и хранить его в базе данных. Если отношение с юзером будет один-к-одному, то у нас будет только одна активная сессия. Если многие-ко-многим, то несколько активных сессий.

Я ни в коем случае не пропагандирую JWT и свои идеи, я просто про то, что тут ещё много чего много думать.

Но за статью ещё раз спасибо!

jti нужен совсем не для этого, а для того, чтоб защищать от replay атак.

Весь смысл JWT — не лезть в базу для проверки токена, а просто проверить подпись. Если возникает желание хранить ключи в базе — скорее всего JWT вам просто не подходит.

Я ж и говорю что безумные идеи.

По поводу ходить в базу полностью с вами согласен.

С JWT все гораздо проще. В 99% случаях он «вам просто не подходит».

>Если мы будем хранить токены в реляционной БД, то получим слишком большие временные издержки, так как нам при каждом запросе в систему нужно будет сделать FULL SCAN таблицы с пользователями,

Почему нельзя просто добавить индекс?

Так и не понял, в чем смысл использования UUID вместо простого рандомного массива байт?

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

Просто наконец-то зумеры начали подозревать, что c JWT что-то не то и начали изобретать то, что было до этого.

Просто «зумеры» вооружились кукбуками, которые они редко поддают здравой критике. Выше я уже сбрасывал пару ссылок, в которых разработчики одно из известных Identity Provider (IdP) сами говорят, почему JWT для сессий — это плохо.

Ситуация как с упомянутым Passport.js — зачем думать, разбираться, просто возьмем готовое без понимания внутренностей

Рандомный массив байт легче подобрать чем UUID и еще когда вы генерите рандомные массивы байт есть ненулевая вероятность сгенерить нечаянно одинаковые байты для разных пользователей cheatsheetseries.owasp.org/...​t.html#session-id-entropy

Каким образом его легче подобрать если uuid v4 точно так же генерируется случайным образом и в нем *меньше* энтропии, чем в рандомном массиве байт такой же длинны?

я почему-то [на автомате] подумала что вы имеете в виду «рандомный массив байт длиной короче чем uuid» потому что если генерить рандомный массив байт такой же длиной как uuid — то вы просто сделаете то же самое, что уже сделали в модуле crypto, но возможно даже медленнее, т.к они там прикрутили оптимизацию по скорости github.com/...​nal/crypto/random.js#L329

и уж точно «своя» имплементация не будет по-настоящему рандомной если использовать какой-нибудь Math.random() а значит ее будет легче подобрать

но возможно даже медленнее

это слишком легко скейлится просто потому как не нужна ни мгновенная генерация достаточно просто брать новый токен из большого массива уже готового а сам массив уже не спеша генерить в периоды простоя либо вообще выделить отдельную ноду для генерения или две или х нод или даже у где на выходе просто очередь таких готовых массивов

а вот хранить и обрабатывать уже выданные токены уже больше интересный вопрос

а чому не використати Auth0? в нас для нест є інструкція auth0.com/...​tjs-adding-authorization . По часу займає може вечір зроибити все :)

Якось лякає опція Contact Us для 10к+ юзерів, не дуже прозорий прайсинг:(.

10к+ юзерів це вас перенаправлять в okta може, наша батьківська компанія www.okta.com/pricing

але auth0 буде дешевше імхо, тому краще поговорити з сейлзами

Я скоріш за все не цільова аудиторія, але наприклад думаю «зроблю-но пет-проект (а в майбутньому стартапчик на мільярд), шо тут по аутентифікації щоб код не писати?». І Файрбейс чи Когніто дають уявлення скільки мені все буде коштувати при будь-якій кількості юзерів, а у вас — хз, може як тільки побачать що багато користувачів, так одразу прайсинг х10 зроблять, страшно(:.

auth0 free tier ідеально підходить для пет проектів

і якщо не упарюватись в платформ-спесифік фічі типу екшенів, то потім злізти на іншого провайдера займе може ще один вечір

мій поінт навіть не в тому щоб юзати конкретно а0, а щоб не пиляти свій лісапет як автор топіку. все-таки 2021 на дворі

auth0 free tier ідеально підходить для пет проектів

Ну якщо якусь безкоштовну гру чи фан-проект зробити, то 7к юзерів — це дуже і дуже небагато на жаль.

мій поінт навіть не в тому щоб юзати конкретно а0, а щоб не пиляти свій лісапет як автор топіку. все-таки 2021 на дворі

Якщо цікаво, то чому б і не попилити. А як досвід є, то повторити з проекта в проект може бути простішим, ніж інтегруватися з кимось стороннім. Особливо якщо SSO всякі не потрібні.

потому что любое vendor dependent решение — априори небезопасно

Почему-то люди не боятся использовать vendor-specific технологии, например, тот же DynamoDB от Amazon.

Все сильно зависит от стратегии IT в каждом отдельно взятом проекте.

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

Цель любой миграции — далеко не хайп. Этот самый хайп вы не сможете оценить в понятных бизнесу метриках, ROI, например. Любая миграция преследует конкретную цель, которую часто сразу и не видно.

Повторюсь, любое решение о использовании вендор-специфического решения зависит от стратегии каждого конкретного продукта/проекта.

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

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

Подписаться на комментарии