Версия на русском языке с использованием Django4 и Nuxt2.
Эта дока покрывает JWT auth workflow, но не затрагивает вопросы кастомных юзеров и кастомных методов аутентификации Django. На их счёт — по первому вопросу https://testdriven.io/blog/django-custom-user-model/, а по второму — https://docs.djangoproject.com/en/4.1/topics/auth/customizing/.
- Есть Аутентификация, и есть Авторизация. Их часто путают и сливают в единое понятие, и с этим приходится жить. Но по идее это два ортогональных явления.
- Аутентификация — это удостоверение личности обращающегося. Паспорт предъявить, например. При этом может статься, что удостоверение личности никаких особых прав и возможностей тебе не даёт — просто теперь к тебе обратятся по имени. Часто даже наоборот, потеря анонимности — потенциальный риск, в случае чего тебе предъявят за хулиганство.
- Авторизация — это выяснение, имеет ли обратившийся достаточно прав для выполнения некоторого действия. При этом личность обращающегося может оставаться неизвестной — можно просто красивой ксивой, или даже стволом как символом власти моргнуть — и все всё поняли и всё принесли, и имени не спрашивают.
Токен, он же жетон — это некая штука-дрюка, которая предъявляется с целью аутентификации и/или авторизации.
Частные случаи токенов: деньги, жетоны метро, полицейские значки, солдатские жетоны, браслеты фитнес-центров, ключи домофонов, JWT-токены.
Из них, чисто аутентификационные: солдатские.
Чисто авторизационные: деньги, жетоны метро, ключи домофонов.
Смешанные: полицейские значки, браслеты фитнес-центров, JWT-токены.
Однократного использования: деньги, жетоны метро; некоторые JWT-токены.
Многократного использования: всё остальное.
Есть такой класс математических функций — (криптографические) хеш-функции. На вход им скармливается примерно всё, что угодно и некий секретный ключ, а на выход от них получается некое число — и оно выглядит совершенно случайным, никак не связанным ни с секретом, ни с входом. Алгоритмов много разных, но они стандартизированы, и есть небольшое число именованных в широком использовании.
Если говорить конкретно о подписывании, то процесс таков:
- Выпускающая сторона использует свой секретный ключ, и вычисляет с его помощью хэш от некоторой информации. Информация и хэш (подпись) отдаются куда-то наружу.
- Снаружи когда-то потом ранее выпустившим прилетает пара информация + подпись, с вопросом "это ваша подпись, удостоверяете?" — с тем же секретом хэш вычисляется заново, и если результаты совпали, то ответ "да, удостоверяем", иначе — "нет, мы такого не подписывали, вас пытаются обмануть от нашего имени". (В частном случае простой аутентификации/авторизации, обмануть пытаются саму выпускающую сторону.)
Кстати, SECRET_KEY
в settings.py
в проектах на Django — это как раз тот самый секрет для крипто-подписей.
Есть язык JavaScript, и у него есть родной для него синтаксис описания базовых структур данных: примитивов, списков, словарей. Если забыть про всё остальное в JavaScript, и оставить только эту декларативную часть, да к тому же потерять некоторое количество синтаксического сахара по пути, то получится формат обмена данными — JSON. Это просто человекочитаемый текст, последовательность байтов без хитрых непечатных спецсимволов. Удобно, модно-молодёжно, популярно. Куда лучше, чем XML, но менее лаконично, чем YAML — а в совсем сухом остатке просто string.
В цифровых ЭВМ пифагоровская религиозная догма "всё есть число" верна в строго техническом смысле: под капотом в памяти нет ничего, кроме последовательности единичек и ноликов. Как эти единицы и нолики сгруппировать, как записать и где указать границы между числами в рамках одного большого числа — способов всегда уйма.
В частности, можно использовать позиционные системы счисления с разными основаниями: можно двоичку, можно восьмиричную, можно шестнадцатеричную... А можно любую другую — 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.
(https://jwt.io/ — тут песочница и разъяснения на английском)
Коротко, JWT — это токен (см. выше), представляющий собой строку текста из трёх сегментов, разделённых точками.
Каждый из сегментов закодирован в base64.
Если их раскодировать, первые два в норме окажутся вполне читабельным и осмысленным JSON-ом: первый с метаинформацией о токене, второй — с полезной нагрузкой.
Третий сегмент — криптографическая подпись, удостоверяющая подлинность выпуска токена выпускающей стороной — хэш от первых двух сегментов, полученный с помощью секретного ключа.
- С фронтенда на бэкэнд прилетает логин-пароль или иной повод аутентифицировать/авторизовать пользователя.
- Если бэкэнд насчёт этого повода доволен (такой юзер в базе есть и активен, например), то он создаёт JWT-токен из трёх частей: метаинформация (токен такого-то типа, выпущен тогда-то, протухнет тогда-то), полезная нагрузка (выдан юзеру с таким-то именем и прочими деталями (аутентификация) и правами доступа и групповыми членствами такими-то (авторизация)), подпись (вышеизложенное верно, в случае сомнения обращайтесь ко мне, бэкэнду — проверю и подтвержу). Часто кроме этого, основного и быстро протухающего токена, одновременно выписывается дополнительный — более долгоживущий, позволяющий перевыпустить основной токен без ввода логина-пароля заново — refresh-токен.
- Токен сохраняется на фронтенде и далее используется для последующих запросов. По классике, он передаётся только в заголовке HTTP. На практике, в приложениях с серверным рендерингом, удобно его ещё и в cookies продублировать.
- Когда токен протухает, о чём свидетельствует "внезапная" ошибка HTTP 401, полезно попытаться его обновить по refresh'у, в централизованном обработчике ошибки. Если удастся, то исходный запрос можно повторить, и пользователь фазы обновления токена и не заметит даже. Если нет, то надо сделать полноценный логаут чисто на всякий случай, чтобы фронтенд не повис в каком-то невнятном промежуточном состоянии, чреватом трудноуловимыми багами.
(Django 4.0.3, Nuxt 2.15; но общий смысл не должен отличаться и в других версиях)
Ставим 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
, @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>