В предыдущей статье я писал веб-приложение и совершенно бездумно реализовал там авторизацию, построенную на JWT. В этой статье я хотел бы устроить небольшое погружение в технические детали того, как это устроено, перебрать разные альтернативы. Достаточно несложно в React нарисовать форму, где можно позволить пользователям вводить свои учетные данные, включающие в себя логин и пароль. Не стоит практически никаких усилий, чтобы на Django сверить пароль, соответствующий логину в базе данных. Но что дальше? Обзор получился достаточно объемный с примерами кода, которые помогут воссоздать реализацию всех схем аутентификации/авторизации.  Системе важно понять, кто послал запрос — идентифицировать по нику, электронной почте, номеру телефона, не важно. Убедиться, что никто не выдает себя за кого-то другого — аутентифицировать по паролю, сертификату или, например, токену. Последним шагом проверяются полномочия пославшего запрос, которые зафиксированы заранее. Именно от последнего шага будет зависеть пройдет ли запрос на следующие стадии обработки. Нельзя же читать не свои личные документы или редактировать чужие публикации и важно сразу выявлять подобные попытки.  После успешной аутентификации пользователя клиентское приложение (будь то браузер или нативное приложение) должны получить что-то взамен, своего рода пропуск, чтобы узнать вас в следующий раз и не спрашивать ничего снова, ведь это было бы достаточно неудобно. Именно с этим пропуском мы получаем доступ к ресурсам. Стоит заметить, что во всех рассмотренных в статье схемах авторизации будет происходить обмен секретными данными, которые будут передаваться в открытом виде. Хотя бы поэтому использование https становится обязательным для правильной организации доступа к защищенным ресурсам, с помощью него устанавливается безопасное шифрованное соединение. Мы напишем приложение, которое будет отображать данные профиля пользователя, их сможет просматривать только владелец профиля, знающий логин и пароль. Для простоты сами профили будут создаваться и заполняться только с помощью встроенной django-админки. Для начала определимся с папкой и создадим в ней виртуальное окружение python и тут же активируем его, все это для удобства работы. Затем установим необходимые зависимости На момент написания статьи установлен системный python 3.7.2, django 3.2.7, django-rest-framework 3.12.4. Теперь создаем django-проект server и django-приложение core. Базу данных будем использовать сразу ту, которая указана по-умолчанию, а именно движок SQLite и хранилище в файле db.sqlite3 в корне проекта. Наконец, применяем миграции и создаем первого пользователя. Для демонстрации используется пароль pass1234!. И вот настала пора написать немного кода. Обязательно открываем server/server/settings.py и актуализируем INSTALLED_APPS В папке server/core создаем файл serializers.py и определяем сериализатор, который будет записывать в ответе сервера интересующие нас поля из модели. Избавляемся от содержимого файла server/core/views.py и пишем в нем простейший обработчик: Обновляем server/server/urls.py, чтобы определить endpoint, в который будет ходить клиент. Из папки server запускаем Проверяем в браузере http://localhost:8000/api/users/. В ответе получим {"data": {"username": ""}}, потому что сервер воспринимает нас как анонима. В клиентской части отображать пока нечего, поэтому давайте не будем ей пока заниматься. Вместо этого займемся тем, чтобы сервер узнал нас как пользователя с именем admin. Рассмотрим схему под названием Basic. Это простейшая схема, но это не про безопасность и встретить данный подход можно лишь в каких-то внутрикорпоративных legacy. Какая тут была бы уместна аналогия… Ах да, если бы голова отсоединялась от туловища, то она и была бы пропуском в систему. Проблемы тут такие же, голову можно забыть где-нибудь, её могут украсть и если это случится, то достать точно такую же голову или даже просто другую становится делом невыполнимым. В общем, берем логин и пароль, склеиваем эти две строки по двоеточию, вот так admin:pass1234!, кодируем в base64, получаем YWRtaW46cGFzczEyMzQh. Полученную последовательность символов складываем в заголовок Authorization. Как-то так: Сервер извлечет заголовок из запроса, раскодирует его, найдет пользователя, сверит пароль и лишь затем выполнит целевое действие. Стоит заметить, что закодирован — не значит зашифрован, ведь нет использования секретного ключа. Поэтому обязательно трафик должен быть зашифрован. Нашему обработчику user в файле server/core/views.py надо сообщить две вещи: не пускать анонимов и использовать схему Basic. В этом нам помогут пара декораторов и несколько встроенных в django-rest-framework классов, вот так: В настоящих проектах, скорее всего придется пользоваться настройками DEFAULT_AUTHENTICATION_CLASSES и DEFAULT_PERMISSION_CLASSES, это просто полезное замечание. Если мы откроем в браузере адрес http://localhost:8000/api/users/ покажется встроенное в браузер всплывающее окно для ввода логина/пароля.  Если ничего не вводить, а нажать на кнопку отмены, то мы получим в ответ следующее: Как браузер понял, что надо показать всплывающее окно для ввода учетных данных? По ответу. Код 401 значит, что пользователь не авторизован, следовательно, не может выполнять данное действие, потому что мы его не узнали. Это важно, ведь в этом случае сервер предоставляет возможность клиенту представиться и подтвердить свою идентичность, другими словами, аутентифицироваться, то возможно, что операция пройдет успешно.
Тут мы сталкиваемся с тем, что во многих материалах в интернете (допустим, в этом отличном видео, которое я готов рекомендовать посмотреть от начала до конца) код ошибки 401 задействуют абсолютно везде, игнорируя тот факт, что сервер обязан сообщить приемлемый способ аутентификации в заголовке ответа WWW-Authenticate . Авторы либо забывают это сделать, хотя можно бы, либо используют этот код возврата в ситуациях, где в заголовок поместить просто нечего.
Вообще, все это сильно напоминает общение между людьми. Мы как бы в неприличной форме указываем клиенту куда ему следует идти. А прикладывая заголовок WWW-Authenticate, мы на первый вопрос отвечаем: "Будьте добры, покажите, пожалуйста, свой пропуск, выписанный нашей организацией". В упомянутом выше примере ответа мы видим, что в заголовке WWW-Authenticate содержится название используемой схемы, а именно Basic, а также realm, контекст, для которого используются свои учетные данные. Эту информацию можно будет использовать в клиенте, чтобы быстро сориентировать пользователя в возникшей ситуации, браузер так и делает, но на это могут заложить свою логику и разные мобильные клиенты. При этом источником правды будет сервер, клиенту не придется ничего придумывать от себя.
Но бывает так, что ответить кодом 401 нельзя, тогда вступает в игру код 403 и разговор приобретает следующий вид: Тогда клиент будет понимать, что любой следующий запрос будет отвергнут и ничего с этим нельзя сделать… Кроме как сходить и попросить себе больше привелегий в сервисе. Если мы введем логин/пароль и нажмем кнопку подтверждения, то получим доступ к ресурсу. А в отладочной панели браузера на вкладке с сетевыми запросами мы увидим, что в запросе будет ожидаемый заголовок Authorization. Имя и фамилия для пользователя, созданного из командной строки не заданы. Чтобы в клиенте было что отображать, я рекомендую зайти во встроенную django-админку http://localhost:8000/admin и задать эти поля. Предварительно необходимо будет установить npm и create-react-app. Находясь в корневой папке, создадим react-приложение. На момент написания статьи версии следующие: npm 6.14.15, yarn 1.22.5, react 17.0.2. В папке client в файле package.json нужно задать поле proxy для того, чтобы не мучиться с CORS и задавать относительные пути для запросов на бэкенд. Так отладочный сервер узнает про бэкенд и поймет, куда перенаправить запрос, если он сам не знает, что это за адрес. Открываем файл client/src/App.js и сверстаем в нем простейшую страницу с информацией о профиле, попутно забирая её с бэкенда. Ниже — реализация функции App, а в начале файла не забываем про импорт import { useState, useEffect } from 'react';. В терминале из новой вкладки в папке client запускаем команду При этом сервер на python тоже должен продолжать работать. Проверяем http://localhost:3000/.  Введя логин и пароль, увидим сайт, который отображает профиль зарегистрированного пользователя.  Не шибко полезное приложение, кроме просмотра информации нельзя ничего сделать без доступа к django-админке, даже загеристрироваться на сайте самостоятельно нельзя. Но этого достаточно, чтобы объяснить суть. Из возможных плюсов — нам не потребовалось верстать форму для ввода логина и пароля. Также не потребовалось самим снабжать запросы внутри страницы заголовком Authorization, нигде не пришлось сохранять base64-строчку. Браузер предоставил все необходимые механизмы сам, мы один раз ввели логин и пароль в специальное всплывающее окно и в пределах одного realm все запросы будут автоматически снабжаться всеми данными, необходимыми для авторизации. Однако подобная пользовательская сессия будет забыта непосредственно, как будет закрыто окно браузера и учетные данные надо будет вводить снова. И это правильно, получается. В документации django-rest-framework рекомендуют просить клиентов каждый раз вводить свои учетные данные заново. Что бы мы ни придумали, в любом случае будут серьезные риски безопасности, поэтому этот вариант не для нас. Хотя, в свое время, когда только-только налаживалось взаимодействие клиента и сервера, приходилось это использовать, как самая простая опция. Лишь потом я задумался... Нет, про этот способ я даже не знал. По-моему, он весьма логичное развитие предыдущего и я поставил его вторым по счету. Я для себя замечу, что он имеет мало отношения к документу от IEFT "HTTP Authentication: Token Access Authentication", потому что там речь идет о токенах bearer, т.е. на предъявителя. Они выпущены доверенной системой и сразу содержат в себе закодированную информацию, а также подписаны с помощью секретного ключа, но об этом позже. Само слово token с английского переводится как знак, жетон, метка. Т.е. это некоторое обозначение некоторой сущности. Этой сущностью в рассматриваемой схеме является сессия или сеанс пользователя, в течение которого пользователь работает с системой. Поэтому токен сессии, айдишник сессии — это все одно. Схема очень похожа на предыдущую тем, что мы тоже передаем секретную строку в заголовке, но сначала мы в явном виде прямо в теле POST-запроса присылаем системе учетные данные, чтобы проверить их корректность. Затем, генерируется токен, пусть это будет 40 случайных букв в нижнем регистре или цифр и возвращается в ответе. То же самое записывается в базу данных, так система будет принимать следующие запросы только с заголовком Authorization: Token ${token}. Получая этот токен, клиент может его сложить в какое-то хранилище и отправлять при каждом следующем обращении. Прекрасной аналогией будет бумажный пропуск со специальным секретным номером, который выписывают только в вашем личном присутствии, а перед самим обращением в систему специально обученные сотрудники сверяют на предмет наличия его в их собственной книге учета пропусков для подтверждения доступа. Если вы потеряете пропуск, не беда: обратившись лично, можно выписать новый, а старый вычеркнуть из книги, тогда нашедший/похитивший пропуск не сможет им воспользоваться.
Мы один раз проверили пароль и больше его не спрашиваем, не передаем, не храним ни в каком виде на клиенте. Узнать токен — не то же самое, что узнать пароль, у нас появится больше возможностей минимизировать масштаб разрушений, например, удалив токен из таблицы соответствия. А профиль останется за тем, кто знает пароль. Со стороны сервера здесь весьма тривиальное исправление. Первым делом нужно создать модель для хранения токенов. И тут все уже сделано за нас, в django-приложении rest_framework.authtoken, добавим его в ISTALLED_APPS в файле server/server/settings.py: У добавленного приложения есть свои миграции, не забудем их применить: Ну и в нашем обработчике server/core/views.py заменим BasicAuthentication на TokenAuthentication. Сначала в импортах: Затем в декораторах: Проверяем http://localhost:8000/api/user Ответили нам ошибкой 401, очень похожей на предыдущую, но разница есть: Токен можно выписать себе опять пока только с помощью django-админки. Давайте попробуем это сделать, чтобы протестировать. Переходим на http://localhost:8000/admin/, затем щелкаем по "Tokens" и потом "Add token". Выбираем нашего суперпользователя и жмем "Save". У вас получится свой токен, у меня получился такой 559b614f17c0450c4e28e2f55ec6cc7a473da822, его и надо будет передать в заголовке. На красивой странице, сгенерированной силами Django REST Framework это сделать не получится, но можно в командной строке вот так: Что же… Все работает нормально, мы так сможем получать профиль пользователя. Но нужно научиться с помощью нашего клиента себе самому выписывать эти токены. Обеспечим эту возможность с помощью отправки JSON с полями username и password с помощью POST-запроса по адресу /api/login. В файле server/core/serializers.py добавляем несколько сериализаторов для валидации входных и выходных данных: Затем в server/core/views.py реализуем обработчик нашего логина, который примет учетные данные пользователя, проверит их на соответствие и выдаст либо существующий токен, либо заведет новый. Не забываем зарегистрировать обработчик в server/server/urls.py, чтобы потом можно было делать запросы: Ну а в клиенте в файле client/src/App.js верстаем форму для входа в систему, реализуем её корректную отправку и отображение данных после ввода правильных данных.  В приведенном примере токен сохраняется прямо в переменную состояния компонента token. Так получается даже хуже, чем в Basic, ведь при перезагрузке страницы токен теряется и надо входить в систему заново. Заносить в localstorage тоже не выход. Так мы повышаем удобство, но жертвуем безопасностью. Для приложений с высокими требованиями к надежности настоятельно не рекомендуют такие критичные данные, как токены складывать в localstorage. Насколько я хорошо осведомлен, схема с токеном весьма актуальна и широко применяется для мобильных и десктопных приложений. Почему? Ранее мне казалось, что их сложнее взломать. Но дело, скорее, совсем не в этом — мобильное приложение не умеет работать с cookies! Так мы подошли к самой распространенной среди сайтов за прошедшие десятилетия схеме. После успешной проверки логина и пароля, токен сессии выставляется в куках браузера и браузер автоматически шлет их при каждом следующем запросе, а сервер делает все то же самое, что и в прошлой схеме. Первым делом меняем TokenAuthentication на SessionAuthentication в импорте: И в декораторе: Проверяем, что изменится на http://localhost:8000/api/user Теперь код ответа 403, потому что аутентификация происходит не средствами самого протокола HTTP, теперь эти функции больше переложены на само приложение. Django по-умолчанию предоставляет механизм сессий. Т.е. когда мы впервые заходим на сайт нам генерируется случайная строка фиксированной длины, такая же как для токена из прошлого раздела и заносится в cookie. Теперь среди всех запросов мы можем определить какие сделал один и тот же пользователь и отследить его цепочку.
Что теперь будет, если он введет свои логин/пароль? Мы просто свяжем номер его сессии и его данные на сервере. Сами данные по поводу того, какой это пользователь, мы можем хранить по-разному: в базе данных, в файлах, в cookies (криптографически подписанных).
В любом случае, мы можем определить от какого именно пользователя поступают запросы, и к каким данным он имеет доступ.
Из клиента к этим данным не будет никакого доступа, если мы не меняли значение SESSION_COOKIE_HTTPONLY по-умолчанию, поэтому внедрение вредоносного кода в приложение не будет иметь тяжелых последствий. Однако, все остается необходимость использовать CSRF-токены и, тем самым, подтверждать, что запрос сделан именно от нашего клиента, а не по вредоносной ссылке со стороннего сайта. В файле server/core/serializers.py уберем ненужный сериализатор TokenSeriazliser, а IssueTokenRequestSerializer переименуем просто в LoginRequestSerializer. Функцию issue_token из файла server/core/views.py придется удалить и вместо неё написать функцию login со следующей реализацией: И не забываем про импорты: Как не забываем и про обновление server/server/urls.py: Чтобы адаптировать клиент client/src/App.js понадобится не запрашивать токен, а просто управлять флагом isLoggedIn, обязательно брать из document.cookie токен для защиты от CSRF-атак и отправлять его вместе с запросом на логин в заголовке X-CSRFToken: Кажется, на данном этапе должна получиться достаточно надежная система, которая будет знать своих пользователей в лицо. Однако нет предела развитию технологий и может так случиться, что это уже может не подходить. А здесь у нас последнее слово техники — веб-токены, они бывают разные, но мы рассмотрим JSON Web Token. Серверу уже не нужно идти в базу данных и проверять, какому пользователю соответствует данный токен, вся информация зашита в нем самом. Драйверами развития подобной технологии стало распространение микросервисного подхода к проектированию и появлению по-настоящему больших ресурсов, ведь база данных для проверки валидности сессий имеет свою пропускную способность по запросам к ней. К тому же, стали появляться конгломераты сервисов, живущих на разных доменах, но имеющие единую форму входа. Как выглядит JWT и как он зашивает с себе всю информацию? Это три закодированные последовательности символов, записанные через точку: в первых двух частях в base64 кодируются два JSON-объекта — заголовок и пользовательские данные; третья генерируется на основе первых двух и секретного ключа — это цифровая подпись. Теперь сам токен в открытом виде содержит всю необходимую информацию для авторизации, а цифровая подпись не позволяет что-то в токене поменять. Когда система принимает запрос, она знает секретный ключ и может сама сгенерировать подпись и проверить, совпадает ли она с тем, что нам передали. Самая лучшая аналогия — паспорт. Паспорт выпускается государством, как доверенной организацией и необязательно сверяться ни с каким источником, чтобы пропустить вас в систему по нему. Его крайне сложно подделать, в случае неудачи это будет заметно. Но что, если он будет потерян или его похитят? Надежный способ не представляется таким уж простым, каждое новшество, которое усиливает безопасность, порождает новые вопросы и технические вызовы. Для минимизации ущерба на данный момент придумано встраивать в пользовательские данные или payload время, до которого токен действителен. Тогда сервер не пропустит запрос с токеном с истекшим сроком действия. Чем более важные вопросы решает система, тем более короткие должны быть токены. Но как его обновлять? Неужели придется каждый раз оказываться на форме с логином паролем? Нет, над этим тоже подумали и придумали выпускать параллельно второй токен, который будет предназначен только для того, чтобы обновлять первый. Первый называется access token, а второй refresh token Но куда их складывать? Допустим, access живет 10 минут, его не критично занести в localstorage. С refresh все становится сложнее, ведь если обращаться с ним также, у нас добавляется много сложностей по разработке и поддержке системы и не возникает почти никаких преимуществ в безопасности и удобстве. Я однажды сам догадался складывать их в localstorage, но оно того не стоит, из-за этого родился этот весьма длинный обзор. Из приложенных ссылок я узнал, что есть следующая схема работы, описанная ниже. В ответ на запрос с логином и паролем, клиент получает пару токенов: access и refresh. Их мы заносим в localstorage, почему — об этом позже. access мы прикладываем при каждом запросе к бэкенду в заголовке Authorization: Bearer ${access_token}. Сервер проверяет подпись и срок действия токена и ему больше не нужно обращаться ни к каким хранилищам. Когда у access истекает срок действия, клиент отправляет запрос на специальный адрес api/refresh и получает новую пару токенов. При этом старый refresh заносится в черный список и больше не может быть использован. Обращаться к внешнему хранилищу все-таки надо, но уже не так часто. Встроенной в django-rest-framework поддержки JWT нет, но есть популярная библиотека, установим её. На момент написания статьи установилась версия 4.8.0. Затем, как мы уже привыкли, меняем файл server/core/views.py. Импортируем нужный класс: И меняем его в декораторе: Как мы это сделаем, проверим http://localhost:8000/api/user, поймаем ошибку 401: Bearer — это значит тут нужен токен на предъявителя. В том же файле server/core/views.py можно удалить обработчик login совсем, мы будем пользоваться уже встроенными средствами. Также избавимся от лишних импортов, оставив в итоге только обработчик, выдающий информацию о пользователе. В server/server/urls.py мы добавим два пути: /api/token/obtain и /api/token/refresh, которые вызывают уже встроенные в django-rest-framework-simplejwt обработчики. Наконец, воспользуемся тем, что сама библиотека предлагает нам средства для занесения токенов в черный список. Достаточно лишь открыть файл server/server/settings.py и добавить в INSTALLED_APPS приложение rest_framework_simplejwt.token_blacklist. В том же файле добавить две настройки со значением True: ROTATE_REFRESH_TOKENS и BLACKLIST_AFTER_ROTATION. Первая будет заставлять запрос на обновление access обновлять также и refresh, а вторая занесет использованный refresh в черный список и им больше нельзя будет воспользоваться. Стоит также определить для себя значения ACCESS_TOKEN_LIFETIME и REFRESH_TOKEN_LIFETIME. Ну, и наконец-то, обновляем клиент client/src/App.js, здесь придется много чего учесть, мне довелось сделать это как-то так: Раньше мне казалось, что авторизация на сессиях в одностраничниках на react строится гораздо сложнее, чем есть на самом деле. А управлять двумя токенами оказалось похоже на overkill, не добавляющий ничего хорошего в систему, кроме бремени поддержки. Я свои выводы сделал и пойду дорабатывать свое приложение. Пусть каждому выводу предшествует достаточно глубокое исследование, укладывающееся в дедлайны. [0] Обзор способов и протоколов аутентификации в веб-приложениях
[1] RFC7235: Hypertext Transfer Protocol (HTTP/1.1): Authentication
[2] RFC2617: HTTP Authentication: Basic and Digest Access Authentication
[3] RFC7617: The 'Basic' HTTP Authentication Scheme
[4] The OAuth 2.0 Authorization Framework: Bearer Token Usage
[5] Introduction to JSON Web Tokens
[6] Пять простых шагов для понимания JSON Web Tokens (JWT)
[7] О хранении JWT токенов в браузерах
[8] Про токены, JSON Web Tokens (JWT), аутентификацию и авторизацию. Token-Based Authentication
[9] Зачем нужен Refresh Token, если есть Access Token?
[10] Добавляем Refresh Token Software Developer