Разделение внешних и внутренних систем стало популярным архитектурным решением. Такой подход не только повышает масштабируемость, но и способствует четкому разделению обязанностей, делая проекты более управляемыми и эффективными. Однако создание рабочей среды разработки может оказаться сложной задачей, особенно при настройке аутентификации. Цель этой статьи — прояснить процесс интеграции интерфейса Quasar/VueJS с серверной частью Django, уделяя особое внимание настройке бесшовной системы аутентификации на основе сеанса.

Если вы хотите пропустить эту статью и просто получить стартовый проект, вы можете найти его здесь: https://github.com/bklik/quasar-django-starter.

Настройка среды

Начнем с настройки среды для нашего проекта. Нам понадобится папка проекта, виртуальная среда Python, проект внешнего интерфейса Quasar и внутренний проект Django.

# Create an navigate to our project folder
mkdir vue-django-session
cd vue-django-session

# Create a python virtual environment and source it
python -m venv .venv
source .venv/bin/activate
# Windows: .venv/Scripts/activate

# Install Django and setup a backend project
pip install django
django-admin startproject backend

# Set up a quasar fontend project
# - Quasar CLI
# - Project folder: frontend
# - Vue 3
# - Typescript
# - Webpack
# - <script setup>
# - SCSS
# - ESLint/Pinia/Axios
# - Prettier
npm init quasar

Создание серверной части

Наша среда разработки Django по умолчанию работает на порту 8000, но наша среда разработки Quasar/VueJS по умолчанию работает на порту 8080. Это приведет к ошибкам совместного использования ресурсов между источниками (CORS), поскольку наш интерфейс пытается взаимодействовать с нашим сервером. Нам нужно изменить некоторые настройки, чтобы это заработало.

Сначала нам понадобятся еще несколько требований к проекту.

# Django REST Framework for delivering our API
pip install djangorestframework

# CORS headers for allowing us to accept requests from other ports
pip install django-cors-headers

cd backend/
python manage.py migrate
python manage.py createsuperuser

Примените следующие изменения настроек.

# backend/backend/settings.py

import os

...

INSTALLED_APPS = [
    ...
    # Requirements
    'djangorestframework',
    'django-cors-headers'
]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    ...
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# This will remove these settings when served by Apache during production
if not os.environ.get('WSGI_APPLICATION'):
    # Allow all origins (not recommended for production)
    CORS_ALLOW_CREDENTIALS = True

    # Or allow specific origins
    CORS_ALLOWED_ORIGINS = [
        "http://localhost:8080",
    ]
    CSRF_TRUSTED_ORIGINS = [
        "http://localhost:8080",
    ]

# Name of token in header
CSRF_COOKIE_NAME = "csrftoken"

# 20 minutes in seconds
SESSION_COOKIE_AGE = 1200

# Resets the cookie are after each request
SESSION_SAVE_EVERY_REQUEST = True

...

Создайте приложение для управления сеансами

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

# from backend/
python manage.py startapp app_auth

Примените следующие изменения файла.

# app_auth/admin.py

from django.contrib import admin
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from django.urls import reverse
from django.utils.html import format_html


@admin.register(Session)
class SessionAdmin(admin.ModelAdmin):
    # Ceates an easy way to view/expire current sessions
    list_display = ('session_key', 'username', 'expire_date', 'expire_session')

    def username(self, obj):
        session_data = obj.get_decoded()
        user_id = session_data.get('_auth_user_id')

        if user_id:
            user = User.objects.get(id=user_id)
            return user.username
        return None

    def expire_session(self, obj):
        return format_html(
            '<a href="{}" class="button">Expire Session</a>',
            reverse('expire_session', args=[obj.pk])
        )
# app_auth/serializers.py

from django.contrib.auth.models import User
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import Serializer


class UserSerializer(serializers.ModelSerializer):
    permissions = serializers.SerializerMethodField()

    def get_permissions(self, obj):
        return list(obj.get_all_permissions())

    class Meta:
        model = User
        fields = [
            'id',
            'username',
            'first_name',
            'last_name',
            'permissions'
        ]


class LoginSerializer(Serializer):
    username = serializers.CharField()
    password = serializers.CharField(write_only=True)

    def validate(self, data):
        user = User.objects.filter(username=data['username']).first()
        if user and user.check_password(data['password']):
            return user
        raise ValidationError({'error': 'Invalid username or password.'})
# app_auth/views.py

from django.contrib.auth import login
from django.contrib.sessions.models import Session
from django.http import HttpResponseRedirect
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView

from .serializers import LoginSerializer, UserSerializer


class AuthCheck(APIView):
    permission_classes = []

    def get(self, request):
        if request.user.is_authenticated:
            user = UserSerializer(request.user, context={'request': request})
            return Response({"isAuthenticated": True, "user": user.data})
        else:
            return Response({"isAuthenticated": False})


class LoginView(APIView):
    authentication_classes = []
    permission_classes = (permissions.AllowAny,)

    def post(self, request):
        serializer = LoginSerializer(data=request.data)

        if serializer.is_valid():
            user = serializer.validated_data
            login(request, user)
            return Response({"detail": "Login successful."}, status=status.HTTP_200_OK)

        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


def expire_session_view(request, session_key):
    try:
        session = Session.objects.get(session_key=session_key)
        session.delete()
    except Session.DoesNotExist:
        pass
    return HttpResponseRedirect('/admin/sessions/session/')
# app_auth/urls.py

from django.urls import path
from .views import AuthCheck, LoginView, expire_session_view

urlpatterns = [
    path('auth-check', AuthCheck.as_view(), name='auth_check'),
    path('login/', LoginView.as_view(), name='login'),
    path('expire-session/<str:session_key>/',
         expire_session_view, name='expire_session'),
]
# backend/urls.py

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

urlpatterns = [
    path('app_auth/', include('app_auth.urls')),
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
    path('admin/', admin.site.urls),
]
# bachend/settings.py

...

INSTALLED_APPS = [
    ...
    # Apps
    'app_auth'
]

Если мы проверим наш прогресс с помощью `python Manage.py runserver`, мы сможем получить доступ к администратору Django по адресу: http://127.0.0.1:8000/admin.

Мы можем войти в систему, используя учетные данные суперпользователя, и просмотреть наш сеанс, нажав «Администратор сеансов». Находясь в системе администратора, создайте нового пользователя, чтобы протестировать представление проверки подлинности.

Мы можем использовать localhost в нашем URL-адресе для входа в систему под другим пользователем: http://localhost:8000/app_auth/auth-check.

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

Если мы вернемся на вкладку администрирования Django и обновим сеансы, теперь мы должны увидеть сеанс нашего тестового пользователя. Мы можем завершить этот сеанс, в результате чего наш тестовый пользователь выйдет из системы.

Создание внешнего интерфейса

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

Сначала мы настроим аксиомы. Нам нужны axios, чтобы знать, какой URL-адрес использовать, что он должен включать учетные данные и передавать файл cookie csfrtoken в заголовке при отправке/размещении запросов.

// src/boot/axios.ts

...

const api = axios.create({
    baseURL: 'http://localhost:8000',
    withCredentials: true,
});

// Helper function to get CSRF token from cookie
function getCookie(name: string): string | null {
    const value = '; ' + document.cookie;
    const parts = value.split('; ' + name + '=');
    if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
    return null;
}

export default boot(({ app }) => {
    ...

    // Set the X-CSRFToken header for all POST/PUT requests
    api.interceptors.request.use((config) => {
        if (
            config.method?.toLowerCase() === 'post' ||
            config.method?.toLowerCase() === 'put'
        ) {
            const csrfToken = getCookie('csrftoken');
            if (csrfToken) {
                config.headers['X-CSRFToken'] = csrfToken;
            }
        }
        return config;
    });
});

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

// src/stores/useAppAuthStore.ts

import { defineStore } from "pinia";
import { api } from "src/boot/axios";

type LoginFormType = {
  username: string;
  password: string;
};

type UserType = {
  id: number;
  username: string;
  first_name: string;
  last_name: string;
  permissions: Array<string>;
};

type AuthCheckType = {
  isAuthenticated: boolean;
  user?: UserType;
};

export const useAppAuthStore = defineStore("app_auth", {
  state: () => ({
    loggedOut: true,
    error: "" as string,
    user: {} as UserType,
    loading: false,
    authInterval: null as ReturnType<typeof setInterval> | null,
  }),
  getters: {},
  actions: {
    async login(credentials: LoginFormType) {
      this.loading = true;
      await api
        .post("/app_auth/login/", credentials)
        .then(() => {
          this.authCheck();
        })
        .catch((error) => {
          console.error(error.response.data);
          this.error = error.response.data.error;
          this.loggedOut = true;
          this.user = {} as UserType;
          this.loading = false;
        });
    },
    async authCheck() {
      this.loading = true;
      await api
        .get("/app_auth/auth-check")
        .then((response) => {
          const data = response.data as AuthCheckType;
          if (data.isAuthenticated && data.user) {
            this.user = data.user;
            this.error = "";
            this.loggedOut = false;
            this.loading = false;

            if (!this.authInterval) {
              this.authInterval = setInterval(() => {
                this.authCheck();
              }, 1000 * 60 * 21); // 21 minutes in miliseconds
            }
          } else {
            this.error = "";
            this.loggedOut = true;
            this.loading = false;
            this.authInterval = null;
          }
        })
        .catch((error) => {
          console.error(error.response.data);
          this.error = error;
          this.loggedOut = true;
          this.loading = false;
        });
    },
    async logout() {
      this.loading = true;
      await api
        .get("/api-auth/logout/")
        .then(() => {
          this.authCheck();
        })
        .catch((error) => {
          console.error(error.response.data);
          this.error = error.response.data.error;
          this.loading = false;
        });
    },
  },
});

Создайте компонент, предоставляющий пользователю форму для входа в систему.

<!-- src/components/app_auth/LoginForm.vue -->

<template>
  <q-dialog v-model="appAuthStore.loggedOut" persistent>
    <q-card class="login-form">
      <q-form @submit="submitLogin()">
        <q-card-section>
          <div class="text-h4">Sign in</div>
        </q-card-section>
        <q-card-section class="column q-gutter-md">
          <q-input
            type="text"
            label="Username"
            required
            autofocus
            v-model="loginModel.username"
            :rules="[
              (val) =>
                (val && val.length > 0) || 'Required field.',
            ]"
          ></q-input>
          <q-input
            type="password"
            label="Password"
            required
            v-model="loginModel.password"
            :rules="[
              (val) =>
                (val && val.length > 0) || 'Required field.',
            ]"
          ></q-input>
          <div v-if="appAuthStore.error" class="text-negative">
            <q-icon :name="mdiAlertCircle" size="sm"></q-icon>
            {{ appAuthStore.error }}
          </div>
        </q-card-section>
        <q-card-actions align="right" class="text-primary">
          <q-btn
            type="submit"
            color="primary"
            :loading="appAuthStore.loading"
            label="Sign in"
            :icon="mdiLoginVariant"
          ></q-btn>
        </q-card-actions>
      </q-form>
    </q-card>
  </q-dialog>
</template>

<style lang="scss">
  .login-form {
    width: 100%;

    & input:required + .q-field__label::after {
      color: $negative;
      content: " *";
    }
  }
</style>

<script lang="ts" setup>
  import { reactive } from "vue";
  import { mdiAlertCircle, mdiLoginVariant } from "@quasar/extras/mdi-v6";
  import { useAppAuthStore } from "src/stores/useAppAuthStore";

  type LoginFormType = {
    username: string;
    password: string;
  };

  const defaultModel = {
    username: "",
    password: "",
  };

  const appAuthStore = useAppAuthStore();
  const loginModel = reactive<LoginFormType>({ ...defaultModel });

  function submitLogin() {
    appAuthStore.login(loginModel).then(() => {
      if (appAuthStore.error === "") {
        Object.assign(loginModel, { ...defaultModel });
      }
    });
  }
</script>

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

<!-- src/components/ExampleComponent.vue -->

<template>
  <pre>{{ appAuthStore.user }}</pre>
  <q-btn
    v-if="!appAuthStore.loggedOut"
    label="Logout"
    color="primary"
    @click="appAuthStore.logout()"
  ></q-btn>
</template>

<script setup lang="ts">
  import { useAppAuthStore } from "src/stores/useAppAuthStore";

  const appAuthStore = useAppAuthStore();
</script>

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

<!-- src/pages/IndexPage.vue -->

<template>
  <q-page class="row items-center justify-evenly">
    <example-component></example-component>
  </q-page>
</template>

<script setup lang="ts">
  import ExampleComponent from "components/ExampleComponent.vue";
</script>

Включите компонент LoginForm в MainLayout, чтобы он отображался, когда пользователь не прошел аутентификацию.

<!-- src/layouts/MainLayout.vue -->

<template>
    ...

    <LoginForm></LoginForm>
</template>

<script setup lang="ts">
    ...

    import LoginForm from 'src/components/app_auth/LoginForm.vue';
</script>

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

// src/router/index.ts

export default route(function (/* { store, ssrContext } */) {
    ...

    // Before each rount, check if the user is logged in
    Router.beforeEach(() => {
        appAuthStore.authCheck();
    });
});

Запустить среду разработчика

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

# Terminal 1
python manage.py runserver
# Terminal 2
quasar dev

Ваше приложение должно выглядеть примерно так.

Это все, что вам нужно для начала работы с аутентифицированной средой. Надеюсь, это было полезно.