Аутентификация с помощью сессий в Django SPA

В этой статье мы рассмотрим, как аутентифицировать одностраничные приложения (SPA) с помощью сеансовой аутентификации. Для бэкенда мы будем использовать Django, а для фронтенда - React, библиотеку JavaScript, предназначенную для построения пользовательских интерфейсов.

Не стесняйтесь заменить React на другой инструмент, например Angular, Vue или Svelte.

Сессия против аутентификации на основе токенов

Что это такое?

При сеансовой аутентификации генерируется сеанс, а идентификатор хранится в cookie.

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

session-based auth workflow

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

С другой стороны, аутентификация на основе токенов является относительно новой по сравнению с аутентификацией на основе сеансов. Она получила распространение с появлением SPA и RESTful API.

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

token-based auth workflow

Токен - это строка, в которой закодирована информация о пользователе.

Например:

// token header
{
  "alg": "HS256",
  "typ": "JWT"
}

// token payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Токен может быть проверен и ему можно доверять, поскольку он подписан цифровой подписью с использованием секретного ключа или пары открытый/закрытый ключ. Наиболее распространенным типом токена является JSON Web Token (JWT).

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

Подробнее о сессиях и токенах читайте в статье Session Authentication vs Token Authentication от Stack Exchange.

Уязвимые места в системе безопасности

Как уже говорилось, при сеансовой аутентификации состояние клиента хранится в cookie. В то время как JWT могут храниться в localStorage или в cookie, большинство реализаций аутентификации на основе токенов хранят JWT в localStorage. Оба этих метода сопряжены с потенциальными проблемами безопасности:

Storage Method Security Vulnerability
Cookie Cross Site Request Forgery (CSRF)
localStorage Cross-Site Scripting (XSS)

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

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

XSS-атаки - это тип инъекций, при котором вредоносные скрипты внедряются на стороне клиента, как правило, для обхода одноименной политики браузера. Веб-приложения, хранящие токены в localStorage, открыты для XSS-атак. Откройте браузер и перейдите на любой сайт. Откройте консоль в инструментах разработчика и введите JSON.stringify(localStorage). Нажмите клавишу Enter. Это должно вывести элементы localStorage в сериализованном виде JSON. Вот так просто скрипт может получить доступ к localStorage.

Подробнее о том, где хранить JWT, читайте в статье "Где хранить JWT - Cookies vs. HTML5 Web Storage".

Настройка аутентификации на основе сеанса

В данном руководстве рассматриваются следующие подходы к объединению Django с фронтенд-библиотекой или фреймворком:

  1. Разместить фреймворк через шаблоны Django
  2. Поддерживать фреймворк отдельно от Django на одном домене
  3. Создать фреймворк отдельно от Django с помощью Django REST Framework на том же домене
  4. Обеспечить работу фреймворка отдельно от Django на другом домене

Опять же, не стесняйтесь заменить React на фронтенд по вашему выбору - например, Angular, Vue или Svelte.

Фронтенд, обслуживаемый из Django

При таком подходе мы будем обслуживать наше React-приложение непосредственно из Django. Этот подход наиболее прост в настройке.

Backend

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

$ mkdir django_react_templates && cd django_react_templates
$ python3.11 -m venv env
$ source env/bin/activate

(env)$ pip install django==4.2.3
(env)$ django-admin startproject djangocookieauth .

После этого создайте новое приложение с именем api:

(env)$ python manage.py startapp api

Зарегистрируйте приложение в djangocookieauth/settings.py под INSTALLED_APPS:

# djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Наше приложение будет иметь следующие конечные точки API:

  1. /api/login/ позволяет пользователю войти в систему, указав свое имя пользователя и пароль
  2. /api/logout/ выводит пользователя из системы
  3. /api/session/ проверяет наличие сессии
  4. /api/whoami/ получает пользовательские данные для аутентифицированного пользователя

Для представления возьмите полный код здесь и добавьте его в файл api/views.py.

Добавьте файл urls.py в раздел "api" и определите следующие URL-адреса:

# api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Теперь зарегистрируем URL-адреса наших приложений в базовом проекте:

# djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Код для нашего бэкенда теперь более или менее готов. Выполним команду migrate и создадим суперпользователя для тестирования в будущем:

(env)$ python manage.py migrate
(env)$ python manage.py createsuperuser

Наконец, обновите следующие настройки безопасности в файле djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = False  # False since we will grab it via universal-cookies
SESSION_COOKIE_HTTPONLY = True

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Примечания:

  1. Установка CSRF_COOKIE_SAMESITE и SESSION_COOKIE_SAMESITE в значение True предотвращает отправку cookies и CSRF-токенов из любых внешних запросов.
  2. Установка CSRF_COOKIE_HTTPONLY и SESSION_COOKIE_HTTPONLY в значение True блокирует доступ клиентского JavaScript к CSRF и сессионным cookies. Мы устанавливаем CSRF_COOKIE_HTTPONLY в значение False, так как будем обращаться к cookie с помощью JavaScript.

Если вы работаете на производстве, то вам следует обслуживать ваш сайт по HTTPS и включить опции CSRF_COOKIE_SECURE и SESSION_COOKIE_SECURE, которые позволят отправлять cookies только по HTTPS.

Frontend

Перед началом работы над фронтендом убедитесь, что у вас установлены Node.js и npm (или Yarn).

Мы будем использовать Vite для создания нового проекта React:

$ npm create vite@4.4 frontend

Выберите React в качестве фреймворка с JavaScript в качестве варианта:

✔ Select a framework: › React
✔ Select a variant: › JavaScript

Затем установите зависимости и запустите сервер разработки:

$ cd frontend
$ npm install
$ npm run dev

Это запустит наше приложение на порт 5173. Посетите http://localhost:5173/, чтобы убедиться в его работоспособности.

Для упрощения работы удалите следующие CSS-файлы:

  1. frontend/src/App.css
  2. frontend/src/index.css

Далее добавим Bootstrap в frontend/index.html:

<!-- frontend/index.html -->

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
     <!-- new -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Далее мы будем использовать universal-cookie для загрузки cookies в приложение React.

Установите его из папки "frontend":

$ npm install universal-cookie@4.0.4

Возьмите полный код здесь для компонента App и добавьте его в файл frontend/src/App.jsx.

Это простое фронтенд-приложение с формой, которая обрабатывается с помощью React state. При загрузке страницы вызывается compontentDidMount(), который получает сессию и устанавливает isAuthenticated в значение true или false.

Мы получили CSRF-токен с помощью universal-cookie и передали его в качестве заголовка в запросах в виде X-CSRFToken:

import Cookies from "universal-cookie";

const cookies = new Cookies();

login = (event) => {
  event.preventDefault();
  fetch("/api/login/", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRFToken": cookies.get("csrftoken"),
      },
      credentials: "same-origin",
      body: JSON.stringify({username: this.state.username, password: this.state.password}),
    })
    .then(this.isResponseOk)
    .then((data) => {
      console.log(data);
      this.setState({isAuthenticated: true, username: "", password: "", error: ""});
    })
    .catch((err) => {
      console.log(err);
      this.setState({error: "Wrong username or password."});
    });
}

Обратите внимание, что при каждом запросе мы использовали credentials: same-origin. Это необходимо, поскольку мы хотим, чтобы браузер передавал cookies при каждом HTTP-запросе, если URL имеет то же происхождение, что и вызывающий скрипт.

Обновление frontend/src/main.jsx:

// frontend/src/main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

Обслуживание React

Поскольку мы будем обслуживать статические файлы с /static/ URL, добавьте конфигурацию public base в frontend/vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  base: '/static/'  // new
})

Затем создайте фронтенд-приложение:

$ npm run build

Эта команда создаст папку "dist", которую наш бэкенд будет использовать для обслуживания нашего React-приложения.

Далее мы должны сообщить Django, где находится наше приложение React:

# djangocookieauth/settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR.joinpath('frontend')],  # new
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

...

STATIC_URL = '/static/'

STATICFILES_DIRS = (
    BASE_DIR.joinpath('frontend', 'dist'),  # new
)

Если вы используете старую версию Django, убедитесь, что импортировали os и используете os.path.join вместо joinpath.

Давайте создадим индексное представление для нашего приложения:

# djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include


# new
def index_view(request):
    return render(request, 'dist/index.html')


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('', index_view, name='index'),  # new
]

Поскольку Django в конечном итоге обслуживает фронтенд, CSRF-куки будут установлены автоматически.

Из корня проекта запустите сервер Django с помощью команды runserver следующим образом:

(env)$ python manage.py runserver

Откройте браузер и перейдите по адресу http://localhost:8000/. Теперь ваше React-приложение обслуживается через шаблоны Django.

Login page

При загрузке устанавливается CSRF-куки, который используется в последующих XHR-запросах. Если пользователь вводит правильное имя пользователя и пароль, то происходит его аутентификация и сохранение куки sessionid в браузере.

Session page

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

Полный код этого подхода можно получить на GitHub: django_react_templates.

Фронтенд обслуживается отдельно (один и тот же домен)

При таком подходе мы будем создавать фронтенд и обслуживать его отдельно от приложения Django на том же домене. Мы будем использовать Docker и Nginx для локального обслуживания обоих приложений на одном домене.

Основное отличие шаблонного подхода от данного заключается в том, что нам придется вручную получать CSRF-токен при загрузке.

Начнем с создания каталога проекта:

$ mkdir django_react_same_origin && cd django_react_same_origin

Backend

Сначала создайте новый каталог "backend" для проекта Django:

$ mkdir backend && cd backend

Далее создаем и активируем новую виртуальную среду, устанавливаем Django и создаем новый проект Django:

$ python3.11 -m venv env
$ source env/bin/activate

(env)$ pip install django==4.2.3
(env)$ django-admin startproject djangocookieauth .

После этого создайте новое приложение с именем api:

(env)$ python manage.py startapp api

Зарегистрируйте приложение в djangocookieauth/settings.py под INSTALLED_APPS:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Наше приложение будет иметь следующие конечные точки API:

  1. /api/get_csrf/ сгенерирует CSRF-токен и вернет его в виде JSON
  2. .
  3. /api/login/ позволит пользователю войти в систему, указав имя пользователя и пароль
  4. /api/logout/ выводит пользователя из системы
  5. /api/session/ проверяет наличие сессии
  6. /api/whoami/ получает пользовательские данные для аутентифицированного пользователя

Для представления возьмите полный код здесь и добавьте его в файл backend/api/views.py.

Добавьте файл urls.py в папку "backend/api" и определите следующие URL-адреса, специфичные для конкретного приложения:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Теперь зарегистрируем URL-адреса наших приложений в базовом проекте:

# backend/djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Изменим некоторые настройки безопасности в файле backend/djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = ['http://localhost:81']

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Примечания:

  1. Включение CSRF_COOKIE_SAMESITE и SESSION_COOKIE_SAMESITE предотвращает отправку cookies и CSRF-токенов из любых внешних запросов.
  2. Включение CSRF_COOKIE_HTTPONLY и SESSION_COOKIE_HTTPONLY блокирует доступ JavaScript на стороне клиента к CSRF и сессионным cookies.

Если вы работаете на производстве, то вам следует обслуживать ваш сайт по HTTPS и включить опции CSRF_COOKIE_SECURE и SESSION_COOKIE_SECURE, которые позволят отправлять cookies только по HTTPS.

Создайте файл backend/requirements.txt:

Django==4.2.3

Frontend

Перед началом работы над фронтендом убедитесь, что у вас установлены Node.js и npm (или Yarn).

Мы будем использовать Vite для создания нового проекта React:

$ npm create vite@4.4 frontend

Выберите React в качестве фреймворка с JavaScript в качестве варианта:

✔ Select a framework: › React
✔ Select a variant: › JavaScript

Затем установите зависимости и запустите сервер разработки:

$ cd frontend
$ npm install
$ npm run dev

Это запустит наше приложение на порт 5173. Посетите http://localhost:5173/, чтобы убедиться в его работоспособности.

Для упрощения работы удалите следующие CSS-файлы:

  1. frontend/src/App.css
  2. frontend/src/index.css

Далее добавим Bootstrap в frontend/index.html:

<!-- frontend/index.html -->

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
     <!-- new -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Возьмите полный код здесь для компонента App и добавьте его в файл frontend/src/App.jsx.

Это простое фронтенд-приложение с формой, которая обрабатывается с помощью React state. При загрузке страницы вызывается compontentDidMount(), который выполняет два вызова API:

  1. Сначала проверяется, аутентифицирован ли пользователь, вызовом /api/session/ и устанавливается isAuthenticated в значение true или false.
  2. Если пользователь не аутентифицирован, он получает CSRF-токен из /api/csrf/ и сохраняет его в состоянии.

Обратите внимание, что при каждом запросе мы использовали credentials: same-origin. Это необходимо, поскольку мы хотим, чтобы браузер передавал cookies при каждом HTTP-запросе, если URL имеет то же происхождение, что и вызывающий скрипт.

