Как создать безопасную авторизацию пользователей с помощью UUID
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всем привет, я Никита, Node.js-разработчик в компании OBRIO, которая входит в экосистему бизнесов Genesis. Компания существует с мая
Я разрабатываю платформу для автоматизации маркетинга 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 на чей-то другой? В таком случае, получив запрос, мы в качестве проверки захешируем идентификатор, но получим совершенно иной хеш (благодаря лавинному эффекту современных хеш-функций, достаточно поменять один бит в исходном сообщении, чтобы хеш получился абсолютно другим) и на этом этапе мы поймем, что пользователь — не тот, за кого себя выдает, потому отклоняем запрос и не аутентифицируем его. Аналогичная ситуация будет, если как-то изменить хеш.
Однако злоумышленник может получить доступ ко всему токену целиком, отправить запрос с его использованием, тогда у сервера не будет поводов не аутентифицировать его. Потому время жизни токенов доступа устанавливается в
Получая в ответ на какой-то запрос UNAUTHORIZED, клиентское приложение понимает, что токен просрочился, потому его нужно обновить. Для этого он берет токен обновления и отправляет его на эндпоинт обновления токенов, который вернет новую пару токенов (или только токен доступа, в зависимости от реализации). И уже с новым токеном клиентское приложение дальше общается с сервером. Благодаря тому, что это происходит незаметно для пользователя, это оставляет хорошее впечатление о сайте.
А теперь о грустном. С какими проблемами мы столкнулись при использовании JWT-токенов и почему впоследствии вообще решили от них отказаться.
1. Усложненная логика отзыва токенов. Предположим, мы узнали, что определенный список токенов был украден и теперь может использоваться злоумышленниками для входа в систему от имени других пользователей. Нам нужно создать механизм деактивации токенов, который сделает невозможным вход с их использованием. Но тут проблема. Из-за того что JWT-токен является самодостаточным и сам по себе определяет свою валидность, мы не можем просто удалить его откуда-то для деактивации.
Все, что мы можем сделать — это или ничего не делать и дать возможность злоумышленникам пользоваться сайтом ограниченное количество времени, пока действует токен доступа (надеясь, что они не украли еще и токен обновления), или добавить украденный токен в некий черный список и при каждом запросе на сервер проверять, не находится ли токен доступа в этом списке. Конечно, если построить его правильно и задавать записям TTL, равный времени жизни токена, то этот список большую часть времени вообще должен быть пустым.
Тем не менее абсолютно при каждом запросе на сервер, требующем аутентификацию пользователя, придется идти в этот список и проверять, нет ли в нем полученного токена. Кроме того, подобные списки становятся проблемой в системах, которые должны масштабироваться, то есть в идеале почти во всех. И в случае масштабируемости меньше всего хочется думать про списки отозванных токенов и как их пошарить между инстансами. Кстати, аналогичная ситуация и с токенами обновления. Они также могут быть украдены, и для них тоже нужно или создавать новый список, или писать в этот же, но с каким-то флагом.
2. Несколько сессий для одного аккаунта. Однажды в нашем приложении появилась необходимость запретить одновременный доступ к одному аккаунту с нескольких устройств. Это стандартная практика для b2b-приложений. Но хотелось сделать это без лишней боли и эксцентричных манипуляций в коде, так как к авторизации уже и так были вопросы. Оказалось, что сделать это не так просто. Одним из решений было сохранять access- и refresh-токены в базе данных для каждого пользователя и перезатирать их в случае нового логина. Но тогда теряется смысл самих JWT-токенов. Зачем мне какая-то логика по конструированию payload (полезной нагрузки), подписыванию, кодированию, повторного хеширования для проверки подлинности (а это ресурсозатратная операция), если я просто могу сгенерировать криптографически стойкую случайную последовательность, сохранить ее в базу данных и по ней проверять пользователей?
На этом моменте пришло ощущение, что это начало конца использования JWT в нашем продукте. В качестве временного решения я решил сохранять только refresh-токен в БД, таким образом давать возможность существования нескольких одновременных сессий в системе для одного аккаунта на время жизни access-токена. Это тоже не очень хорошо сказывается на впечатлении о сайте, так как если бы пользователю кидало ошибку UNAUTHORIZED сразу после создания сессии, ему было бы намного проще отследить причину. А так получилось, что его выбивает из системы через
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 — это просто случайный
Что если в нашей системе авторизации мы заменим JWT-токен на UUID? То есть на первом входе пользователя в систему будем генерировать для него два обычных UUID, сохранять их в базу данных и отдавать ему. Один — как токен доступа, второй — как токен обновления. И затем при каждом запросе пользователя на сервер, мы будем получать в cookie его токен доступа, проверять, соответствует ли он хоть одному токену, сохраненному на текущий момент в БД, если да — аутентифицировать его как пользователя, для которого в базе сохранен этот токен. Аналогично с обновлением: пользователь отправляет на сервер токен обновления на специальный эндпоинт, мы проверяем, имеется ли у нас в базе такой токен обновления, и если да, какому пользователю он соответствует.
Давайте посмотрим, как такой подход решает вышеуказанные проблемы:
Усложненная логика отзыва токенов
UUID являются просто случайным набором битов, источник правды для подлинности токенов целиком находится на сервере. Потому у нас есть полный контроль над всеми токенами: мы можем удалить конкретный токен, удалить токены по определенному пользователю, группе пользователей или все токены в системе одновременно. Если мы удаляем токен определенного пользователя, его разлогинит из системы, и тот токен, который у него сохранен и кажется клиентскому приложению валидным, перестанет быть таковым.
Несколько сессий для одного аккаунта
Благодаря тому, что все токены доступа хранятся в нашей базе, нам вообще ничего не нужно реализовывать, чтобы предотвратить несколько одновременных сессий для одного пользователя. Если два пользователя пытаются одновременно зайти в систему с одного аккаунта, мы для каждого из них генерируем свою пару токенов и токены последнего пользователя просто перезатрут токены первого, и они перестанут быть валидными. Его сразу разлогинит из системы, и это произойдет без нашего участия.
Если же мы хотим разрешить пользователям иметь несколько одновременных сессий, например, определенное количество или неограниченно, то мы можем сохранять в БД n-ое количество токенов для определенного аккаунта и все их считать валидными, заменяя их только в случае достижения лимитов по количеству сессий. Благодаря гибкой системе сохранения токенов, которую мы рассмотрим далее, сделать это будет крайне легко.
Logout
Если пользователь хочет выйти из системы, мы просто удаляем его токены из БД. Они больше не будут валидными и не смогут использоваться кем-то для входа/обновления. Profit.
Использование стандарта
В силу того, что UUID — это просто набор символов без какого-либо состояния, проблема сложности стандарта не стоит. Все, что нужно, чтобы уверенно себя чувствовать при использовании UUID, — найти правильный метод у себя в языке, который секьюрно генерирует новые UUID. А чтобы вообще не иметь сомнений — разобраться, использует ли данный метод под капотом криптографически стойкий генератор случайных чисел, о чем мы подробно поговорим в следующем разделе.
Все проблемы, как по мне, решены в полном объеме. Получили ли мы какие-то дополнительные проблемы? За полгода, которые мы используем этот подход, я не увидел ни соринки, ни задоринки в UUID. Кроме одного...
... Для меня основной головной болью было наконец уверовать в то, что это является безопасным способом авторизации. Что не существует каких-то уязвимостей. Поскольку этот метод мало освещен в интернете, мне пришлось долгое время мириться с сомнениями, успокаивая себя тем, что на поверхности метод кажется безопасным. Но окончательно я убедился в этом только после детального ресерча и изучения исходников своей платформы и использующихся библиотек. И чтобы вам не пришлось проделывать то же самое, предлагаю под микроскопом рассмотреть аспект безопасности.
UUID — secure or not secure?
Чтобы ответить на вопрос из названия раздела, давайте разберемся, какие вообще основные уязвимости и проблемы безопасности могут иметь реализации токенов доступа и обновления.
- Самая страшная из них — возможность с некоторой вероятностью, большей чем чистый рандом, угадывать токены, которые будут сгенерированы следующими, зная один из токенов. На практике это даст злоумышленнику возможность провернуть такой сценарий: он авторизуется в нашем приложении, получает свой токен доступа, и, зная свой токен доступа, каким-то образом угадывает токены, которые будут сгенерированы сервером клиентам сразу после него. И затем, подождав некоторое время, за которое потенциально кто-то из пользователей авторизуется в приложении, он берет предполагаемый им сгенерированный токен доступа и пытается зайти с его использованием. Если он угадает, то сможет войти в систему от имени другого пользователя. А дальше уже делать все, что душа пожелает.
- Возможность брутфорса токенов. То есть перебора возможных вариантов сгенерированных токенов и попытки войти в систему с каждым из них.
Разберемся с первой уязвимостью и тем, опасна ли она для нас.
Всего есть пять версий UUID. Четыре из них используют неподходящие для нас варианты генерации: на основе текущего таймстемпа, MAC-адреса сервера и так далее. То есть способы генерации, при котором по одному идентификатору можно предугадать следующий с большой вероятностью.
Остановимся подробнее на версии № 4.
a2dada9b-gae8-4bdd-af76-af89bed2262f
Почти весь идентификатор генерируется случайным образом. Только два символа установлены в определенное значение. Это первый символ третьей группы, отвечающий за версию и первый символ четвертой группы, отвечающий за тип. Он, как правило, будет иметь значение «a». Таким образом, алгоритм генерации UUID делает следующее:
- Генерирует 30 случайных символов.
- Превращает их в правильную форму, добавляет версию с типом и возвращает сериализированное значение.
Второй пункт довольно тривиален, самое интересное кроется в первом пункте. Генерация 30 случайных
Для этого мы используем генератор случайных чисел — программную абстракцию, хранящую свое состояние и возвращающую новые порции случайных данных по запросу. Генератор случайных чисел берет предыдущее сгенерированное число, пропускает его через некоторую функцию и на выходе получает новое число, которое отдает пользователю, а также сохраняет у себя для следующей генерации числа.
Для генерации первого числа используется начальное значение, которое передается генератору, так называемый 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 по умолчанию создает по одному инстансу каждого сервиса и затем прокидывает его всем сервисам, которые его используют через
В этом же файле пропишем логику стартового заполнения данными. Не буду на этом останавливаться. Напишем два утилитарных декоратора: для определения необходимых прав для контроллера и для пометки публичных контроллеров.
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-авторизацией окончена.
Всем спасибо за внимание.
Пожалуй, самое лучшее, что каждый из нас может вынести из этой статьи это мнение коллег по теме.
Поэтому обязательно включайтесь в дискуссию в комментариях, особенно если вам есть что сказать.
Буду рад вопросам и замечаниям 😌
63 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів