Всем здравия! Сегодня будет рассмотрена авторизация с помощью сессий между Django и React, находящихся на разных доменах, т.е случай "cross-origin". Я в двух словах донесу принцип работы, причины появления концепций и технологий, описанных здесь, оставлю ссылки на более подробные источники и приведу код конкретной реализации с объяснением своих шагов. Кого интересует полный код - он находится на GitHub.

Авторизация с помощью сессий

Как это происходит:

  • Пользователь вводит данные, отправляет на сервер;

  • Сервер их валидирует, создаёт сессию, генерирует cookie и отсылает пользователю session_id. Сервер устанавливает такой заголовок Set-Cookie: session_id=id

  • Когда клиент получает ответ, в его cookie при помощи заголовка Set-Cookie автоматически устанавливается ID сессии

  • Далее при каждом запросе к серверу, клиент будет отправлять все свои cookies, в котором есть session_id. Сервер проверяет есть ли эта сессия, жива ли она и даёт доступ к каким-то данным

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

Безопасность такого подхода

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

Если кратко об CSRF-уязвимости: на сайте злоумышленника вас каким-то образом "редиректят" на тот домен, где вы, предположительно, можете быть авторизованы и где у вас есть те самые cookie. Поскольку cookie отправляются автоматически на домен, то запрос пройдёт как авторизованный. Т.е злоумышленник сможет действовать от вашего лица.

Чтобы этого не происходило существует CSRF-токен, который является одноразовым. При опасных операциях, где обязательно нужно убедиться, что пользователь тот за кого себя выдаёт (POST, DELETE и т.п), ему сначала отправляется CSRF-токен, который он отправит в cookie вместе со своим опасным запросом. На сайте злоумышленника бы такое не прокатило, ему бы никто попросту не отправил этот токен. Это лишь один из способов защиты.

Настройка Django

Создайте проект

python -m venv venv
venv\Scripts\activate
pip install Django
django-admin startproject core .
python manage.py startapp app
python manage.py migrate
python manage.py createsuperuser
pip install django-cors-headers
python manage.py runserver

Заметьте, что мы установили django-cors-headers это необходимо для совместного использования ресурсов между разными источниками, т.к Django и React будут разными источниками, на разных портах. Подробнее ознакомиться с CORS, вы можете в этой статье.

settings.py
Установите приложение и corsheaders в INSTALLED_APPS

INSTALLED_APPS = [
  ...
  'app',
  'corsheaders',
]

Установите в MIDDLEWARE 'corsheaders.middleware.CorsMiddleware':

MIDDLEWARE = [
  'corsheaders.middleware.CorsMiddleware', 
  ... 
]

Далее будут настройки директив для заголовка Set-Cookie, полный перечень настроек можно увидеть здесь.

# Поскольку Django и React - разные источники, ставим Lax
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SAMESITE = 'Lax'

# Чтобы cookie не были доступны из JS, нужен атрибут HttpOnly
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True

# Домены, которым мы доверяем
CSRF_TRUSTED_ORIGINS = ['http://localhost:3000']

# Когда приложение заимеет production окружение и https соединение
#CSRF_COOKIE_SECURE = True
#SESSION_COOKIE_SECURE = True

# Время жизни сессии в секундах. По умолчанию 2 недели = 1209600
#SESSION_COOKIE_AGE = 120 <- для примера 120 секунд

# Чтобы убивать сессию при закрытии браузера
#SESSION_EXPIRE_AT_BROWSER_CLOSE = True

# Разрешаем межсайтовые запросы для домена, на котором находится React приложение
CORS_ALLOWED_ORIGINS = [
  'http://localhost:3000',
  'http://127.0.0.1:3000', 
]

# Разрешаем заголовки для межсайтовых запросов
CORS_EXPOSE_HEADERS = ['Content-Type', 'X-CSRFToken']

# Разрешаем отправлять cookie при межсайтовых запросах на разрешённые домены:
CORS_ALLOW_CREDENTIALS = True

core/urls.py:

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

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

app/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('user_info/', views.user_info, name='api-userInfo'),
   path('kill_all_sessions/', views.kill_all_sessions, name='kill-all-sessions'),
]

app/views.py

import json
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
from django.contrib.sessions.models import Session

# Декоратор для выдачи ошибки если пользователь неавторизован
def json_login_required(view_func):
    def wrapped_view(request, *args, **kwargs):
        if not request.user.is_authenticated:
            return JsonResponse({'error': 'Вы не авторизованы'}, status=401)
        return view_func(request, *args, **kwargs)
    return wrapped_view

Был написан самодельный декоратор, потому что встроенный декоратор @login_required делал редирект в случае если юзер не авторизован, мне же нужно было кидать на клиент ошибку. Возможно это можно было сделать как-то более по-умному и не делать велосипед, буду рад поправкам. Декоратор используется в представлениях, которые не должны отвечать неавторизованным пользователям (профиль, корзина и т.п)

# Создаёт уникальный CSRF-токен и вставляет в cookie браузеру
def get_csrf(request):
    response = JsonResponse({'detail': 'CSRF cookie set'})
    response['X-CSRFToken'] = get_token(request)
    return response

При "опасных" действиях, которые могут подделаться злоумышленником, к примеру авторизация. Клиент должен запросить этот CSRF-токен

@require_POST
def login_view(request):
    # Получаем авторизационные данные
    data = json.loads(request.body)
    username = data.get('username')
    password = data.get('password')

    # Валидация
    if username is None or password is None:
        return JsonResponse({'detail': 'Пожалуйста предоставьте логин и пароль'}, status=400)

    # Аутентификация пользоваля
    user = authenticate(username=username, password=password)
    
    if user is None:
        return JsonResponse({'detail': 'Неверные данные'}, status=400)

    # Создаётся сессия. session_id отправляется в куки
    login(request, user)
    return JsonResponse({'detail': 'Успешная авторизация'})

  
# Сессия удаляется из БД и session_id на клиенте более недействителен
@json_login_required
def logout_view(request):
    logout(request)
    return JsonResponse({'detail': 'Вы успешно вышли'})

  
# Узнать авторизован ли пользователь и получить его данные
@ensure_csrf_cookie # <- Принудительная отправка CSRF cookie
def session_view(request):
    if not request.user.is_authenticated:
        return JsonResponse({'isAuthenticated': False})

    return JsonResponse({'isAuthenticated': True, 'username': request.user.username, 'user_id': request.user.id})

  
# Получение информации о пользователе
@json_login_required
def user_info(request):
    return JsonResponse({'username': request.user.username})

  
# Удаление всех сессий из БД
# Вы можете переделать так, чтобы отзывать сессию у определённого пользователя
@json_login_required
def kill_all_sessions(request):
    sessions = Session.objects.all()
    sessions.delete()

    return JsonResponse({'detail': 'Сессии успешно завершены'})

Настройка React

Создайте приложение через CRA. Далее npm install axios

import React, { useState, useEffect } from "react";
import axios from 'axios';

import style from './index.module.scss';


// Важно заметить, что если написать http://127.0.0.1:8000 cookie не будут устанавливаться
const serverUrl = 'http://localhost:8000/'

const Form = () => {
    const [isCsrf, setIsCsrf] = useState(null)
    const [isLogin, setIsLogin] = useState('')
    const [isPassword, setIsPassword] = useState('')
    const [isError, setIsError] = useState(null)
    const [isAuth, setIsAuth] = useState(false)
    const [username, setUsername] = useState('')
    const [userId, setUserId] = useState(null)

    // При первой загрузке страницы мы спрашиваем, авторизован ли пользователь
    useEffect(() => {
        getSession()
    }, [])

    const isResponseOk = (res) => {
      if (!(res.status >= 200 && res.status <= 299)) {
        throw Error(res.statusText);
      }
    }

    // Если необходимо авторизоваться, запрашиваем CSRF-токен у сервера
    const getCSRF = () => {
        axios.get(serverUrl + 'api/csrf/', { withCredentials: true })
        .then((res) => {
            isResponseOk(res)

            const csrfToken = res.headers.get('X-CSRFToken')
            setIsCsrf(csrfToken)
        })
        .catch((err) => console.error(err))
    }

    // withCredentials:true - аналогия директивы credentials='include'
    const getSession = () => {
      axios.get(serverUrl + "api/session/", { withCredentials: true })
      .then((res) => {
          if (res.data.isAuthenticated) {
              setUserId(res.data.user_id)
              setUsername(res.data.username)
              setIsAuth(true)
              return
          }

          setIsAuth(false)
          getCSRF()
      })
      .catch(err => console.error(err))
    }

    // Полученный CSRF-токен пихаем в заголовок и отправляем серверу
    const login = () => {
      const data = { username: isLogin, password: isPassword }
      axios.post(serverUrl + "api/login/", data, {
        withCredentials: true,
        headers: {
          "Content-Type": "application/json",
          "X-CSRFToken": isCsrf,
        }
      })
      .then((res) => {
        isResponseOk(res)
        setIsAuth(true)
        setIsLogin('')
        setIsPassword('')
        setIsError(null)
        
        userInfo()
      })
      .catch((err) => {
        console.error(err);
        setIsError("Неверные данные")
      });
    }

    const logout = () => {
      axios.get(serverUrl + "api/logout", { withCredentials: true })
      .then((res) => {
        isResponseOk(res)
        setIsAuth(false);
        getCSRF();
      })
      .catch(err => console.error(err));
    }

    const userInfo = () => {
      axios.get(serverUrl + "api/user_info/", {
        withCredentials: true,
        headers: {
          "Content-Type": "application/json",
        },
      })
      .then((res) => {
        console.log("Вы авторизованы как: " + res.data.username);
        setUsername(res.data.username)
      })
      .catch((err) => {
          if (err.status === 401) console.log(err.error);
      });
    }

    const killAllSessions = () => {
      axios.get(serverUrl + "api/kill_all_sessions/", {
        withCredentials: true,
        headers: {
          "Content-Type": "application/json",
        },
      })
      .then((res) => {
        isResponseOk(res)
        console.log(res.data.detail)
      })
      .catch((err) => {
        console.log(err);
      });
    }

    function changePassword(e) {
        setIsPassword(e.target.value)
    }

    function changeLogin(e) {
        setIsLogin(e.target.value)
    }

    function submitForm(e) {
        e.preventDefault()
        login()
    }

    return(
      <div className={style.container}>
          <div className={style.authStatus}>
            Вы - 
            <span className={style.username}>
            {
              isAuth ? ' ' + username   : ' неавторизованы' 
            }
            </span>
          </div>

          {
            !isAuth ?
              <form className={style.formContainer}>
                  <label htmlFor="login">Логин</label>
                  <input 
                      type="text" 
                      name="login" 
                      id="login" 
                      className={style.field}
                      onChange={changeLogin}
                      value={isLogin}
                  />

                  <label htmlFor="password">Пароль</label>
                  <input 
                      type="password" 
                      name="password" 
                      id="password" 
                      className={style.field} 
                      onChange={changePassword}
                      value={isPassword}
                  />

                  {
                      isError ? <div className={style.error}>{isError}</div> : null
                  }

                  <input type="submit" value='Войти' onClick={submitForm} className={style.sendBtn} />
              </form>
            :

              <div className={style.btnContainer}>
                  <input type="submit" value='Выйти' onClick={logout} className={style.logoutBtn} />
                  <input type="submit" value='Убить все сессии' onClick={killAllSessions} className={style.killAllSessionsBtn} />
              </div>
                  
          }
      </div>
    )
}

export default Form;

В принципе, React полностью зеркалит Django. Сначала убеждаемся, что мы авторизованы, если нет, то просим CSRF-токен, который будет использоваться в заголовке для отправки формы авторизации на сервер. Когда нужно получить информацию, где необходимо быть авторизованным, мы используем withCredentials:true, что достаёт наш session_id и CSRF-токен, отправляя на сервер, где он, в свою очередь валидирует session_id по записи в модели Session.

Инструменты разработчика

Когда вы только заходите на страницу в DevTools во вкладке Network посылается два запроса: session и csrf, если бы вы изначально были авторизованы, то послался бы лишь session. Введите логин и пароль суперпользователя Django, если всё верно, то появятся ещё два запроса login и user_info, а во вкладке applications -> cookies появится sessionid.

Заключение

Авторизация на основе сессий имеет свои плюсы и минусы, раньше многие сетовали на её небезопасность, проблему с обработкой cookies в разных браузерах. Однако сейчас эти проблемы перестали стоять так остро и авторизация на основе сессий, по-моему мнению, является наиболее перспективной

Исходный код

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