Обновление frontend/src/main.jsx:

// frontend/src/main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

Docker

Далее выполним Dockerize обоих приложений.

Backend

# backend/Dockerfile

# pull official base image
FROM python:3.11.4-slim-buster

# set working directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# add app
COPY . .

# start app
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Frontend

# frontend/Dockerfile

# pull official base image
FROM node:18-slim

# set working directory
WORKDIR /usr/src/app

# add `/usr/src/app/node_modules/.bin` to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH

# install and cache app dependencies
COPY package.json .
COPY package-lock.json .
RUN npm ci

# start app
CMD ["vite", "--host"]

Nginx

Для того чтобы запустить оба приложения на одном домене, добавим контейнер для Nginx, работающий в качестве обратного прокси. Создадим в корне проекта новую папку с именем "nginx".

# nginx/Dockerfile

FROM nginx:latest
COPY ./nginx.conf /etc/nginx/nginx.conf

Добавьте также конфигурационный файл nginx/nginx.conf. Код для него можно найти здесь.

Обратите внимание на следующие два блока location:

# nginx/nginx.conf

location /api {
  proxy_pass              http://backend;
  ...
}

location / {
  proxy_pass              http://frontend;
  ...
}

Запросы к / будут перенаправляться на http://frontend (frontend - имя сервиса из файла Docker Compose, который мы добавим в ближайшее время), а запросы к /api - на http://backend (backend - имя сервиса из файла Docker Compose).

Docker Compose

Создайте в корне проекта файл docker-compose.yml и добавьте в него следующее:

# docker-compose.yml

version: '3.8'

services:

  backend:
    build: ./backend
    volumes:
      - ./backend:/usr/src/app
    expose:
      - 8000

  frontend:
    stdin_open: true
    build: ./frontend
    volumes:
      - ./frontend:/usr/src/app
      - /usr/src/app/node_modules
    expose:
      - 5173
    environment:
      - NODE_ENV=development
    depends_on:
      - backend

  reverse_proxy:
    build: ./nginx
    ports:
      - 81:80
    depends_on:
      - backend
      - frontend

Теперь структура вашего проекта должна выглядеть следующим образом:

├── backend
│   ├── Dockerfile
│   ├── api
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── apps.py
│   │   ├── migrations
│   │   │   └── __init__.py
│   │   ├── models.py
│   │   ├── tests.py
│   │   ├── urls.py
│   │   └── views.py
│   ├── djangocookieauth
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── manage.py
│   └── requirements.txt
├── docker-compose.yml
├── frontend
│   ├── .eslintrc.cjs
│   ├── .gitignore
│   ├── Dockerfile
│   ├── README.md
│   ├── index.html
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   │   └── vite.svg
│   ├── src
│   │   ├── App.jsx
│   │   ├── assets
│   │   │   └── react.svg
│   │   └── main.jsx
│   └── vite.config.js
└── nginx
    ├── Dockerfile
    └── nginx.con

Запуск с помощью Docker

Сборка образов и разгон контейнеров:

$ docker-compose up -d --build

Если вы столкнулись с ошибкой 'Service frontend failed to build', возможно, отсутствует файл package-lock.json. Переместитесь в папку "frontend" и выполните npm install --package-lock для его генерации.

Запустите миграцию и создайте суперпользователя:

$ docker-compose exec backend python manage.py makemigrations
$ docker-compose exec backend python manage.py migrate
$ docker-compose exec backend python manage.py createsuperuser

Ваша заявка должна быть доступна по адресу: http://localhost:81. Протестируйте его, войдя в систему с только что созданным суперпользователем.

Полный код этого подхода можно получить на GitHub: django_react_same_origin.

Django DRF + Frontend обслуживаются отдельно (один и тот же домен)

Данный подход более-менее похож на предыдущий - "Frontend обслуживается отдельно (в одном домене)". Есть несколько незначительных отличий, которые перечислены ниже.

При использовании этого подхода необходимо установить djangorestframework с помощью pip или добавить его в requirements.txt (при сборке с помощью Docker). После установки необходимо зарегистрировать его в разделе INSTALLED_APPS внутри вашего settings.py.

# djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig',
    'rest_framework', # new
]

Для включения SessionAuthentication необходимо добавить в файл settings.py следующее:

# backend/djangocookieauth/settings.py

# Django REST framework
# https://www.django-rest-framework.org/api-guide/settings/

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
}

Также рекомендуется установить JSONRenderer в качестве DEFAULT_RENDERER_CLASSES, чтобы отключить DRF-навигацию и этот причудливый дисплей.

При создании представлений session и whoami используйте APIView, импортированные из rest_framework, и явно задайте authentication_classes и permission_classes:

# backend/api/views.py

from django.http import JsonResponse
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView


class SessionView(APIView):
    authentication_classes = [SessionAuthentication, BasicAuthentication]
    permission_classes = [IsAuthenticated]

    @staticmethod
    def get(request, format=None):
        return JsonResponse({'isAuthenticated': True})


class WhoAmIView(APIView):
    authentication_classes = [SessionAuthentication, BasicAuthentication]
    permission_classes = [IsAuthenticated]

    @staticmethod
    def get(request, format=None):
        return JsonResponse({'username': request.user.username})

А при регистрации URL-адресов зарегистрировать их следующим образом:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.SessionView.as_view(), name='api-session'),  # new
    path('whoami/', views.WhoAmIView.as_view(), name='api-whoami'),  # new
]

Полный код этого подхода можно получить на GitHub: django_react_drf_same_origin.

Фронтенд обслуживается отдельно (кросс-домен)

При таком подходе мы создадим фронтенд и будем обслуживать его отдельно от приложения Django на другом домене. Нам придется немного ослабить защиту, разрешив междоменные запросы из фронтенда с помощью django-cors-headers.

Начнем с создания каталога проекта:

$ mkdir django_react_cross_origin && cd django_react_cross_origin

Backend

Сначала создайте новый каталог "backend" для проекта Django:

$ mkdir backend && cd backend

Далее создаем и активируем новую виртуальную среду, устанавливаем Django и создаем новый проект Django:

$ python3.11 -m venv env
$ source env/bin/activate

(env)$ pip install django==4.2.3
(env)$ django-admin startproject djangocookieauth .

После этого создайте новое приложение с именем api:

(env)$ python manage.py startapp api

Зарегистрируйте приложение в backend/djangocookieauth/settings.py под INSTALLED_APPS:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Наше приложение будет иметь следующие конечные точки API:

  1. /api/get_csrf/ сгенерирует CSRF-токен и вернет его в виде JSON
  2. .
  3. /api/login/ позволит пользователю войти в систему, указав имя пользователя и пароль
  4. /api/logout/ выводит пользователя из системы
  5. /api/session/ проверяет наличие сессии
  6. /api/whoami/ получает пользовательские данные для аутентифицированного пользователя

Для представления возьмите полный код здесь и добавьте его в файл backend/api/views.py.

Добавьте файл urls.py в раздел "api" и определите следующие URL-адреса:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Теперь зарегистрируем URL-адреса наших приложений в базовом проекте:

# backend/djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Теперь нам необходимо ослабить несколько настроек безопасности, чтобы наши запросы прошли. Сначала установим настройки cookie в файле backend/djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = ['http://localhost:5173']

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Примечания:

  1. Установка CSRF_COOKIE_SAMESITE и SESSION_COOKIE_SAMESITE в значение Lax позволяет отправлять CSRF-куки во внешних запросах.
  2. Включение CSRF_COOKIE_HTTPONLY и SESSION_COOKIE_HTTPONLY блокирует доступ клиентского JavaScript к CSRF и сессионным кукам.

Если вы работаете на производстве, то вам следует обслуживать ваш сайт по HTTPS и включить опции CSRF_COOKIE_SECURE и SESSION_COOKIE_SECURE, которые позволят отправлять cookies только по HTTPS.

Для того чтобы разрешить кросс-оригинальное сохранение cookie, нам также необходимо изменить некоторые настройки CORS. Для этого мы будем использовать django-cors-headers. Начнем с его установки с помощью следующей команды:

(env)$ pip install django-cors-headers==4.2.0

Добавьте его к установленным приложениям и добавьте новый класс промежуточного ПО:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig',
    'corsheaders',  # new
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # new
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Настроить CORS:

# backend/djangocookieauth/settings.py

CORS_ALLOWED_ORIGINS = [
    'http://localhost:5173',
    'http://127.0.0.1:5173',
]
CORS_EXPOSE_HEADERS = ['Content-Type', 'X-CSRFToken']
CORS_ALLOW_CREDENTIALS = True

Примечания:

  1. В списке CORS_ALLOWED_ORIGINS мы задаем допустимое происхождение. Стоит отметить, что для целей тестирования вместо настройки CORS_ALLOWED_ORIGINS можно установить значение CORS_ALLOW_ALL_ORIGIN на True, чтобы разрешить любому origin'у делать запросы. Однако не используйте это в производстве.
  2. CORS_EXPOSE_HEADERS - список HTTP-заголовков, которые открываются браузеру.
  3. Установка значения CORS_ALLOW_CREDENTIALS в True позволяет отправлять cookies вместе с кросс-оригинальными запросами.

Код для нашего бэкенда теперь более или менее готов. Запустим команду migrate и создадим суперпользователя для тестирования в будущем:

(env)$ python manage.py migrate
(env)$ python manage.py createsuperuser

Frontend

Перед началом работы над фронтендом убедитесь, что у вас установлены Node.js и npm (или Yarn).

Мы будем использовать Vite для создания нового проекта React:

$ npm create vite@4.4 frontend

Выберите React в качестве фреймворка с JavaScript в качестве варианта:

✔ Select a framework: › React
✔ Select a variant: › JavaScript

Затем установите зависимости и запустите сервер разработки:

$ cd frontend
$ npm install
$ npm run dev

Это запустит наше приложение на порт 5173. Посетите http://localhost:5173/, чтобы убедиться в его работоспособности.

Для упрощения работы удалите следующие CSS-файлы:

  1. frontend/src/App.css
  2. frontend/src/index.css

Далее добавим Bootstrap в frontend/index.html:

<!-- frontend/index.html -->

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
     <!-- new -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Возьмите полный код здесь для компонента App и добавьте его в файл frontend/src/App.js.

Это простое фронтенд-приложение с формой, которая обрабатывается с помощью React state. При загрузке страницы вызывается compontentDidMount(), который выполняет два вызова API:

  1. Сначала проверяется, аутентифицирован ли пользователь, вызовом /api/session/ и устанавливается isAuthenticated в значение true или false.
  2. Если пользователь не аутентифицирован, он получает CSRF-токен из /api/csrf/ и сохраняет его в состоянии.

Обратите внимание, что при каждом запросе мы использовали credentials: include. Это необходимо, так как мы хотим, чтобы браузер передавал cookies при каждом HTTP-запросе, даже если URL не совпадает по происхождению с вызывающим скриптом. Следует помнить, что для этого мы изменили некоторые настройки CORS в бэкенде.

Обновление frontend/src/main.jsx:

// frontend/src/main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

Запуск приложения

Переместитесь в папку "backend" и запустите Django, используя:

(env)$ python manage.py runserver

Ваш бэкэнд должен быть доступен по адресу: http://localhost:8000.

Откройте новое окно терминала, перейдите в папку "frontend" и запустите React с помощью npm:

$ npm run dev

Вы должны иметь возможность получить доступ к своему приложению по адресу http://localhost:5173.

Протестируйте свое приложение, войдя в систему с созданным ранее суперпользователем.

Полный код этого подхода можно получить на GitHub: django_react_same_origin

Заключение

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

Мы рассмотрели четыре различных подхода к объединению Django с фронтенд-фреймворком для работы с сессионными аутентификаторами:

Approach Frontend Backend
Frontend served from Django Grab the CSRF token using universal-cookies and use credentials: "same-origin" in the requests. Set CSRF_COOKIE_SAMESITESESSION_COOKIE_SAMESITE to "Strict". Enable SESSION_COOKIE_HTTPONLY and disable CSRF_COOKIE_HTTPONLY.
Frontend served separately (same domain) Obtain CSRF token and use credentials: "same-origin" in the fetch request. Add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLYCSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITECSRF_COOKIE_SAMESITE to "Strict".
Frontend served separately with DRF (same domain) Obtain CSRF token and use credentials: "same-origin" in the fetch request. Add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLYCSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITECSRF_COOKIE_SAMESITE to "Strict".
Frontend served separately (cross-origin) Obtain CSRF token and use credentials: "include" in the fetch request. Enable CORS and add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLYCSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITECSRF_COOKIE_SAMESITE to "Lax". Add the django-cors-headers package and configure the CORS_ALLOWED_ORIGINSCORS_EXPOSE_HEADERS, and CORS_ALLOW_CREDENTIALS settings.

Возьмите код из репозитория django-spa-cookie-auth.

Вернуться на верх