Skip to content

Instantly share code, notes, and snippets.

@gbezyuk
Last active August 21, 2023 13:04
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gbezyuk/10dc40ec146f61bbb9c6931752f651ad to your computer and use it in GitHub Desktop.
Save gbezyuk/10dc40ec146f61bbb9c6931752f651ad to your computer and use it in GitHub Desktop.
ЛикБез по Аутентификации, Авторизации и JWT (Django4, Nuxt2, RU)

ЛикБез по Аутентификации, Авторизации и JWT

Версия на русском языке с использованием Django4 и Nuxt2.

Эта дока покрывает JWT auth workflow, но не затрагивает вопросы кастомных юзеров и кастомных методов аутентификации Django. На их счёт — по первому вопросу https://testdriven.io/blog/django-custom-user-model/, а по второму — https://docs.djangoproject.com/en/4.1/topics/auth/customizing/.

Вводная

Об Аутентификации и Авторизации

  1. Есть Аутентификация, и есть Авторизация. Их часто путают и сливают в единое понятие, и с этим приходится жить. Но по идее это два ортогональных явления.
  2. Аутентификация — это удостоверение личности обращающегося. Паспорт предъявить, например. При этом может статься, что удостоверение личности никаких особых прав и возможностей тебе не даёт — просто теперь к тебе обратятся по имени. Часто даже наоборот, потеря анонимности — потенциальный риск, в случае чего тебе предъявят за хулиганство.
  3. Авторизация — это выяснение, имеет ли обратившийся достаточно прав для выполнения некоторого действия. При этом личность обращающегося может оставаться неизвестной — можно просто красивой ксивой, или даже стволом как символом власти моргнуть — и все всё поняли и всё принесли, и имени не спрашивают.

О токенах

Токен, он же жетон — это некая штука-дрюка, которая предъявляется с целью аутентификации и/или авторизации.

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

Из них, чисто аутентификационные: солдатские.

Чисто авторизационные: деньги, жетоны метро, ключи домофонов.

Смешанные: полицейские значки, браслеты фитнес-центров, JWT-токены.

Однократного использования: деньги, жетоны метро; некоторые JWT-токены.

Многократного использования: всё остальное.

О хешах, шифровании и криптографическом подписывании

Есть такой класс математических функций — (криптографические) хеш-функции. На вход им скармливается примерно всё, что угодно и некий секретный ключ, а на выход от них получается некое число — и оно выглядит совершенно случайным, никак не связанным ни с секретом, ни с входом. Алгоритмов много разных, но они стандартизированы, и есть небольшое число именованных в широком использовании.

Если говорить конкретно о подписывании, то процесс таков:

  1. Выпускающая сторона использует свой секретный ключ, и вычисляет с его помощью хэш от некоторой информации. Информация и хэш (подпись) отдаются куда-то наружу.
  2. Снаружи когда-то потом ранее выпустившим прилетает пара информация + подпись, с вопросом "это ваша подпись, удостоверяете?" — с тем же секретом хэш вычисляется заново, и если результаты совпали, то ответ "да, удостоверяем", иначе — "нет, мы такого не подписывали, вас пытаются обмануть от нашего имени". (В частном случае простой аутентификации/авторизации, обмануть пытаются саму выпускающую сторону.)

Кстати, SECRET_KEY в settings.py в проектах на Django — это как раз тот самый секрет для крипто-подписей.

О JSON

Есть язык JavaScript, и у него есть родной для него синтаксис описания базовых структур данных: примитивов, списков, словарей. Если забыть про всё остальное в JavaScript, и оставить только эту декларативную часть, да к тому же потерять некоторое количество синтаксического сахара по пути, то получится формат обмена данными — JSON. Это просто человекочитаемый текст, последовательность байтов без хитрых непечатных спецсимволов. Удобно, модно-молодёжно, популярно. Куда лучше, чем XML, но менее лаконично, чем YAML — а в совсем сухом остатке просто string.

О Base64 и прочих BaseN

В цифровых ЭВМ пифагоровская религиозная догма "всё есть число" верна в строго техническом смысле: под капотом в памяти нет ничего, кроме последовательности единичек и ноликов. Как эти единицы и нолики сгруппировать, как записать и где указать границы между числами в рамках одного большого числа — способов всегда уйма.

В частности, можно использовать позиционные системы счисления с разными основаниями: можно двоичку, можно восьмиричную, можно шестнадцатеричную... А можно любую другую — baseN. Вот ежели в используемой позиционной системе счисления 64 цифры, то имеем дело с Base64. В качестве таких цифр применяются символы латинского алфавита обоих регистров, собственно арабские цифры, и пара доплнительных символов — + и /; дополнительно в качестве пробела применяют = (см. https://en.wikipedia.org/wiki/Base64).

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

Кодировать так можно что угодно, любые бинарные, хоть HD порно — вес, конечно, вырастет в разы по сравнению с обычным бинарным файлом; зато можно вставить напрямую в HTML, например:

<img src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4#8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot" />

где iVBO...kJggg== — это как раз бинарник png, закодированный в base64.

О JWT

(https://jwt.io/ — тут песочница и разъяснения на английском)

Коротко, JWT — это токен (см. выше), представляющий собой строку текста из трёх сегментов, разделённых точками.

Каждый из сегментов закодирован в base64.

Если их раскодировать, первые два в норме окажутся вполне читабельным и осмысленным JSON-ом: первый с метаинформацией о токене, второй — с полезной нагрузкой.

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

Базовая схема JWT-аутентификации/авторизации

  1. С фронтенда на бэкэнд прилетает логин-пароль или иной повод аутентифицировать/авторизовать пользователя.
  2. Если бэкэнд насчёт этого повода доволен (такой юзер в базе есть и активен, например), то он создаёт JWT-токен из трёх частей: метаинформация (токен такого-то типа, выпущен тогда-то, протухнет тогда-то), полезная нагрузка (выдан юзеру с таким-то именем и прочими деталями (аутентификация) и правами доступа и групповыми членствами такими-то (авторизация)), подпись (вышеизложенное верно, в случае сомнения обращайтесь ко мне, бэкэнду — проверю и подтвержу). Часто кроме этого, основного и быстро протухающего токена, одновременно выписывается дополнительный — более долгоживущий, позволяющий перевыпустить основной токен без ввода логина-пароля заново — refresh-токен.
  3. Токен сохраняется на фронтенде и далее используется для последующих запросов. По классике, он передаётся только в заголовке HTTP. На практике, в приложениях с серверным рендерингом, удобно его ещё и в cookies продублировать.
  4. Когда токен протухает, о чём свидетельствует "внезапная" ошибка HTTP 401, полезно попытаться его обновить по refresh'у, в централизованном обработчике ошибки. Если удастся, то исходный запрос можно повторить, и пользователь фазы обновления токена и не заметит даже. Если нет, то надо сделать полноценный логаут чисто на всякий случай, чтобы фронтенд не повис в каком-то невнятном промежуточном состоянии, чреватом трудноуловимыми багами.

Как это всё реализовать на Django и Nuxt

(Django 4.0.3, Nuxt 2.15; но общий смысл не должен отличаться и в других версиях)

Django Backend

Ставим django-rest-framework, django-rest-framework-simplejwt и django-cors-headers.

В settings.py должно быть примерно такое:

INSTALLED_APPS = [
    # ...
    'corsheaders',
    'rest_framework',
    'rest_framework_simplejwt',
    # ...
]

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

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',

        # для отладочного HTML-интерфейса, в production — выключить
        'rest_framework.authentication.SessionAuthentication',
    ),
}

# чтобы на все URL /api/* можно было из браузера ломиться с любого домена
CORS_URLS_REGEX = r"^/api/.*$"
CORS_ALLOW_ALL_ORIGINS = True

SIMPLE_JWT = {
    # чтобы закинуть свою начинку в полезную нагрузку токена
    "TOKEN_OBTAIN_SERIALIZER": "some.where.TokenSerializer",
    # чтобы по refresh выдавался ещё и новый refresh токен, что упростит юзеру жизнь
    "ROTATE_REFRESH_TOKENS": True,
    # (в хардкорных окружениях так лучше не делать)
}

Где-то в отдельном файле (some/where.py) должен жить кастомный сериализатор, начиняющий токен по нашему вкусу (если ничего кастомного не надо, это можно опустить, конечно):

from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from motoschool.models import Student # для примера


class TokenSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)

        # Add custom claims
        token['username'] = user.username
        token['email'] = user.email
        token['first_name'] = user.first_name
        token['last_name'] = user.last_name
        token['is_staff'] = user.is_staff
        token['is_superuser'] = user.is_superuser

        # для примера
        try:
            token['student_id'] = Student.objects.get(user=user).pk
        except Student.DoesNotExist:
            pass

        return token

Тестовая вьюха, показывающая профиль аутентифицированным пользователям:

from django.contrib.auth.models import User
from motoschool.models import Student # для примера
import json
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.serializers import ModelSerializer


 # для примера
class StudentSerializer(ModelSerializer):
    class Meta:
        model = Student
        fields = ['id', 'first_name', 'last_name', 'middle_name', 'level']


class UserSerializer(ModelSerializer):

    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name', 'as_student', 'is_staff', 'is_superuser' ]

    # для примера
    as_student = StudentSerializer()


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def me_view(request):
    return Response(UserSerializer(request.user).data)

и URLы чтобы это всё забегало:

# auth_views/jwt_urls.py
from django.urls import path

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)


urlpatterns = [

    path('', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('verify/', TokenVerifyView.as_view(), name='token_verify'),
]
# core/urls.py
from django.urls import path, include
from auth_views.views import me_view


urlpatterns = [
    # ...

    path('api/token/', include('auth_views.jwt_urls')),
    path('api/me/', me_view),
]

Если всё завелось, то после ./manage.py runserver в браузере можно сходить на 127.0.0.1:8000/api/token и авторизоваться. Или то же самое сделать cURL'ом. А по /api/me должно показать огрызок профиля, если пользователь авторизован — в браузере, если стоит сессионная кука (например, после входа в админку), а cURL'ом — если передан правильный заголовок с живым токеном.

Получение токена:

# ->
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"username": "davidattenborough", "password": "boatymcboatface"}' \
  http://127.0.0.1:8000/api/token/

# <-
{
  "access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU",
  "refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"
}

Проверка, что токен работает:

# ->
curl \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU" \
  http://127.0.0.1:8000/api/me/
# <-
{
    "id": 1,
    "username": "gb",
    "first_name": "G",
    "last_name": "B",
    "as_student": {
        "id": 1,
        "first_name": "G",
        "last_name": "B",
        "middle_name": "K",
        "level": 4
    },
    "is_staff": true,
    "is_superuser": true
}

Обновление токена:

# ->
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"}' \
  http://localhost:8000/api/token/refresh/


# <-
{"access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNTY3LCJqdGkiOiJjNzE4ZTVkNjgzZWQ0NTQyYTU0NWJkM2VmMGI0ZGQ0ZSJ9.ekxRxgb9OKmHkfy-zs1Ro_xs1eMLXiR17dIDBVxeT-w"}

Nuxt Frontend

Из зависимостей нужен nuxt, @nuxtjs/axios, cookie-universal-nuxt.

Примерно вся красота происходит в store/auth.js:

const atob = b64Encoded => Buffer.from(b64Encoded, 'base64').toString()

const setCookies = ($cookies, tokens) => {
    const user = JSON.parse(atob(tokens.access.split('.')[1]))
    $cookies.set('user', user, { path: '/' });
    $cookies.set('tokens', tokens, { path: '/' });
}

const clearCookies = ($cookies) => {
    $cookies.remove('user')
    $cookies.remove('tokens')
}

const setAuthHeader = ($axios, tokens) => {
    $axios.defaults.headers.common['Authorization'] = 'Bearer ' + tokens.access
}

const clearAuthHeader = ($axios) => {
    delete $axios.defaults.headers.common['Authorization']
}

export const state = () => ({
    user: null,
    tokens: null,
})


export const mutations = {
    setUser (state, user) {
        state.user = user
    },
    setTokens (state, tokens) {
        state.tokens = tokens
    },
}

export const actions = {
    login ({ commit, dispatch }, { username, password }) {
        return this.$axios.$post('/token/', { username, password }
        ).then(tokens => {
            commit('setTokens', tokens)
            commit('setUser', JSON.parse(atob(tokens.access.split('.')[1])))
            setCookies(this.$cookies, tokens)
            setAuthHeader(this.$axios, tokens)
        }).catch(error => {
            dispatch('logout')
            return Promise.reject(error)
        })
    },
    refresh ({ state, commit, dispatch }, refresh) {
        return this.$axios.$post('/token/refresh/', { refresh: refresh || state.tokens?.refresh }
        ).then(tokens => {
            commit('setTokens', tokens)
            commit('setUser', JSON.parse(atob(tokens.access.split('.')[1])))
            setCookies(this.$cookies, tokens)
            setAuthHeader(this.$axios, tokens)
            return tokens.access
        }).catch(error => {
            dispatch('logout')
            return Promise.reject(error)
        })
    },
    tryRecoveringFromCookie ({ dispatch } ) {
        const tokens = this.$cookies.get('tokens')
        return tokens && dispatch('refresh', tokens?.refresh)
    },
    initClientSideAxios ({ state }) {
        if (state.tokens) {
            setAuthHeader(this.$axios, state.tokens)
        }
    },
    logout ({ commit }) {
        commit('setUser', null)
        commit('setTokens', null)
        clearCookies(this.$cookies)
        clearAuthHeader(this.$axios)
    },

    setupAxios401Interceptor ({ dispatch }) {

        let alreadyIntercepting = false

        this.$axios.interceptors.response.use(
            response => response, // если не было ошибки, то и не путаемся под ногами
            error => { // а вот если она была, была именно HTTP 401, и не повторная — наш выход
                if (error.response.status === 401 && !alreadyIntercepting) {
                    alreadyIntercepting = true
                    console.log('API HTTP 401, trying to recover via token refreshing')
                    return dispatch('refresh').then(access => {
                        console.log('refreshed OK, redoing the original request')
                        error.config.headers.Authorization = 'Bearer ' + access
                        return this.$axios(error.config)
                    }).catch(() => {
                        console.log('API HTTP 401, recovering failed, logging out')
                        this.$router.push('/')
                        return dispatch('logout').then(() => Promise.reject(error))
                    }).finally(() => {
                        alreadyIntercepting = false
                    })
                } else {
                    return Promise.reject(error)
                }
            }
        )


        return Promise.resolve()
    }
}

В state есть денормализация — полезная нагрузка user живёт не только в токене, но и отдельно, расшифрованной сразу при первом получении токена.

Особое внимание — к setupAxios401Interceptor, он пытается молчаливо обновить токен после HTTP 401.

Ещё важно учесть специфику универсальности приложения: код работает и в процессе серверного рендеринга, и затем на клиенте. Поэтому может понадобиться дважды инициализировать авторизационный заголовок в axios — см. tryRecoveringFromCookie, initClientSideAxios.

Дальше всё довольно просто.

В layouts/default в секции <script>:

export default {
    mounted () {
        this.$store.dispatch('auth/initClientSideAxios').then(
            () => this.$store.dispatch('auth/setupAxios401Interceptor')
        )
    }
}

В store/index.js:

export const state = () => ({
})

export const mutations = {
}

export const actions = {
    nuxtServerInit ({ dispatch }) {
        return dispatch('auth/tryRecoveringFromCookie').then(
            dispatch('auth/setupAxios401Interceptor')
        ).then(
            () => Promise.all([
                // сюда можно добавить dispatch'и для SSR, app-wide
            ])
        )).catch(error => {
            // эта часть нужна, чтобы не падать с серверной ошибкой с совсем уж протухшими токенами
            // и при прочих неприятностях взаимодействия с API во время SSR
            console.log('ERROR during nuxtServerInit', error)
        })
    }
}

В форме логина:

methods: {
  login () {
    this.$store.dispatch('auth/login', { ...this.authForm }).then(() => {
        this.$router.push('/auth/profile')
    })
  }
},

Ну и некий компонент auth-info для шапки ($t — из @nuxt/i18n):

<template>
<div class="auth-info">
    <span v-if="isAuthorized">
      <nuxt-link to="/auth/profile" :title="$t('profile')">{{ user.username }}</nuxt-link>
      <nuxt-link to="/auth/logout">[{{ $t('logout') }}]</nuxt-link>
    </span>
    <nuxt-link v-else to="/auth/login">{{ $t('login') }}</nuxt-link>
</div>
</template>

<script>
export default {
  computed: {
    user () {
      return this.$store.state.auth.user
    },
    isAuthorized () {
      return !!this.user
    }
  }
}
</script>

<i18n lang="yaml">
en:
  login: "login"
  logout: "logout"
  profile: "profile"
ru:
  login: "войти"
  logout: "выйти"
  profile: "профиль"
</i18n>

<style lang="stylus" scoped>
.auth-info
  //
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment