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


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


main


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


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


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


Предустановка


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


Затем установим необходимые зависимости


pip install django
pip install django-rest-framework

На момент написания статьи установлен системный python 3.7.2, django 3.2.7, django-rest-framework 3.12.4.


Теперь создаем django-проект server и django-приложение core. Базу данных будем использовать сразу ту, которая указана по-умолчанию, а именно движок SQLite и хранилище в файле db.sqlite3 в корне проекта. Наконец, применяем миграции и создаем первого пользователя. Для демонстрации используется пароль pass1234!.


django-admin startproject server
cd server
django-admin startapp core
python3 ./manage.py migrate
python3 ./manage.py createsuperuser --username admin --email admin@example.com

И вот настала пора написать немного кода. Обязательно открываем server/server/settings.py и актуализируем INSTALLED_APPS


 INSTALLED_APPS = [
+    'core',
+    'rest_framework',
     'django.contrib.admin',
     'django.contrib.auth',

В папке server/core создаем файл serializers.py и определяем сериализатор, который будет записывать в ответе сервера интересующие нас поля из модели.


from rest_framework.serializers import ModelSerializer
from django.contrib.auth.models import User

class UserSerializer(ModelSerializer):

    class Meta:
        model = User
        fields = ['username', 'first_name', 'last_name', 'email', 'date_joined']

Избавляемся от содержимого файла server/core/views.py и пишем в нем простейший обработчик:


from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.decorators import api_view
from core.serializers import UserSerializer

@api_view()
def user(request: Request):
    return Response({
        'data': UserSerializer(request.user).data
    })

Обновляем server/server/urls.py, чтобы определить endpoint, в который будет ходить клиент.


 from django.contrib import admin
 from django.urls import path
+from core import views

 urlpatterns = [
     path('admin/', admin.site.urls),
+    path('api/user', views.user, name='user')
 ]

Из папки server запускаем


python3 ./manage.py runserver

Проверяем в браузере http://localhost:8000/api/users/. В ответе получим {"data": {"username": ""}}, потому что сервер воспринимает нас как анонима. В клиентской части отображать пока нечего, поэтому давайте не будем ей пока заниматься. Вместо этого займемся тем, чтобы сервер узнал нас как пользователя с именем admin.


Basic


Рассмотрим схему под названием Basic. Это простейшая схема, но это не про безопасность и встретить данный подход можно лишь в каких-то внутрикорпоративных legacy.


Какая тут была бы уместна аналогия… Ах да, если бы голова отсоединялась от туловища, то она и была бы пропуском в систему. Проблемы тут такие же, голову можно забыть где-нибудь, её могут украсть и если это случится, то достать точно такую же голову или даже просто другую становится делом невыполнимым.


В общем, берем логин и пароль, склеиваем эти две строки по двоеточию, вот так admin:pass1234!, кодируем в base64, получаем YWRtaW46cGFzczEyMzQh. Полученную последовательность символов складываем в заголовок Authorization. Как-то так:


Authorization: Basic YWRtaW46cGFzczEyMzQh

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


Реализуем схему на сервере


Нашему обработчику user в файле server/core/views.py надо сообщить две вещи: не пускать анонимов и использовать схему Basic. В этом нам помогут пара декораторов и несколько встроенных в django-rest-framework классов, вот так:


 from rest_framework.response import Response
-from rest_framework.decorators import api_view
+from rest_framework.decorators import api_view, permission_classes, authentication_classes
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.authentication import BasicAuthentication
 from core.serializers import UserSerializer

 @api_view()
+@permission_classes([IsAuthenticated])
+@authentication_classes([BasicAuthentication])
 def user(request: Request):

В настоящих проектах, скорее всего придется пользоваться настройками DEFAULT_AUTHENTICATION_CLASSES и DEFAULT_PERMISSION_CLASSES, это просто полезное замечание.


Если мы откроем в браузере адрес http://localhost:8000/api/users/ покажется встроенное в браузер всплывающее окно для ввода логина/пароля.


sign-in-drf


Если ничего не вводить, а нажать на кнопку отмены, то мы получим в ответ следующее:


HTTP 401 Unauthorized
Allow: OPTIONS, GET
Content-Type: application/json
Vary: Accept
WWW-Authenticate: Basic realm="api"

{
    "detail": "Authentication credentials were not provided."
}

Как браузер понял, что надо показать всплывающее окно для ввода учетных данных? По ответу. Код 401 значит, что пользователь не авторизован, следовательно, не может выполнять данное действие, потому что мы его не узнали. Это важно, ведь в этом случае сервер предоставляет возможность клиенту представиться и подтвердить свою идентичность, другими словами, аутентифицироваться, то возможно, что операция пройдет успешно.
Тут мы сталкиваемся с тем, что во многих материалах в интернете (допустим, в этом отличном видео, которое я готов рекомендовать посмотреть от начала до конца) код ошибки 401 задействуют абсолютно везде, игнорируя тот факт, что сервер обязан сообщить приемлемый способ аутентификации в заголовке ответа WWW-Authenticate [RFC7235 3.1]. Авторы либо забывают это сделать, хотя можно бы, либо используют этот код возврата в ситуациях, где в заголовок поместить просто нечего.
Вообще, все это сильно напоминает общение между людьми.


  • К: Дай мне доступ к ресурсу,
  • С: Не дам!
  • К: Почему? Вопрос повисает в воздухе

Мы как бы в неприличной форме указываем клиенту куда ему следует идти. А прикладывая заголовок WWW-Authenticate, мы на первый вопрос отвечаем: "Будьте добры, покажите, пожалуйста, свой пропуск, выписанный нашей организацией".


В упомянутом выше примере ответа мы видим, что в заголовке WWW-Authenticate содержится название используемой схемы, а именно Basic, а также realm, контекст, для которого используются свои учетные данные. Эту информацию можно будет использовать в клиенте, чтобы быстро сориентировать пользователя в возникшей ситуации, браузер так и делает, но на это могут заложить свою логику и разные мобильные клиенты. При этом источником правды будет сервер, клиенту не придется ничего придумывать от себя.
Но бывает так, что ответить кодом 401 нельзя, тогда вступает в игру код 403 и разговор приобретает следующий вид:


  • К: Дай мне доступ к ресурсу,
  • С: Вам сюда нельзя! Идите, куда подальше!

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


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


{
    "data": {
        "username": "admin",
        "first_name": "",
        "last_name": "",
        "email": "admin@example.com",
        "date_joined": "2021-09-11T09:55:18.424800Z"
    }
}

Имя и фамилия для пользователя, созданного из командной строки не заданы. Чтобы в клиенте было что отображать, я рекомендую зайти во встроенную django-админку http://localhost:8000/admin и задать эти поля.


Создадим клиент


Предварительно необходимо будет установить npm и create-react-app. Находясь в корневой папке, создадим react-приложение.


create-react-app client

На момент написания статьи версии следующие: npm 6.14.15, yarn 1.22.5, react 17.0.2.


В папке client в файле package.json нужно задать поле proxy для того, чтобы не мучиться с CORS и задавать относительные пути для запросов на бэкенд. Так отладочный сервер узнает про бэкенд и поймет, куда перенаправить запрос, если он сам не знает, что это за адрес.


     "eject": "react-scripts eject"
   },
+  "proxy": "http://localhost:8000",
   "eslintConfig": {
     "extends": [

Открываем файл client/src/App.js и сверстаем в нем простейшую страницу с информацией о профиле, попутно забирая её с бэкенда. Ниже — реализация функции App, а в начале файла не забываем про импорт import { useState, useEffect } from 'react';.


  const [ firstName, setFirstName] = useState('')
  const [ lastName, setLastName] = useState('')
  const [ username, setUsername] = useState('')
  const [ email, setEmail] = useState('')
  const [ dateJoined, setDateJoined] = useState('')
  const [ error, setError] = useState()

  useEffect(() => {
    fetch('/api/user')
      .then(response => {
        if (response.ok) {
          return response.json()
        } else {
          throw Error(`Something went wrong: code ${response.status}`)
        }
      })
      .then(({data}) => {
        setFirstName(data.first_name)
        setLastName(data.last_name)
        setUsername(data.username)
        setEmail(data.email)
        setDateJoined(data.date_joined)
      })
      .catch(error => {
        console.log(error)
        setError('Ошибка, подробности в консоли')
      })
  }, [])

  return (
    <div className="App">
      {error?
        <p>{error}</p>
      :
        <div className="Profile">
          <h1>{firstName} {lastName}</h1>
          <h2>{username}</h2>
          <p>email: {email}</p>
          <p>Профиль создан {dateJoined}</p>
        </div>
      }
    </div>
  );

В терминале из новой вкладки в папке client запускаем команду


yarn start

При этом сервер на python тоже должен продолжать работать.


Проверяем http://localhost:3000/.


sign-in-react


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


profile-page


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


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


И это правильно, получается. В документации django-rest-framework рекомендуют просить клиентов каждый раз вводить свои учетные данные заново. Что бы мы ни придумали, в любом случае будут серьезные риски безопасности, поэтому этот вариант не для нас. Хотя, в свое время, когда только-только налаживалось взаимодействие клиента и сервера, приходилось это использовать, как самая простая опция. Лишь потом я задумался...


Token


Нет, про этот способ я даже не знал. По-моему, он весьма логичное развитие предыдущего и я поставил его вторым по счету. Я для себя замечу, что он имеет мало отношения к документу от IEFT "HTTP Authentication: Token Access Authentication", потому что там речь идет о токенах bearer, т.е. на предъявителя. Они выпущены доверенной системой и сразу содержат в себе закодированную информацию, а также подписаны с помощью секретного ключа, но об этом позже.


Само слово token с английского переводится как знак, жетон, метка. Т.е. это некоторое обозначение некоторой сущности. Этой сущностью в рассматриваемой схеме является сессия или сеанс пользователя, в течение которого пользователь работает с системой. Поэтому токен сессии, айдишник сессии — это все одно. Схема очень похожа на предыдущую тем, что мы тоже передаем секретную строку в заголовке, но сначала мы в явном виде прямо в теле POST-запроса присылаем системе учетные данные, чтобы проверить их корректность. Затем, генерируется токен, пусть это будет 40 случайных букв в нижнем регистре или цифр и возвращается в ответе. То же самое записывается в базу данных, так система будет принимать следующие запросы только с заголовком Authorization: Token ${token}. Получая этот токен, клиент может его сложить в какое-то хранилище и отправлять при каждом следующем обращении.


Прекрасной аналогией будет бумажный пропуск со специальным секретным номером, который выписывают только в вашем личном присутствии, а перед самим обращением в систему специально обученные сотрудники сверяют на предмет наличия его в их собственной книге учета пропусков для подтверждения доступа. Если вы потеряете пропуск, не беда: обратившись лично, можно выписать новый, а старый вычеркнуть из книги, тогда нашедший/похитивший пропуск не сможет им воспользоваться.
Мы один раз проверили пароль и больше его не спрашиваем, не передаем, не храним ни в каком виде на клиенте. Узнать токен — не то же самое, что узнать пароль, у нас появится больше возможностей минимизировать масштаб разрушений, например, удалив токен из таблицы соответствия. А профиль останется за тем, кто знает пароль.


Как это встроить в наше приложение?


Со стороны сервера здесь весьма тривиальное исправление. Первым делом нужно создать модель для хранения токенов. И тут все уже сделано за нас, в django-приложении rest_framework.authtoken, добавим его в ISTALLED_APPS в файле server/server/settings.py:


 INSTALLED_APPS = [
     'core',
     'rest_framework',
+    'rest_framework.authtoken',
     'django.contrib.admin',
     'django.contrib.auth',
     'django.contrib.contenttypes',

У добавленного приложения есть свои миграции, не забудем их применить:


python3 ./manage.py migrate

Ну и в нашем обработчике server/core/views.py заменим BasicAuthentication на TokenAuthentication. Сначала в импортах:


-from rest_framework.authentication import BasicAuthentication
+from rest_framework.authentication import TokenAuthentication

Затем в декораторах:


-@authentication_classes([BasicAuthentication])
+@authentication_classes([TokenAuthentication])

Проверяем http://localhost:8000/api/user


Ответили нам ошибкой 401, очень похожей на предыдущую, но разница есть:


 HTTP 401 Unauthorized
 Allow: GET, OPTIONS
 Content-Type: application/json
 Vary: Accept
-WWW-Authenticate: Basic realm="api"
+WWW-Authenticate: Token

 {
     "detail": "Authentication credentials were not provided."
 }

Токен можно выписать себе опять пока только с помощью django-админки. Давайте попробуем это сделать, чтобы протестировать. Переходим на http://localhost:8000/admin/, затем щелкаем по "Tokens" и потом "Add token". Выбираем нашего суперпользователя и жмем "Save". У вас получится свой токен, у меня получился такой 559b614f17c0450c4e28e2f55ec6cc7a473da822, его и надо будет передать в заголовке. На красивой странице, сгенерированной силами Django REST Framework это сделать не получится, но можно в командной строке вот так:


curl -H "Authorization: Token 559b614f17c0450c4e28e2f55ec6cc7a473da822" http://localhost:8000/api/user

Что же… Все работает нормально, мы так сможем получать профиль пользователя. Но нужно научиться с помощью нашего клиента себе самому выписывать эти токены. Обеспечим эту возможность с помощью отправки JSON с полями username и password с помощью POST-запроса по адресу /api/login.


В файле server/core/serializers.py добавляем несколько сериализаторов для валидации входных и выходных данных:


@@ -1,5 +1,6 @@
-from rest_framework.serializers import ModelSerializer
+from rest_framework.serializers import Serializer, ModelSerializer, CharField
 from django.contrib.auth.models import User
+from rest_framework.authtoken.models import Token

 class UserSerializer(ModelSerializer):
@@ -7,3 +8,17 @@
     class Meta:
         model = User
         fields = ['username', 'first_name', 'last_name', 'email', 'date_joined']
+
+
+class IssueTokenRequestSerializer(Serializer):
+    model = User
+
+    username = CharField(required=True)
+    password = CharField(required=True)
+
+
+class TokenSeriazliser(ModelSerializer):
+
+    class Meta:
+        model = Token
+        fields = ['key']

Затем в server/core/views.py реализуем обработчик нашего логина, который примет учетные данные пользователя, проверит их на соответствие и выдаст либо существующий токен, либо заведет новый.


@@ -1,9 +1,26 @@
 from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.decorators import api_view, permission_classes, authentication_classes
-from rest_framework.permissions import IsAuthenticated
+from rest_framework.permissions import IsAuthenticated, AllowAny
 from rest_framework.authentication import TokenAuthentication
-from core.serializers import UserSerializer
+from core.serializers import UserSerializer, IssueTokenRequestSerializer, TokenSeriazliser
+from rest_framework.authtoken.models import Token
+from django.contrib.auth import authenticate
+
+
+@api_view(['POST'])
+@permission_classes([AllowAny])
+def issue_token(request: Request):
+    serializer = IssueTokenRequestSerializer(data=request.data)
+    if serializer.is_valid():
+        authenticated_user = authenticate(**serializer.validated_data)
+        try:
+            token = Token.objects.get(user=authenticated_user)
+        except Token.DoesNotExist:
+            token = Token.objects.create(user=authenticated_user)
+        return Response(TokenSeriazliser(token).data)
+    else:
+        return Response(serializer.errors, status=400)

 @api_view()

Не забываем зарегистрировать обработчик в server/server/urls.py, чтобы потом можно было делать запросы:


 urlpatterns = [
     path('admin/', admin.site.urls),
     path('api/user', views.user, name='user'),
+    path('api/login', views.issue_token, name='issue_token'),
 ]

Ну а в клиенте в файле client/src/App.js верстаем форму для входа в систему, реализуем её корректную отправку и отображение данных после ввода правильных данных.


@@ -2,6 +2,10 @@
 import './App.css';

 function App() {
+  const [token, setToken] = useState()
+  const [loading, setLoading] = useState()
+  const [formUsername, setFormUsername] = useState()
+  const [formPassword, setFormPassword] = useState()
   const [ firstName, setFirstName] = useState('')
   const [ lastName, setLastName] = useState('')
   const [ username, setUsername] = useState('')
@@ -10,7 +14,16 @@
   const [ error, setError] = useState()

   useEffect(() => {
-    fetch('/api/user')
+    if (token) {
+    fetch(
+        '/api/user',
+        {
+        headers: {
+          'Content-Type': 'application/json;charset=utf-8',
+          'Authorization': `Token ${token}`,
+        },
+      }
+    )
       .then(response => {
         if (response.ok) {
           return response.json()
@@ -24,24 +37,69 @@
         setUsername(data.username)
         setEmail(data.email)
         setDateJoined(data.date_joined)
+        setError(null)
+      })
+      .catch(error => {
+        console.log(error)
+        setError('Ошибка, подробности в консоли')
+      })
+    }
+  }, [token])
+
+  const submitHandler = e => {
+    e.preventDefault();
+    setLoading(true);
+    fetch(
+      '/api/login',
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json;charset=utf-8',
+        },
+        body: JSON.stringify({
+          username: formUsername,
+          password: formPassword,
+        })
+      }
+    )
+      .then(response => {
+        if (response.ok) {
+          return response.json()
+        } else {
+          throw Error(`Something went wrong: code ${response.status}`)
+        }
+      })
+      .then(({key}) => {
+        setToken(key)
+        setError(null)
       })
       .catch(error => {
         console.log(error)
         setError('Ошибка, подробности в консоли')
       })
-  }, [])
+      .finally(setLoading(false))
+    }

   return (
     <div className="App">
-      {error?
-        <p>{error}</p>
+      {error? <p>{error}</p> : null}
+      {!token?
+        loading? "Загрузка..." :
+        <form className="loginForm" onSubmit={submitHandler}>
+          <input type="text" name="username" value={formUsername} onChange={e => setFormUsername(e.target.value)} placeholder="Username"/>
+          <input type="password" name="password" value={formPassword} onChange={e => setFormPassword(e.target.value)} placeholder="Password"/>
+          <input type="submit" name="submit" value="Войти"/>
+        </form>
       :
+        !error?
         <div className="Profile">
           <h1>{firstName} {lastName}</h1>
           <h2>{username}</h2>
           <p>email: {email}</p>
           <p>Профиль создан {dateJoined}</p>
         </div>
+        :
+        null
       }
     </div>
   );

login-page


В приведенном примере токен сохраняется прямо в переменную состояния компонента token. Так получается даже хуже, чем в Basic, ведь при перезагрузке страницы токен теряется и надо входить в систему заново.


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


Насколько я хорошо осведомлен, схема с токеном весьма актуальна и широко применяется для мобильных и десктопных приложений. Почему? Ранее мне казалось, что их сложнее взломать. Но дело, скорее, совсем не в этом — мобильное приложение не умеет работать с cookies!


Session


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


Давайте переделаем приложение, чтобы задействовать эту схему.


Первым делом меняем TokenAuthentication на SessionAuthentication в импорте:


-from rest_framework.authentication import TokenAuthentication
+from rest_framework.authentication import SessionAuthentication

И в декораторе:


-@authentication_classes([TokenAuthentication])
+@authentication_classes([SessionAuthentication])

Проверяем, что изменится на http://localhost:8000/api/user


HTTP 403 Forbidden
Allow: GET, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Authentication credentials were not provided."
}

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


Django по-умолчанию предоставляет механизм сессий. Т.е. когда мы впервые заходим на сайт нам генерируется случайная строка фиксированной длины, такая же как для токена из прошлого раздела и заносится в cookie. Теперь среди всех запросов мы можем определить какие сделал один и тот же пользователь и отследить его цепочку.
Что теперь будет, если он введет свои логин/пароль? Мы просто свяжем номер его сессии и его данные на сервере. Сами данные по поводу того, какой это пользователь, мы можем хранить по-разному: в базе данных, в файлах, в cookies (криптографически подписанных).
В любом случае, мы можем определить от какого именно пользователя поступают запросы, и к каким данным он имеет доступ.
Из клиента к этим данным не будет никакого доступа, если мы не меняли значение SESSION_COOKIE_HTTPONLY по-умолчанию, поэтому внедрение вредоносного кода в приложение не будет иметь тяжелых последствий. Однако, все остается необходимость использовать CSRF-токены и, тем самым, подтверждать, что запрос сделан именно от нашего клиента, а не по вредоносной ссылке со стороннего сайта.


В файле server/core/serializers.py уберем ненужный сериализатор TokenSeriazliser, а IssueTokenRequestSerializer переименуем просто в LoginRequestSerializer. Функцию issue_token из файла server/core/views.py придется удалить и вместо неё написать функцию login со следующей реализацией:


@api_view(['POST'])
@permission_classes([AllowAny])
def login(request: Request):
    serializer = LoginRequestSerializer(data=request.data)
    if serializer.is_valid():
        authenticated_user = authenticate(**serializer.validated_data)
        if authenticated_user is not None:
            login(request, authenticated_user)
            return Response({'status': 'Success'})
        else:
            return Response({'error': 'Invalid credentials'}, status=403)
    else:
        return Response(serializer.errors, status=400)

И не забываем про импорты:


-from rest_framework.authentication import TokenAuthentication
+from rest_framework.authentication import SessionAuthentication
-from core.serializers import UserSerializer, IssueTokenRequestSerializer, TokenSeriazliser
+from core.serializers import UserSerializer, LoginRequestSerializer
-from rest_framework.authtoken.models import Token
-from django.contrib.auth import authenticate
+from django.contrib.auth import authenticate, login

Как не забываем и про обновление server/server/urls.py:


 urlpatterns = [
     path('admin/', admin.site.urls),
     path('api/user', views.user, name='user'),
-    path('api/login', views.issue_token, name='issue_token'),
+    path('api/login', views.login, name='login'),
 ]

Чтобы адаптировать клиент client/src/App.js понадобится не запрашивать токен, а просто управлять флагом isLoggedIn, обязательно брать из document.cookie токен для защиты от CSRF-атак и отправлять его вместе с запросом на логин в заголовке X-CSRFToken:


@@ -1,8 +1,16 @@
 import { useState, useEffect } from 'react';
 import './App.css';

+
+function getCookie(name) {
+  const value = `; ${document.cookie}`;
+  const parts = value.split(`; ${name}=`);
+  if (parts.length === 2) return parts.pop().split(';').shift();
+}
+
+
 function App() {
-  const [token, setToken] = useState()
+  const [isLoggedIn, setIsLoggedIn] = useState(true)
   const [loading, setLoading] = useState()
   const [formUsername, setFormUsername] = useState()
   const [formPassword, setFormPassword] = useState()
@@ -12,15 +20,15 @@
   const [ email, setEmail] = useState('')
   const [ dateJoined, setDateJoined] = useState('')
   const [ error, setError] = useState()
+  const csrftoken = getCookie('csrftoken')

   useEffect(() => {
-    if (token) {
+    if (isLoggedIn) {
     fetch(
         '/api/user',
         {
         headers: {
           'Content-Type': 'application/json;charset=utf-8',
-          'Authorization': `Token ${token}`,
         },
       }
     )
@@ -42,9 +50,10 @@
       .catch(error => {
         console.log(error)
         setError('Ошибка, подробности в консоли')
+        setIsLoggedIn(false)
       })
     }
-  }, [token])
+  }, [isLoggedIn])

   const submitHandler = e => {
     e.preventDefault();
@@ -55,6 +64,7 @@
         method: 'POST',
         headers: {
           'Content-Type': 'application/json;charset=utf-8',
+          'X-CSRFToken': csrftoken,
         },
         body: JSON.stringify({
           username: formUsername,
@@ -70,7 +80,7 @@
         }
       })
       .then(({key}) => {
-        setToken(key)
+        setIsLoggedIn(true)
         setError(null)
       })
       .catch(error => {
@@ -83,7 +93,7 @@
   return (
     <div className="App">
       {error? <p>{error}</p> : null}
-      {!token?
+      {!isLoggedIn?
         loading? "Загрузка..." :
         <form className="loginForm" onSubmit={submitHandler}>
           <input type="text" name="username" value={formUsername} onChange={e => setFormUsername(e.target.value)} placeholder="Username"/>

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


JWT


А здесь у нас последнее слово техники — веб-токены, они бывают разные, но мы рассмотрим 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 нет, но есть популярная библиотека, установим её.


pip install djangorestframework-simplejwt

На момент написания статьи установилась версия 4.8.0. Затем, как мы уже привыкли, меняем файл server/core/views.py. Импортируем нужный класс:


-from rest_framework.authentication import SessionAuthentication
+from rest_framework_simplejwt.authentication import JWTAuthentication

И меняем его в декораторе:


-@authentication_classes([SessionAuthentication])
+@authentication_classes([JWTAuthentication])

Как мы это сделаем, проверим http://localhost:8000/api/user, поймаем ошибку 401:


HTTP 401 Unauthorized
Allow: OPTIONS, GET
Content-Type: application/json
Vary: Accept
WWW-Authenticate: Bearer realm="api"

{
    "detail": "Authentication credentials were not provided."
}

Bearer — это значит тут нужен токен на предъявителя. В том же файле server/core/views.py можно удалить обработчик login совсем, мы будем пользоваться уже встроенными средствами. Также избавимся от лишних импортов, оставив в итоге только обработчик, выдающий информацию о пользователе.


В server/server/urls.py мы добавим два пути: /api/token/obtain и /api/token/refresh, которые вызывают уже встроенные в django-rest-framework-simplejwt обработчики.


 from core import views
+from rest_framework_simplejwt.views import (
+    TokenObtainPairView,
+    TokenRefreshView,
+)

 urlpatterns = [
     path('admin/', admin.site.urls),
     path('api/user', views.user, name='user'),
-    path('api/login', views.login, name='login'),
+    path('api/token/obtain', TokenObtainPairView.as_view(), name='token_obtain'),
+    path('api/token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
 ]

Наконец, воспользуемся тем, что сама библиотека предлагает нам средства для занесения токенов в черный список. Достаточно лишь открыть файл 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, здесь придется много чего учесть, мне довелось сделать это как-то так:


@@ -10,7 +10,9 @@

 function App() {
-  const [isLoggedIn, setIsLoggedIn] = useState(true)
+  const [access, setAccess] = useState(localStorage.getItem('accessToken'))
+  const [refresh, setRefresh] = useState(localStorage.getItem('refreshToken'))
+  const [refreshRequired, setRefreshRequired] = useState(false)
   const [loading, setLoading] = useState()
   const [formUsername, setFormUsername] = useState()
   const [formPassword, setFormPassword] = useState()
@@ -20,15 +22,15 @@
   const [ email, setEmail] = useState('')
   const [ dateJoined, setDateJoined] = useState('')
   const [ error, setError] = useState()
-  const csrftoken = getCookie('csrftoken')

   useEffect(() => {
-    if (isLoggedIn) {
+    if (access) {
     fetch(
         '/api/user',
         {
         headers: {
           'Content-Type': 'application/json;charset=utf-8',
+          'Authorization': `Bearer ${access}`,
         },
       }
     )
@@ -36,6 +38,9 @@
         if (response.ok) {
           return response.json()
         } else {
+          if (response.status === 401) {
+            throw Error('refresh')
+          }
           throw Error(`Something went wrong: code ${response.status}`)
         }
       })
@@ -48,23 +53,59 @@
         setError(null)
       })
       .catch(error => {
-        console.log(error)
-        setError('Ошибка, подробности в консоли')
-        setIsLoggedIn(false)
+        if (error.message === 'refresh') {
+          setRefreshRequired(true)
+        } else {
+          console.log(error)
+          setError('Ошибка, подробности в консоли')
+        }
       })
     }
-  }, [isLoggedIn])
+  }, [access])
+
+
+  useEffect(() => {
+    if (refreshRequired) {
+    fetch(
+        '/api/token/refresh',
+        {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json;charset=utf-8',
+        },
+        body: JSON.stringify({ refresh })
+      }
+    )
+      .then(response => {
+        if (response.ok) {
+          return response.json()
+        } else {
+          throw Error(`Something went wrong: code ${response.status}`)
+        }
+      })
+      .then(({access, refresh}) => {
+        localStorage.setItem('accessToken', access)
+        setAccess(access)
+        localStorage.setItem('refreshToken', refresh)
+        setRefresh(refresh)
+        setError(null)
+      })
+      .catch(error => {
+        console.log(error)
+        setError('Ошибка, подробности в консоли')
+      })
+    }
+  }, [refreshRequired])

   const submitHandler = e => {
     e.preventDefault();
     setLoading(true);
     fetch(
-      '/api/login',
+      '/api/token/obtain',
       {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json;charset=utf-8',
-          'X-CSRFToken': csrftoken,
         },
         body: JSON.stringify({
           username: formUsername,
@@ -79,8 +120,11 @@
           throw Error(`Something went wrong: code ${response.status}`)
         }
       })
-      .then(({key}) => {
-        setIsLoggedIn(true)
+      .then(({access, refresh}) => {
+        localStorage.setItem('accessToken', access)
+        setAccess(access)
+        localStorage.setItem('refreshToken', refresh)
+        setRefresh(refresh)
         setError(null)
       })
       .catch(error => {
@@ -93,7 +137,7 @@
   return (
     <div className="App">
       {error? <p>{error}</p> : null}
-      {!isLoggedIn?
+      {!access?
         loading? "Загрузка..." :
         <form className="loginForm" onSubmit={submitHandler}>
           <input type="text" name="username" value={formUsername} onChange={e => setFormUsername(e.target.value)} placeholder="Username"/>

Важные выводы


Раньше мне казалось, что авторизация на сессиях в одностраничниках на 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

Комментарии (4)


  1. apapacy
    11.09.2021 22:52
    +2

    >Как выглядит JWT и как он зашивает с себе всю информацию? Это три закодированные последовательности символов, записанные через точку: в первых двух частях в base64 кодируются два JSON-объекта — заголовок и пользовательские данные; третья генерируется на основе первых двух и секретного ключа — это цифровая подпись. Теперь сам токен в открытом виде содержит всю необходимую информацию для авторизации, а цифровая подпись не позволяет что-то в токене поменять. Когда система принимает запрос, она знает секретный ключ и может сама сгенерировать подпись и проверить, совпадает ли она с тем, что нам передали.

    Кодировка на самом деле base64url. Данные могут быть и зашированы, и это реально делают сервисы например google auth2. Для подписи чаще испрользуют криптографию с закрытым и открытым ключом. И как раз для проверки подписи достатоно открытого ключа.


  1. apapacy
    11.09.2021 23:11
    +1

    >При этом старый refresh заносится в черный список и больше не может быть использован. Обращаться к внешнему хранилищу все-таки надо, но уже не так часто.

    Надо четко представлять, что все что связано с refresh token и методы ротации токенов не оределены в стандарте и относятся к категории городского фольклора. Единствекнный смысл ао времени жизни токенов следующий

    1) время жизни access токена является периодом для которого в данном конкретном приложении допустимо не перзарашивать данные о пользователе в системе. При этом возникает иногда необзодимостьнемедленного отзыва access токено для чего формируют черный список

    2) время жизни refresh токена это собственно время после которого произойдет разлогин клиента если не было активности клиента

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


    1. mrevgenx Автор
      12.09.2021 09:32
      +1

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


      1. apapacy
        12.09.2021 16:36
        +2

        Я работаю со стеком js у на с б блиотеками с популярными все нормально. Реализации адекватные наверное на python тоже есть такие если не попасть случайно на экзотику. Тут скорее речь идёт о том что непонимание того что может и что не может токен приводит к решениям которые выглядят просто неубедительно. Например хранение товаров в базе данных. Или хранение в товаре тол ко идентификатора пользователя. Или вообще идентификатора сессии. Одноразовое использование токена принадлежит к той же серии. Несмотря на повсеместное распрос ранение этой традиции я так и не услышал ни одного убедительного объяснения зачем это нужно. Говорят что так безопасно. Но в чем эта безопасность заключается я так и не понял. Если злоумышленник получил токен точное ему мешает тут же им и воспользоваться?

        Вцелом jwt можно рекомендовать к испол доверию во все проектах без исключения. Только сначала нужно просто подумать